Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save carefree-ladka/0f08b6dc7bba844504f635823e792f61 to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/0f08b6dc7bba844504f635823e792f61 to your computer and use it in GitHub Desktop.
Google Calendar — Frontend System Design

Google Calendar — Frontend System Design

A Complete Guide to Cracking FAANG/MAANG Interviews


Table of Contents

  1. Problem Statement & Scope
  2. Functional Requirements
  3. Non-Functional Requirements
  4. Data Modelling
  5. High-Level Architecture
  6. UI Views & Rendering
  7. Recurrence Engine
  8. Drag & Drop System
  9. Timezone Handling
  10. Performance Optimisations
  11. Real-Time Sync & Conflict Resolution
  12. Offline Support & Service Workers
  13. Accessibility (a11y)
  14. API Contract
  15. Testing Strategy
  16. Interview Cheat Sheet

1. Problem Statement & Scope

Design the frontend of a Google Calendar-like application that supports:

  • Creating, editing, and deleting events
  • Multiple calendar views (month, week, day, agenda)
  • Recurring events with full RFC 5545 iCalendar RRULE support
  • Multi-timezone display
  • Drag-and-drop scheduling
  • Real-time collaboration (shared calendars)
  • Offline-first capability

Out of scope for this discussion: Backend service design, authentication infrastructure, billing systems.

Scale assumptions:

  • A power user has ~500 events/month across 5 calendars
  • View renders in < 100ms after data is cached
  • Real-time updates propagate in < 2 seconds

2. Functional Requirements

Core (P0)

  • CRUD events — title, start/end datetime, location, description, guests, colour
  • Recurring events — daily, weekly, monthly, yearly, with exceptions
  • Calendar views — month, week, 3-day, day, schedule/agenda
  • Multi-calendar — personal, shared, subscribed (read-only), birthdays
  • Invitations — accept / decline / tentative with RSVP tracking
  • Notifications — browser push, in-app badges, email digest
  • Timezone — per-user setting; display events in local time; all-day events span midnight correctly

Extended (P1)

  • Drag & drop — move event across time slots; resize event duration
  • Quick-add — natural language input ("Lunch with Tom Friday 1pm")
  • Event search — full-text across title, description, guests
  • Conflict detection — warn when two events overlap
  • Goals & tasks — separate entity type with auto-scheduling AI
  • Google Meet integration — auto-generate video link

Nice-to-have (P2)

  • Dark mode
  • Keyboard shortcuts (n = new event, t = today, 1/2/3 = day/week/month)
  • Print view
  • ICS import/export

3. Non-Functional Requirements

Quality Attribute Target How Achieved
Initial Load (TTI) < 3s on 4G Code splitting per view, skeleton screens
View Switch < 100ms Pre-rendered adjacent views, virtual scroll
Event Render (week, 200 events) < 16ms (60fps) Canvas fallback, CSS containment
Availability 99.9% Service Worker + IndexedDB offline cache
Real-time latency < 2s WebSocket / Server-Sent Events (SSE)
Accessibility WCAG 2.1 AA ARIA grid roles, keyboard nav, focus traps
Bundle size < 150KB gzipped (initial) Tree shaking, lazy routes
Memory < 100MB for 12-month view Event virtualisation, WeakMap caches

4. Data Modelling

4.1 Event Schema

The canonical event object lives in the frontend store. It mirrors the Google Calendar API v3 Event resource with some client-side additions.

interface CalendarEvent {
  // Identity
  id: string;                        // UUID, local-first generated
  calendarId: string;
  etag: string;                      // Optimistic concurrency token

  // What
  title: string;
  description?: string;
  location?: string;
  colorId?: CalendarColor;           // Enum: 'tomato' | 'flamingo' | 'tangerine' | ...
  attachments?: Attachment[];
  conferenceData?: ConferenceData;   // Google Meet link

  // When
  start: EventDateTime;              // { dateTime: ISO8601, timeZone } OR { date: 'YYYY-MM-DD' }
  end: EventDateTime;
  allDay: boolean;

  // Recurrence
  recurrence?: string[];             // RRULE, EXRULE, RDATE, EXDATE strings
  recurringEventId?: string;         // Points to the master event
  originalStartTime?: EventDateTime; // For exceptions/instances
  isException: boolean;              // This instance deviates from RRULE

  // Who
  organizer: Person;
  creator: Person;
  attendees?: Attendee[];
  guestsCanModify: boolean;
  guestsCanInviteOthers: boolean;

  // State
  status: 'confirmed' | 'tentative' | 'cancelled';
  visibility: 'default' | 'public' | 'private' | 'confidential';
  transparency: 'opaque' | 'transparent';  // Blocks time or not

  // Notifications
  reminders: {
    useDefault: boolean;
    overrides?: Reminder[];
  };

  // System
  created: ISO8601;
  updated: ISO8601;
  sequence: number;                  // iCal SEQUENCE for change tracking
  syncToken?: string;

  // Client-only (not persisted to server)
  _localStatus?: 'syncing' | 'error' | 'dirty';
  _computedInstances?: EventInstance[];  // Expanded recurrence instances
}

interface EventDateTime {
  date?: string;       // 'YYYY-MM-DD' for all-day events
  dateTime?: string;   // ISO8601 with timezone for timed events
  timeZone?: string;   // IANA tz identifier e.g. 'America/New_York'
}

interface Attendee {
  email: string;
  displayName?: string;
  self?: boolean;
  responseStatus: 'needsAction' | 'declined' | 'tentative' | 'accepted';
  organizer?: boolean;
  resource?: boolean;  // Conference room
}

interface Reminder {
  method: 'email' | 'popup';
  minutes: number;
}

4.2 Recurrence Rule (RRULE) Model

Recurrence is stored as RFC 5545 strings (the same format as iCal). The frontend ships a lightweight RRULE expansion engine.

// Parsed RRULE object (internal to the recurrence engine)
interface ParsedRRule {
  freq: 'SECONDLY' | 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
  interval?: number;           // Default 1. FREQ=WEEKLY;INTERVAL=2 = biweekly
  until?: Date;                // Recur until this datetime (exclusive)
  count?: number;              // Recur exactly N times (mutually exclusive with until)
  byDay?: ByDay[];             // e.g. ['MO', 'WE', 'FR'] or ['-1SA'] (last Saturday)
  byMonthDay?: number[];       // e.g. [1, 15] = 1st and 15th of month
  byMonth?: number[];          // e.g. [1, 7] = January and July
  bySetPos?: number[];         // e.g. [-1] = last occurrence in set
  weekStart?: DayOfWeek;       // Default MO
  exDates?: Date[];            // Dates excluded from rule (EXDATE)
  rDates?: Date[];             // Extra dates added outside rule (RDATE)
}

type ByDay = DayOfWeek | `${'-' | ''}${number}${DayOfWeek}`;
type DayOfWeek = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';

Common RRULE examples:

# Every weekday
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR

# Every 2 weeks on Monday
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO

# Last Friday of every month
RRULE:FREQ=MONTHLY;BYDAY=-1FR

# First Monday of every month
RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1

# Every year on March 15, ending after 5 occurrences
RRULE:FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=15;COUNT=5

4.3 User & Calendar Schema

interface UserSettings {
  userId: string;
  displayName: string;
  email: string;
  photoUrl?: string;
  locale: string;                   // 'en-US'
  timezone: string;                 // 'Asia/Kolkata'
  weekStart: 0 | 1 | 6;            // 0=Sun, 1=Mon, 6=Sat
  use24HourTime: boolean;
  defaultView: ViewType;
  defaultReminderMinutes: number;
  workingHours: { start: string; end: string; days: number[] };
}

interface Calendar {
  id: string;
  summary: string;
  description?: string;
  timeZone: string;
  colorId: CalendarColor;
  backgroundColor: string;         // Hex
  foregroundColor: string;         // Hex for text on background
  accessRole: 'freeBusyReader' | 'reader' | 'writer' | 'owner';
  primary?: boolean;               // User's main calendar
  selected: boolean;               // Shown in UI
  kind: 'user' | 'shared' | 'resource' | 'birthday' | 'holiday';
}

4.4 Notification Schema

interface Notification {
  id: string;
  eventId: string;
  calendarId: string;
  eventTitle: string;
  eventStart: string;
  triggerTime: string;             // When to fire the notification
  method: 'popup' | 'email';
  dismissed: boolean;
  snoozedUntil?: string;
}

5. High-Level Architecture

5.1 Component Tree

App
├── RouterProvider
│   ├── AuthGuard
│   └── CalendarShell
│       ├── TopBar
│       │   ├── Logo
│       │   ├── DateNavigator          ← prev / today / next
│       │   ├── ViewSwitcher           ← Day | Week | Month | Agenda
│       │   └── SearchBar
│       ├── SidePanel
│       │   ├── MiniCalendar           ← month thumbnail, clickable
│       │   ├── CalendarList           ← toggleable calendar checkboxes
│       │   └── CreateEventButton
│       └── MainView                   ← code-split per view
│           ├── MonthView
│           │   └── MonthGrid → WeekRow[] → DayCell[] → EventChip[]
│           ├── WeekView
│           │   ├── AllDayBanner       ← multi-day / all-day events
│           │   └── TimeGrid           ← hourly rows × 7 columns
│           │       └── TimeSlot[] → EventBlock[]
│           ├── DayView                ← same as WeekView, single column
│           └── AgendaView
│               └── AgendaGroup[] → AgendaItem[]
├── EventModal                         ← shared overlay for create/edit
│   ├── EventForm
│   ├── RecurrenceEditor
│   ├── GuestPicker
│   ├── ReminderEditor
│   └── ConferenceToggle
└── NotificationCenter

5.2 State Management Architecture

Use a layered state approach:

┌─────────────────────────────────────────────────────────┐
│  Server State  (React Query / SWR)                      │
│  - events per calendar per date range                   │
│  - user settings                                        │
│  - calendar list                                        │
│  - Stale-while-revalidate + background sync             │
└───────────────────────┬─────────────────────────────────┘
                        │ normalized + merged into
┌───────────────────────▼─────────────────────────────────┐
│  Global UI State  (Zustand / Redux Toolkit)             │
│  - currentView: ViewType                                │
│  - selectedDate: Date                                   │
│  - visibleDateRange: { start, end }                     │
│  - activeCalendars: Set<calendarId>                     │
│  - draggingEventId: string | null                       │
│  - openModal: ModalState | null                         │
└───────────────────────┬─────────────────────────────────┘
                        │ derived
┌───────────────────────▼─────────────────────────────────┐
│  Computed / Derived State  (useMemo / Reselect)         │
│  - expandedRecurrences: EventInstance[]                 │
│  - overlapGroups: OverlapGroup[]                        │
│  - conflictMap: Map<eventId, eventId[]>                 │
└─────────────────────────────────────────────────────────┘
                        │ local component state
┌───────────────────────▼─────────────────────────────────┐
│  Local / Component State  (useState / useReducer)       │
│  - form field values                                    │
│  - hover state                                          │
│  - tooltip visibility                                   │
└─────────────────────────────────────────────────────────┘

5.3 Data Flow

User Action (click/drag)
  │
  ▼
Component Handler
  │
  ├── Optimistic UI update (local state mutated immediately)
  │
  ├── API mutation dispatched (React Query mutate)
  │
  ├── Server responds:
  │     success → merge server response, clear dirty flag
  │     failure → rollback optimistic update, show toast
  │
  └── WebSocket event arrives from other collaborator
        → merge into normalized cache
        → re-render affected view slices

6. UI Views & Rendering

6.1 Month View

Layout: A 6-row × 7-column CSS grid. Each cell is a DayCell.

[Mon] [Tue] [Wed] [Thu] [Fri] [Sat] [Sun]
[ 27]  28    29    30    31   [ 1]  [ 2]   ← greyed = outside current month
[  3]   4     5     6     7     8     9
...

Challenges & solutions:

  • Multi-day events span across cells. Solved with absolute positioning relative to the week row container, not individual cells. An event from Wed–Fri gets left: 2/7 * 100% and width: 3/7 * 100%.
  • "More" overflow — a cell with > 3 events shows "+2 more" which opens a popover. Calculate visible event slots by measuring cell height at render time.
  • Memoisation — each WeekRow is wrapped in React.memo. Rows only re-render if their set of events changes (shallow compare on event ID arrays).

6.2 Week View

Layout: Two zones stacked vertically:

  1. All-day banner — a separate scrollable row for events spanning > 24h or marked allDay: true
  2. Time grid — a scrollable 24-hour × 7-column grid
         Mon 12  Tue 13  Wed 14 ...
all-day  [Team meeting──────────]
  8:00   [Standup]
  8:30
  9:00               [Design review]
 ...

Pixel-to-time mapping:

const HOUR_HEIGHT = 60;  // px per hour at 100% zoom

function timeToY(datetime: Date, dayStart: Date): number {
  const minutesFromDayStart = differenceInMinutes(datetime, dayStart);
  return (minutesFromDayStart / 60) * HOUR_HEIGHT;
}

function yToTime(y: number, dayStart: Date): Date {
  const minutes = Math.round((y / HOUR_HEIGHT) * 60);
  return addMinutes(dayStart, minutes);
}

Zoom levels: 100% (60px/hr), 150% (90px/hr), 200% (120px/hr). Zoom state is persisted in localStorage.

6.3 Day View

Identical to Week View but with a single column. The time grid takes full width, allowing longer event titles to show. The all-day banner handles the same logic.

6.4 Agenda View

A chronological flat list grouped by date. Infinite scroll loads future dates on demand (intersection observer on the sentinel element).

interface AgendaGroup {
  date: Date;           // Group header e.g. "Monday, June 12"
  events: EventInstance[];
}

// Render pattern
<AgendaContainer>
  {groups.map(group => (
    <AgendaGroup key={group.date.toISOString()} date={group.date}>
      {group.events.map(event => <AgendaItem key={event.instanceId} event={event} />)}
    </AgendaGroup>
  ))}
  <Sentinel ref={sentinelRef} />  {/* triggers load more */}
</AgendaContainer>

7. Recurrence Engine

7.1 RRULE Standard

The RFC 5545 iCalendar spec defines recurrence rules. Key properties:

Property Meaning Example
FREQ Frequency WEEKLY
INTERVAL Every N units of FREQ INTERVAL=2 (biweekly)
BYDAY Day filter BYDAY=MO,FR
BYMONTHDAY Day-of-month BYMONTHDAY=1,-1 (first & last)
UNTIL End date UNTIL=20261231T235959Z
COUNT Max occurrences COUNT=10
EXDATE Exception dates Specific dates to skip
RDATE Extra dates Additional occurrences added outside rule

7.2 Expansion Algorithm

The key insight: The server returns only master events with RRULE strings. The frontend expands them into concrete instances for the visible date range. This is called "client-side expansion."

function expandRecurrence(
  master: CalendarEvent,
  viewStart: Date,
  viewEnd: Date,
  maxInstances = 300
): EventInstance[] {
  const rule = parseRRule(master.recurrence!);
  const instances: EventInstance[] = [];
  const duration = differenceInMilliseconds(
    parseISO(master.end.dateTime!),
    parseISO(master.start.dateTime!)
  );

  let cursor = parseISO(master.start.dateTime!);
  let count = 0;

  while (cursor <= viewEnd && count < maxInstances) {
    if (cursor >= viewStart && !isExcluded(cursor, rule.exDates)) {
      instances.push(makeInstance(master, cursor, addMilliseconds(cursor, duration)));
    }
    cursor = nextOccurrence(cursor, rule);  // Core: advance by FREQ × INTERVAL
    if (rule.until && cursor > rule.until) break;
    if (rule.count && count >= rule.count) break;
    count++;
  }

  // Merge server-confirmed exceptions (edits to individual instances)
  return mergeExceptions(instances, master.id);
}

nextOccurrence implementation sketch:

function nextOccurrence(current: Date, rule: ParsedRRule): Date {
  switch (rule.freq) {
    case 'DAILY':   return addDays(current, rule.interval ?? 1);
    case 'WEEKLY':  return nextWeeklyOccurrence(current, rule);
    case 'MONTHLY': return nextMonthlyOccurrence(current, rule);
    case 'YEARLY':  return addYears(current, rule.interval ?? 1);
    // ... etc
  }
}

function nextWeeklyOccurrence(current: Date, rule: ParsedRRule): Date {
  const days = rule.byDay ?? [toDayCode(current)];
  // Find next matching day within the week, then skip by interval weeks
  // ... implementation with getDay() comparisons
}

Why client-side expansion?

  • Reduces API chattiness — one request per calendar fetch, not per rendered week
  • Enables instant navigation (no network round trip for prev/next week)
  • Allows snappy drag-and-drop preview before committing

Caching expanded instances:

// Memoised with the master event ID + RRULE string + date range as cache key
const expansionCache = new LRUCache<string, EventInstance[]>({ max: 200 });

const cacheKey = `${master.id}:${master.recurrence?.join('|')}:${viewStart.toISOString()}:${viewEnd.toISOString()}`;

7.3 Edit Modes: This / This & Following / All

When a user edits a recurring event instance, they get three choices:

1. This event only (exception)

  • Create a new exception event with recurringEventId = masterId and originalStartTime = instance.start
  • The RRULE remains unchanged; EXDATE for the original slot is added if needed (when moving, not just editing)
Master: RRULE weekly on Mon
        Instances: Mon1  Mon2  Mon3 [Mon4→Tue4 exception]  Mon5

2. This and following events

  • Truncate master's UNTIL to the day before the selected instance
  • Create a new master event starting from the selected instance with the same RRULE, optionally modified
Master A: RRULE weekly on Mon, UNTIL=Mon3
New Master B: RRULE weekly on Mon, starting Mon4 (with edits applied)

3. All events

  • Update the master event directly
  • All exceptions that no longer deviate from the new master may be cleaned up
  • Show conflict warning if this overwrites user-specific exceptions

8. Drag & Drop System

Library choice: @dnd-kit/core (lighter than react-beautiful-dnd, touch-friendly, supports accessibility).

Architecture:

// DndContext wraps the entire TimeGrid
<DndContext
  sensors={sensors}           // PointerSensor + KeyboardSensor
  collisionDetection={closestCorner}
  onDragStart={handleDragStart}
  onDragOver={handleDragOver}   // Preview snapping
  onDragEnd={handleDragEnd}     // Commit or rollback
>
  <TimeGrid>
    {events.map(event => (
      <Draggable key={event.instanceId} id={event.instanceId}>
        <EventBlock event={event} />
      </Draggable>
    ))}
  </TimeGrid>
  <DragOverlay>  {/* Renders drag ghost */}
    {draggingEvent && <EventBlock event={draggingEvent} isGhost />}
  </DragOverlay>
</DndContext>

Snapping logic:

function snapToSlot(y: number, snapMinutes = 15): Date {
  const raw = yToTime(y, dayStart);
  const mins = getMinutes(raw) + getHours(raw) * 60;
  const snapped = Math.round(mins / snapMinutes) * snapMinutes;
  return setHours(setMinutes(dayStart, snapped % 60), Math.floor(snapped / 60));
}

Recurrence drag: When dragging an instance of a recurring event, immediately prompt the user with the edit-mode dialog (This / This & Following / All). Do not commit the drag until the user confirms.

Resize handle: The bottom of each EventBlock has a 6px drag handle. Dragging it adjusts event.end while keeping event.start fixed. Minimum event duration: 15 minutes.


9. Timezone Handling

Timezone handling is among the most subtle parts of a calendar frontend. Getting it wrong causes incorrect event display across DST boundaries or for guests in other zones.

Rules:

  1. All-day events are timezone-agnostic. They are stored as date: 'YYYY-MM-DD' without time or zone. They always render as the full day in the user's local view, regardless of what timezone they're in.

  2. Timed events are stored in UTC. Display converts to the user's preferred timezone using the IANA database.

  3. DST (Daylight Saving Time) transitions: When an event that recurs weekly crosses a DST boundary, future instances should keep the same "clock time" (e.g. 9:00 AM), not the same UTC offset. This requires computing each instance's local time individually, not by adding fixed offsets.

// WRONG: adding fixed offset across DST
const nextInstance = addMilliseconds(instance, 7 * 24 * 60 * 60 * 1000);

// CORRECT: use date-fns-tz to work in the target timezone
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';

function nextWeekSameLocalTime(instance: Date, tz: string): Date {
  const localTime = utcToZonedTime(instance, tz);
  const nextLocal = addWeeks(localTime, 1);
  return zonedTimeToUtc(nextLocal, tz);
}
  1. Guest timezone display: On event detail view, show the event time in both the organizer's timezone and the viewer's timezone if they differ.

  2. "Floating" times: All-day events spanning multiple days (multi-day events entered without times) should not have their end date shifted by UTC conversion. Store as date not dateTime.

Library: date-fns-tz (tree-shakeable, 40KB) or Temporal polyfill (future-proof, better API).


10. Performance Optimisations

10.1 Virtual Rendering

The agenda view uses virtual scrolling to handle thousands of events without rendering them all:

import { useVirtualizer } from '@tanstack/react-virtual';

const virtualizer = useVirtualizer({
  count: flattenedItems.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: (i) => flattenedItems[i].type === 'header' ? 40 : 64,
  overscan: 10,
});

Month view cells that are out of the visible viewport use content-visibility: auto CSS:

.month-row {
  content-visibility: auto;
  contain-intrinsic-size: 0 120px;  /* estimated height */
}

10.2 Event Overlap Layout Algorithm

When multiple events overlap in the time grid, they must be laid out side-by-side. This is a classic interval scheduling / graph colouring problem.

Algorithm (O(n log n) sweep line):

interface LayoutColumn {
  events: EventInstance[];
  end: Date;
}

function computeOverlapLayout(events: EventInstance[]): Map<string, Layout> {
  // 1. Sort events by start time, then by duration descending
  const sorted = [...events].sort((a, b) =>
    a.start - b.start || (b.end - b.start) - (a.end - a.start)
  );

  const columns: LayoutColumn[] = [];
  const layoutMap = new Map<string, Layout>();

  for (const event of sorted) {
    // 2. Find the first column where the last event ends before this event starts
    let placed = false;
    for (let col = 0; col < columns.length; col++) {
      if (columns[col].end <= event.start) {
        columns[col].events.push(event);
        columns[col].end = event.end;
        layoutMap.set(event.instanceId, { column: col, totalColumns: 0 });  // fill totalColumns later
        placed = true;
        break;
      }
    }
    if (!placed) {
      columns.push({ events: [event], end: event.end });
      layoutMap.set(event.instanceId, { column: columns.length - 1, totalColumns: 0 });
    }
  }

  // 3. Second pass: determine max concurrent columns for each event
  for (const event of sorted) {
    const layout = layoutMap.get(event.instanceId)!;
    let maxCol = layout.column;
    for (const [otherId, otherLayout] of layoutMap) {
      const other = eventsById.get(otherId)!;
      if (other.start < event.end && other.end > event.start) {
        maxCol = Math.max(maxCol, otherLayout.column);
      }
    }
    layout.totalColumns = maxCol + 1;
  }

  return layoutMap;
}

// Render: left = (column / totalColumns) * 100%, width = (1 / totalColumns) * 100%

10.3 Caching & Prefetching

// React Query config
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,          // 5 min: don't refetch if fresh
      gcTime: 30 * 60 * 1000,            // 30 min: keep in memory
      refetchOnWindowFocus: true,
      retry: 2,
    }
  }
});

// Prefetch adjacent weeks on navigation
function prefetchAdjacentWeeks(date: Date) {
  [-1, 1].forEach(offset => {
    const adjacentWeek = addWeeks(date, offset);
    queryClient.prefetchQuery({
      queryKey: ['events', calendarId, getWeekRange(adjacentWeek)],
      queryFn: () => fetchEvents(calendarId, getWeekRange(adjacentWeek))
    });
  });
}

IndexedDB cache: Events are also persisted to IndexedDB via idb-keyval as the offline store. On first load, the app reads from IndexedDB (instant), then revalidates against the network in the background.


11. Real-Time Sync & Conflict Resolution

Sync strategy: Google Calendar uses a sync token approach:

// Initial full sync
const { items, nextSyncToken } = await calendarApi.list({ calendarId });
storeSyncToken(calendarId, nextSyncToken);

// Incremental sync (WebSocket message or polling)
const { items: changed, nextSyncToken: newToken } = await calendarApi.list({
  calendarId,
  syncToken: getSyncToken(calendarId)
});
// changed contains only added/modified/deleted events since last sync
applyIncrementalChanges(changed);
storeSyncToken(calendarId, newToken);

Conflict resolution (Optimistic Concurrency Control):

Each event has an etag (server-assigned version hash) and a sequence (iCal integer).

  • Client sends update with If-Match: <etag> header
  • If server returns 412 Precondition Failed, another client modified the event
  • Strategy: last-write-wins for non-overlapping fields, merge-on-conflict for attendee lists
  • If unresolvable: show conflict UI with diff viewer, let user pick version

WebSocket / SSE:

// Server-Sent Events for push updates (simpler than WebSocket for calendar)
const eventSource = new EventSource('/api/events/stream?token=...');

eventSource.addEventListener('event.updated', (e) => {
  const updatedEvent = JSON.parse(e.data);
  queryClient.setQueryData(
    ['events', updatedEvent.calendarId, currentRange],
    (old: CalendarEvent[]) => mergeEvent(old, updatedEvent)
  );
});

eventSource.addEventListener('event.deleted', (e) => {
  const { id, calendarId } = JSON.parse(e.data);
  queryClient.setQueryData(
    ['events', calendarId, currentRange],
    (old: CalendarEvent[]) => old.filter(ev => ev.id !== id)
  );
});

12. Offline Support & Service Workers

Service Worker strategy (Workbox):

Resource Cache Strategy
App Shell (HTML, CSS, JS) Cache First (version-busted on deploy)
Static assets (fonts, icons) Stale While Revalidate
Calendar event API responses Network First with fallback to IndexedDB
Profile images Cache First with 24hr expiry
// sw.ts
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';

// API: network-first with IndexedDB fallback
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/events'),
  new NetworkFirst({
    cacheName: 'events-cache',
    plugins: [new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 })]
  })
);

Offline event creation:

// Queue mutations when offline
const mutation = useMutation({
  mutationFn: createEvent,
  onMutate: async (newEvent) => {
    // 1. Optimistic update
    queryClient.setQueryData(['events', ...], (old) => [...old, newEvent]);
    return { previousEvents };
  },
  onError: (err, _, context) => {
    if (!navigator.onLine) {
      offlineQueue.push({ type: 'createEvent', payload: newEvent });
      return;  // Don't rollback — will sync when back online
    }
    queryClient.setQueryData(['events', ...], context.previousEvents);
  }
});

// Flush queue on reconnect
window.addEventListener('online', () => flushOfflineQueue());

13. Accessibility (a11y)

ARIA Grid Pattern

The week/day time grid implements the ARIA grid role with row and gridcell roles:

<div role="grid" aria-label="Week of June 10–16">
  <div role="row">
    <div role="columnheader" aria-label="Monday June 10">Mon 10</div>
    <!-- ... -->
  </div>
  <div role="row" aria-label="8:00 AM">
    <div role="gridcell" aria-label="Monday June 10, 8:00 AM, empty">
      <!-- Event blocks as buttons inside the cell -->
      <button role="button" aria-label="Standup, 8:00 to 8:30 AM" tabindex="0">
        Standup
      </button>
    </div>
  </div>
</div>

Keyboard Navigation

Key Action
Arrow keys Navigate between time slots
Enter / Space Open event detail
n New event at focused slot
e Edit focused event
Delete / Backspace Delete focused event (confirm dialog)
Escape Close modal / cancel drag
t Jump to today
1 / 2 / 3 Switch Day / Week / Month view

Focus Management

  • Opening the event modal traps focus inside (focus-trap-react)
  • On modal close, focus returns to the triggering element
  • Drag-and-drop has a keyboard mode: activate dragging with Space, move with arrows, confirm with Enter, cancel with Escape

Screen Reader Announcements

// aria-live region for dynamic announcements
const [announcement, setAnnouncement] = useState('');

// After drag completes:
setAnnouncement(`Moved "${event.title}" to Monday June 12 at 2:00 PM`);

// In render:
<div role="status" aria-live="polite" className="sr-only">
  {announcement}
</div>

14. API Contract

REST Endpoints (Google Calendar API v3 pattern)

GET    /calendars/{calendarId}/events
       ?timeMin=&timeMax=&singleEvents=true&syncToken=
       → { items: Event[], nextSyncToken, nextPageToken }

GET    /calendars/{calendarId}/events/{eventId}
       → Event

POST   /calendars/{calendarId}/events
       Body: Partial<Event>
       → Event (with server-assigned id, etag)

PUT    /calendars/{calendarId}/events/{eventId}
       Header: If-Match: <etag>
       Body: Event
       → Event (with updated etag)

PATCH  /calendars/{calendarId}/events/{eventId}
       Body: Partial<Event>   (field mask pattern)
       → Event

DELETE /calendars/{calendarId}/events/{eventId}
       Header: If-Match: <etag>
       → 204 No Content

POST   /calendars/{calendarId}/events/{eventId}/move
       ?destination={calendarId}
       → Event (in new calendar)

GET    /freeBusy
       Body: { timeMin, timeMax, items: [{ id: calendarId }] }
       → { calendars: { [calendarId]: { busy: Period[] } } }

WebSocket / SSE Events

type ServerEvent =
  | { type: 'event.created';  calendarId: string; event: CalendarEvent }
  | { type: 'event.updated';  calendarId: string; event: CalendarEvent }
  | { type: 'event.deleted';  calendarId: string; eventId: string }
  | { type: 'calendar.updated'; calendar: Calendar }
  | { type: 'notification.due'; notification: Notification };

15. Testing Strategy

Pyramid

E2E (Playwright, ~20 tests)
  ├── Create recurring event, verify next 4 weeks
  ├── Drag event across days, confirm API call
  ├── Offline create event, reconnect, verify sync
  └── Timezone change, verify event time updated

Integration (React Testing Library, ~80 tests)
  ├── WeekView renders correct event positions
  ├── Recurrence engine produces correct instances
  ├── Drag-and-drop fires correct mutations
  └── Edit modal shows correct edit-mode dialog for recurring

Unit (Vitest, ~200 tests)
  ├── parseRRule (all FREQ/BYDAY/BYSETPOS combos)
  ├── expandRecurrence (DST transitions, COUNT, UNTIL)
  ├── computeOverlapLayout (3-column cases, edge cases)
  ├── snapToSlot (15-min rounding)
  └── mergeExceptions (exception overrides master instances)

Key Test Cases for Recurrence Engine

describe('expandRecurrence', () => {
  test('DAILY for 5 days from Jan 1', () => { /* count=5 */ });
  test('WEEKLY on MO,WE,FR', () => { /* byDay filter */ });
  test('last Friday of month for 3 months', () => { /* BYDAY=-1FR */ });
  test('excludes EXDATE entries', () => { /* skip specific date */ });
  test('handles spring-forward DST', () => { /* 2:00 AM → 3:00 AM */ });
  test('UNTIL truncates correctly', () => { /* doesn't include UNTIL date */ });
  test('merges server exception that moved instance', () => { /* override */ });
});

16. Interview Cheat Sheet

Questions you will almost certainly be asked, and how to nail them:

"Walk me through the architecture"

Start with data model → state layers → component tree → rendering strategies. Use the tier structure: server state (React Query) → global UI state (Zustand) → derived state (useMemo) → local state (useState).

"How do you handle recurring events?"

  1. Server stores master + RRULE string only. Frontend expands instances for the visible range.
  2. Explain the sweep through the RRULE (FREQ → INTERVAL → BYDAY filters).
  3. Mention EXDATE for exceptions and the three edit modes.
  4. Show the cache key design to avoid redundant expansions.

"How do you render overlapping events?"

Describe the O(n log n) sweep line / interval scheduling algorithm. Key: sort by start time, assign to first available column (no overlap), second pass to count total concurrent columns, then set CSS left and width as percentages.

"How would you make this fast?"

  • Virtual scrolling for agenda view
  • content-visibility: auto for off-screen month rows
  • React.memo + stable event ID arrays as props
  • Prefetch adjacent week/month on navigation
  • IndexedDB for instant cold-start load
  • Code-split each view (Day/Week/Month/Agenda are separate bundles)

"How do you handle timezones?"

Stress two points: (1) all-day events are date-only, never convert to UTC, (2) weekly recurring events across DST boundaries must compute each instance's local time independently, not add fixed milliseconds.

"How would you support offline?"

Service Worker (Workbox) caches API responses. Mutations while offline are queued in localStorage / IndexedDB. On reconnect, the offline queue is flushed in order. Conflict detection via etag + sequence.

"What are your non-functional requirements?"

Lead with the 60fps constraint for drag-and-drop, then < 3s TTI, then < 2s real-time sync. Always quantify.

"What would you do differently at 10x scale?"

  • Push RRULE expansion to the server with pagination (cursor-based)
  • Switch to a canvas-based rendering engine for the time grid (like Google Calendar itself does for performance at scale)
  • Use a CRDT (e.g. Yjs) instead of OCC for true real-time collaborative editing

This document covers the frontend system design comprehensively enough to pass system design rounds at Microsoft, Amazon, Uber, Google, Meta, and similar companies. Focus on the data model and recurrence engine — they are the most differentiating areas that separate strong candidates from the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment