Calendar System Design

A calendar service stores, presents, and synchronizes time-bounded events on behalf of users — meetings, appointments, recurring obligations, all-day reminders — across multiple devices, time zones, and (often) multiple federated systems. The dominant industrial deployments are Google Calendar (~500M+ active users; bundled into Gmail and Workspace), Microsoft Outlook Calendar / Exchange (corporate-dominant; tightly integrated with Office 365 and Teams), Apple Calendar (default on iOS/macOS, syncs via CalDAV / iCloud), and Calendly (the booking-link layer that sits on top of these and lets external participants pick from your free time). Despite the surface simplicity (“CRUD on events”), calendar is one of the most algorithmically subtle systems in productivity software because of three intertwined problems that no amount of compute throws at: time-zone correctness across daylight-saving transitions and tzdata updates, recurring-event modeling that scales without materializing infinite event sequences, and multi-attendee free/busy intersection at hundreds-of-attendees latency. Get any of those wrong and the user discovers it at the worst possible moment — they show up an hour late to a meeting because DST shifted, or a “first Monday of every month for 10 years” recurrence creates 120 rows in storage that drift out of sync, or the “find a slot when 30 people are free” UI takes 30 seconds and the user gives up.

1. Why This System Is Distinctive

Three structural challenges separate calendar from everything around it:

  • Time is not a flat number line. Every event has a wall-clock time, a time zone, an offset that varies with daylight saving, leap seconds, and a calendar (Gregorian — but date math also has to handle some edge cases like leap years and the Julian-to-Gregorian shift if you go back centuries). Whichever timestamp representation you pick, you’re going to lose data unless you store enough to reconstruct the user’s intent.
  • Recurrence is infinite by spec. “Every Monday” continues forever. “First Monday of the month” is even harder — depending on which Mondays the months have, the Mondays themselves drift in their position in the recurring sequence. Recurrences can have exceptions (move just this one instance to Tuesday), which need to be representable without exploding storage.
  • Many-attendee free/busy is a constraint-satisfaction problem. Find a 30-minute slot when 30 specific people are all free, in their respective time zones, respecting their working hours, declining hard conflicts. The naive intersection of 30 calendars is O(N × events) and in practice slow.

Layer on the federation requirement (your calendar must interoperate with calendars at other companies via iCalendar, CalDAV, Exchange Web Services), the multi-device sync requirement (laptop, phone, watch all need consistent state), and the legal/compliance requirements (some industries can’t permanently delete a meeting log), and the system grows substantial.

2. Requirements

2.1 Functional Requirements

  1. Create / edit / delete events. Single events have a start, end, title, description, location, attendees, and notification settings.
  2. All-day events. Events without a specific time (e.g., “Sprint Demo Day”) that span an entire day in the user’s local time zone.
  3. Recurring events. Daily / weekly / monthly / yearly / custom (RRULE syntax — see §8). With exceptions: a single instance can be moved or cancelled.
  4. Time-zone-aware events. Events stored in a way that survives DST transitions, the user moving across time zones, and tzdata updates that reclassify a region.
  5. Invitations and RSVP. Organizer adds attendees; attendees receive notifications and respond Yes/No/Maybe. RSVP state syncs back to organizer.
  6. Free/busy lookup. Show whether an attendee is free or busy in a window without disclosing meeting details (a permission-controlled overlay).
  7. Calendar sharing. A user can share their calendar with another user (read-only, busy-time-only, or read-write). Multi-user access patterns are common — managers/assistants, family.
  8. Multiple calendars per user. A user might have “Personal”, “Work”, “Family”, “Holidays” — each independently colored, toggleable, shareable.
  9. Reminders / notifications. Triggered N minutes before the event start, via push, email, or in-app notification.
  10. Federation. Subscribe to a public iCalendar feed (sports schedules, holidays); export your calendar as .ics for others.
  11. “Find a slot” / Calendly-style booking. Given a set of attendees and constraints (working hours, duration), present available slots.
  12. Search. Find events by title / location / attendees.
  13. Conflict detection. When creating a new event, warn if it overlaps existing events.
  14. Auto-parse from email. Many providers parse hotel and flight reservations from the user’s inbox into calendar events automatically. (Tightly tied to Email Service System Design.)

2.2 Non-Functional Requirements

  • Event load latency. < 100 ms to load the next 7 days of events for a user.
  • Free/busy across many attendees. < 500 ms for 100 attendees over a 1-week range.
  • Scale. Billions of events stored; tens of millions of users. Google Calendar is part of the Google Workspace, with hundreds of millions of active business users plus the consumer base.
  • Time-zone correctness. No event should be presented at the wrong wall-clock time; this is the user’s primary trust property.
  • Recurrence correctness. Materialized event instances must align exactly with the recurrence rule across DST shifts and tzdata updates.
  • Sync / device consistency. Edits on one device propagate to all others within seconds (push sync) or at most minutes (poll sync via CalDAV).
  • Availability. ≥ 99.9%; calendar outages are particularly visible because they hit during the workday.
  • Compliance. GDPR (right to delete personal events), eDiscovery (legal hold of meetings), audit logs.

3. Capacity Estimation

active users                     ~ 5 × 10^8 (Google Calendar approx)
events created per user/year     ~ 500 (rough average)
total events stored              ~ 10 × 5×10^8 × 500 = 2.5 × 10^12 over decade
recurring rules per user         ~ 10–50 active
storage per event metadata       ~ 1 KB (excluding attachments)
total storage                    ~ 2.5 × 10^12 × 1 KB = 2.5 PB raw + 3× replication = 7.5 PB
peak event reads/sec             ~ 10^5 (users open calendar)
peak free/busy queries/sec       ~ 10^4 (during scheduling tools' use)
sync traffic                      ~ 10^5 push events/sec at peak

Compared to email (10× larger), calendar is a smaller storage problem but a more interesting algorithmic problem.

4. API

# Calendars
GET    /v1/calendars                                 # list user's calendars
POST   /v1/calendars                                 # create
PATCH  /v1/calendars/{id}
DELETE /v1/calendars/{id}

# Events
GET    /v1/calendars/{id}/events?from=&to=&q=&page_token=
POST   /v1/calendars/{id}/events
   Body: {
     start: "2026-05-12T09:00:00",
     end:   "2026-05-12T10:00:00",
     timezone: "America/New_York",
     title, description, location,
     attendees: [{email, response, optional}],
     reminders: [{minutes_before, type}],
     recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO"],
     visibility: "default|public|private"
   }
   → 201 {event_id}

PATCH  /v1/events/{event_id}                         # whole event
PATCH  /v1/events/{event_id}?instance=2026-05-19     # single recurring instance
DELETE /v1/events/{event_id}                         # whole series
DELETE /v1/events/{event_id}?instance=2026-05-19     # single instance

# RSVP
PATCH  /v1/events/{event_id}/rsvp                    # accept/decline/tentative

# Free/busy
POST   /v1/freebusy
   Body: {
     attendees: ["user1@", "user2@"],
     time_min: "2026-05-12T00:00:00Z",
     time_max: "2026-05-19T00:00:00Z"
   }
   → { busy_per_attendee: { "user1@": [{start, end}, ...], ... } }

# Calendly-style "find a slot"
POST   /v1/availability
   Body: {
     attendees, duration_minutes, working_hours, time_min, time_max
   }
   → { available_slots: [{start, end}, ...] }

# Sharing
POST   /v1/calendars/{id}/acl                        # grant access to another user

# Sync (CalDAV-style)
GET    /v1/calendars/{id}/changes?token=...          # incremental fetch

5. Data Model

5.1 Events

The core table. The choice is between storing wall-clock times + IANA zone string or storing UTC timestamps. The argument for the former is conclusive:

CREATE TABLE events (
    event_id        BIGINT PRIMARY KEY,
    calendar_id     BIGINT,
    organizer_id    BIGINT,
    title           VARCHAR(1024),
    description     TEXT,
    location        VARCHAR(1024),
 
    -- Wall-clock time + zone, NOT UTC for non-all-day events
    start_local     TIMESTAMP WITHOUT TIME ZONE,    -- "2026-05-12 09:00:00"
    end_local       TIMESTAMP WITHOUT TIME ZONE,
    timezone_name   VARCHAR(64),                     -- "America/New_York" (IANA)
 
    all_day         BOOLEAN,                         -- if true, time fields are 00:00:00
 
    -- Materialized UTC for indexing / querying
    start_utc       TIMESTAMP,                       -- derived from start_local + tz at write
    end_utc         TIMESTAMP,
 
    -- Recurrence
    rrule           TEXT NULL,                       -- RFC 5545 RRULE string
    rrule_until_utc TIMESTAMP NULL,                  -- the UNTIL clause (for index)
    parent_event_id BIGINT NULL,                     -- if this is a moved instance of a recurrence
 
    visibility      ENUM('default','public','private'),
    sequence        INT NOT NULL DEFAULT 0,          -- iCalendar SEQUENCE; bump on edit
    uid             VARCHAR(255) NOT NULL,           -- iCalendar UID for federation
    created_at      TIMESTAMP,
    updated_at      TIMESTAMP,
 
    INDEX idx_calendar_start (calendar_id, start_utc),
    INDEX idx_organizer (organizer_id, start_utc),
    INDEX idx_uid (uid)
);

Why store wall-clock time + IANA zone, not just UTC. Suppose a user in New York creates a recurring “every Monday at 9 AM” event. If we store UTC (1 PM), then when DST shifts on November 1, “9 AM ET” becomes “2 PM UTC” — but if we store UTC and naively expand, the November 1 instance happens at 1 PM UTC = 8 AM ET, which is wrong. The user wanted 9 AM ET every Monday, not “the same UTC moment that was 9 AM ET on the day I created it.” The correct storage: the local time and the zone; expand to UTC at query time using current tzdata. This also handles user-traveling: the event is always at 9 AM in the originating zone, regardless of where the user is when reading.

The materialized start_utc / end_utc are convenience columns for indexing and “give me events in this UTC range” queries; they’re recomputed when an event is edited or when the tzdata changes.

For all-day events: start_local is 2026-05-12 00:00:00, end_local is 2026-05-13 00:00:00, timezone_name is the user’s home timezone, all_day = TRUE. All-day events are presented in the viewer’s local zone — they “show up on May 12” regardless of where the viewer is.

5.2 Recurring Events — The Storage Question

A “first Monday of every month for 10 years” event: do you store 120 rows or 1?

The right answer: 1 row with the rule, expand on read.

events:
  (event_id=42, start_local="2026-05-04 09:00", rrule="FREQ=MONTHLY;BYDAY=1MO")

When the user views May 2026, the query computes occurrences in [May 1, June 1) using the rrule expansion algorithm, returning the May 4 instance. When the user views any other month, similar logic. No materialized rows for future instances.

But what about exceptions? Suppose the user moves the May 4 instance to May 5:

events:
  (event_id=42, ..., rrule="FREQ=MONTHLY;BYDAY=1MO", exdates=["2026-05-04"])
event_overrides:
  (parent_event_id=42, recurrence_id="2026-05-04", start_local="2026-05-05 09:00", ...)

The exdates list says “skip this date in the rule”; the override row supplies the moved instance.

Cancellation of one instance:

exdates=["2026-05-04", "2026-09-07"]   # skip these

This compact representation handles arbitrary edits without ever materializing the unbounded sequence.

5.3 Attendees

CREATE TABLE event_attendees (
    event_id   BIGINT,
    attendee_email VARCHAR(320),
    attendee_user_id BIGINT NULL,    -- if internal
    response   ENUM('needs_action','accepted','declined','tentative'),
    optional   BOOLEAN,
    organizer  BOOLEAN,
    PRIMARY KEY (event_id, attendee_email)
);

5.4 Free/Busy Index

For free/busy lookup at scale, maintain a per-user, per-day busy-time index:

CREATE TABLE free_busy (
    user_id    BIGINT,
    day_utc    DATE,
    busy_intervals JSON [{start_utc, end_utc, event_id}],
    last_updated TIMESTAMP,
    PRIMARY KEY (user_id, day_utc)
);

Rebuilt incrementally on every event create/edit/delete. Asking “is alice@ free Tuesday 2-3 PM?” is one row lookup + interval check; “find slot for 30 attendees” is 30 row lookups + interval intersection — fast.

5.5 Sharing / ACL

CREATE TABLE calendar_acl (
    calendar_id BIGINT,
    grantee_id  BIGINT,
    role        ENUM('reader','freebusy_only','writer','owner'),
    PRIMARY KEY (calendar_id, grantee_id)
);

6. High-Level Architecture

flowchart TB
    Client[Web / Mobile / Native Client] --> CDN[CDN]
    CDN --> APIGW[API Gateway]
    APIGW --> Auth[Auth Service]
    APIGW --> EventSvc[Event Service]
    APIGW --> RecurSvc[Recurrence Expander]
    APIGW --> FreeBusy[Free/Busy Service]
    APIGW --> Search[Search Service]
    APIGW --> Sharing[Sharing / ACL Service]

    EventSvc --> EventDB[(Event Store<br/>per-user shards)]
    EventSvc --> Reminder[Reminder Scheduler]
    EventSvc --> Audit[Audit Log]
    Reminder --> NotifSvc[Notification Service]

    RecurSvc --> EventDB
    RecurSvc --> TZDB[(IANA tzdata<br/>regularly updated)]

    FreeBusy --> FreeBusyDB[(Free/Busy Index<br/>per-user, per-day)]
    FreeBusy --> EventDB

    Search --> SearchIdx[(Per-User<br/>Inverted Index)]

    EventSvc --> ChangeStream[Kafka<br/>change stream]
    ChangeStream --> SyncSvc[Sync Service]
    SyncSvc --> Client
    ChangeStream --> Federation[CalDAV / iCal Outbound]
    ChangeStream --> SearchIdx
    ChangeStream --> FreeBusyDB

    NotifSvc --> APN[APNS / FCM Push]
    NotifSvc --> EmailOut[Email]

    External[External CalDAV Servers] -.iCalendar/CalDAV.-> Federation

What this diagram shows. Reads of events go through the Event Service, which delegates to the Recurrence Expander for any RRULE-bearing rows in the queried window — recurrences are expanded on the fly, not materialized. The expander reads from the IANA tzdata (which is itself a large reference database that updates whenever a country changes its DST policy). Free/busy queries hit a per-user busy-day index rebuilt incrementally on event change. Reminder scheduling is a separate service consuming a precomputed schedule of (event_id, fire_at) tuples. The architecturally interesting piece on the right is the change stream — every event create/edit/delete is published to Kafka, which fans out to: the sync service (push to client devices), federation gateway (export .ics updates to subscribed external systems), search index updater, and free/busy index updater. This change-stream pattern is how multi-device sync stays consistent without complex two-phase coordination — the database is the source of truth; everything downstream is a derived projection.

7. Request Flow / Sequence Diagrams

7.1 Create a Recurring Event

sequenceDiagram
    participant U as User Web App
    participant API as API Gateway
    participant E as Event Service
    participant DB as Event Store
    participant FB as Free/Busy Index
    participant K as Change Stream (Kafka)
    participant S as Sync Service
    participant Other as User's Other Devices
    participant N as Notification Svc

    U->>API: POST /events {start, end, tz, RRULE=WEEKLY;BYDAY=MO, attendees=[...]}
    API->>E: forward
    E->>E: validate (consistent tz, end > start, valid RRULE)
    E->>DB: INSERT events row + attendee rows
    E->>FB: enqueue free/busy rebuild for organizer
    E->>K: publish change event
    K->>S: relay
    S->>Other: WebSocket push
    K->>FB: trigger rebuild
    K->>N: schedule reminder firings
    N->>N: persist (event_id, fire_at, channel) tuples
    E-->>U: 201 {event_id}

7.2 Free/Busy Across Many Attendees

sequenceDiagram
    participant U as Organizer
    participant API as API Gateway
    participant FB as Free/Busy Service
    participant FBdb as Free/Busy Index

    U->>API: POST /freebusy {attendees=[a@,b@,...], time_min, time_max}
    API->>FB: forward
    par parallel per attendee
        FB->>FBdb: get busy_intervals for a@ in [time_min, time_max]
        FBdb-->>FB: intervals
    and
        FB->>FBdb: get busy_intervals for b@
        FBdb-->>FB: intervals
    end
    FB->>FB: format response (per-attendee or merged)
    FB-->>U: 200 {busy_per_attendee}

7.3 Reminder Firing

sequenceDiagram
    participant Scheduler as Reminder Scheduler
    participant N as Notification Svc
    participant Push as APNS/FCM

    Note over Scheduler: every minute scan for reminders due
    Scheduler->>N: emit reminder (user, event_id)
    N->>Push: send push to all user devices

The reminder scheduler is essentially Distributed Task Scheduler System Design — same problem.

8. Deep Dive 1 — Recurrence Rules (RRULE)

The recurrence model is defined by RFC 5545 §3.3.10, the iCalendar RRULE syntax. The expressiveness is substantial; a serious implementation must handle the full grammar.

8.1 RRULE Grammar (essential subset)

RRULE:FREQ=<freq>[;INTERVAL=<n>][;COUNT=<n> | UNTIL=<date>]
       [;BYDAY=<day-list>][;BYMONTHDAY=<n>][;BYMONTH=<n>]
       [;BYSETPOS=<n>][;WKST=<day>]

Where:

  • FREQ: the base frequency — DAILY, WEEKLY, MONTHLY, YEARLY, HOURLY, MINUTELY, SECONDLY.
  • INTERVAL: every Nth occurrence (e.g., every 2 weeks).
  • COUNT: total occurrences (5 occurrences and stop).
  • UNTIL: keep recurring until this date.
  • BYDAY: which days of the week (MO,TU,WE,TH,FR); for monthly/yearly, prefix with a number to mean “the Nth such day” (1MO = first Monday).
  • BYMONTHDAY: 15 = the 15th of each month; -1 = last day of month.
  • BYMONTH: 1–12.
  • BYSETPOS: positional filter applied after other expansions (e.g., the first or last day matching).
  • WKST: which day starts the week (default MO); affects BYWEEKNO semantics.

8.2 Examples

"Every weekday at 9 AM":
   RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR

"Every other week on Tue and Thu":
   RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH

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

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

"Every 4 years on Feb 29 (leap day)":
   RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=2;BYMONTHDAY=29

"5 occurrences of every Tuesday":
   RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=5

"Until end of 2026":
   RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261231T235959Z

8.3 Expansion Algorithm

Given an event with start time S and RRULE R, produce the set of occurrences in window [T_min, T_max]:

def expand(event, t_min, t_max):
    rule = parse_rrule(event.rrule)
    candidates = generate_candidates(event.start_local, rule, t_min, t_max)
    # candidates are tuples of local datetimes per the rule
    occurrences = []
    for cand in candidates:
        # convert local + zone to UTC using current tzdata
        utc_start = local_to_utc(cand, event.timezone_name)
        utc_end   = utc_start + (event.end_local - event.start_local)  # duration preserved
        if utc_start < t_max and utc_end >= t_min:
            occurrences.append((cand, utc_start, utc_end))
    return apply_overrides(event, occurrences)

Generation walks forward from event.start_local in steps of INTERVAL × FREQ units (a day, a week, a month, a year), filtering candidates by BYDAY / BYMONTHDAY / etc. Stops when the candidate exceeds t_max or hits COUNT or UNTIL.

The Python dateutil.rrule library is a widely used reference implementation; production systems often have their own. The full RRULE grammar is gnarly enough that implementing it from scratch is a several-thousand-line endeavor.

This single-rule-plus-exceptions model is exactly what real APIs expose. The Google Calendar API carries recurrence as an array of strings holding RRULE, RDATE, and EXDATE properties “as defined in RFC 5545”; a modified instance becomes an exception event that points back to its series via recurringEventId and records which occurrence it replaces via originalStartTime — the API analogue of the iCalendar RECURRENCE-ID property and the event_overrides table in §5.2. EXDATE is the API analogue of the exdates list, and a cancelled instance is an exception with status: "cancelled". The point is that the storage model above is not an implementation invention; it is the data shape the standards and the major APIs all converge on.

8.4 The Time-Zone Trap in Recurrences

This is the trap that bites every implementation at least once.

Suppose a user in New York creates “every Monday at 9 AM ET” recurring forever. Stored as start_local="09:00", timezone_name="America/New_York", rrule="FREQ=WEEKLY;BYDAY=MO".

On the second Sunday of November (DST ends in the US), 2 AM falls back to 1 AM. Monday at 9 AM ET is still 9 AM ET, but in UTC it’s now 14:00 instead of 13:00 (the offset changed). The expansion code must:

  1. Generate the local Monday 9 AM.
  2. Convert local + IANA zone → UTC using the current tzdata, which knows about the DST transition.

If you stored UTC and naively added 7 days × N for each Monday, you’d hit 9 AM ET in summer and 8 AM ET in winter (or vice versa) — the user would show up at the wrong time half the year.

8.5 Tricky DST and Leap-Year Edge Cases

RFC 5545 is less silent on these than folklore suggests. The rule lives in §3.3.5 (the DATE-TIME value type), not in any “§3.7.5” (which does not exist — §3.7 is “Calendar Properties” and stops at §3.7.4). §3.3.5 specifies both transition behaviors explicitly, and gives worked New-York examples:

  • Spring forward (the gap). “If the local time described does not occur (when changing from standard to daylight time), the DATE-TIME value is interpreted using the UTC offset before the gap in local times” (RFC 5545 §3.3.5). The RFC’s own example: March 11, 2007 at 2:30 AM in New York “refers to EDT,” i.e. one hour after the non-existent 2:30 AM EST. Concretely, applying the pre-gap offset (EST, −5) to 2:30 means 07:30 UTC, which in the post-gap zone (EDT, −4) reads as 3:30 AM — so the spec effectively slides the event forward by the lost hour. It does not skip it. (The older draft of this note claimed “RFC 5545 doesn’t strictly mandate; common practice is to skip” — that is wrong; the spec mandates the pre-gap-offset interpretation. Some libraries nonetheless deviate.)
  • Fall back (the doubled hour). “If, based on the definition of the referenced time zone, the local time described occurs more than once (when changing from daylight to standard time), the DATE-TIME value refers to the first occurrence” (RFC 5545 §3.3.5). The RFC’s example: November 4, 2007 at 1:30 AM in New York “refers to EDT” — the first (daylight-time) occurrence. So an event at “1:30 AM ET” on a fall-back night fires once, in the pre-transition offset.

The note’s earlier instinct (take the first occurrence on fall-back) was right; the citation and the spring-forward claim were not, and are corrected above.

  • Leap day (Feb 29). “Yearly on Feb 29” works fine in 2024, 2028 — but what does it mean in non-leap years? Most implementations skip; some slide to Feb 28 or Mar 1. RFC 5545 doesn’t precisely specify; you have to pick.
  • Leap second (a real-world but rare insertion of an extra second on June 30 or December 31). UTC has had 27 leap seconds inserted since 1972, the most recent on December 31, 2016; the international community has resolved to stop inserting them by 2035. Calendar systems mostly ignore them (the IANA tz database absorbs them silently); but if your time math ever runs at second-precision, you must be aware.

8.6 IANA tzdata Updates

The IANA Time Zone Database (www.iana.org/time-zones) is the global reference for time-zone offsets and DST rules, maintained by Paul Eggert and contributors. It is revised several times a year as governments change DST policy — four releases in 2023, two in 2024 (per the IANA release index). Recent real-world examples: Russia abolished DST in 2014; Brazil abolished DST in 2019; US legislation (the “Sunshine Protection Act”) has repeatedly almost-but-not-quite made DST permanent.

Calendar systems must update their tzdata on every release and re-render any cached UTC times. The “real way DST changes break calendars” historically: a country (e.g., Egypt) announces a DST change with two weeks’ notice; cloud providers ship updated tzdata; your calendar service is cached on a stale tzdata for those two weeks; events for users in that zone display at the wrong wall-clock time.

Pattern: store wall-clock + zone (the user’s intent), recompute UTC at every read using current tzdata. Re-render on tzdata update.

9. Deep Dive 2 — Free/Busy and “Find a Slot”

The query that powers every meeting scheduler.

9.1 The Naive Approach Is Slow

To answer “is alice@ free at 2-3 PM Tuesday?”, we could query all of alice’s events in that window and check overlap. For 30 attendees in a 1-week window, that’s 30 × ~50 events = 1500 row reads, plus parsing recurrences. Not infeasible, but at 500 ms budget, we have to be careful.

9.2 The Materialized Free/Busy Index

For each user, maintain a per-day list of [(busy_start, busy_end, event_id), ...] precomputed and sorted:

user_id=alice@,  day_utc=2026-05-12,
busy_intervals=[
  {start_utc: "2026-05-12T09:00:00Z", end_utc: "2026-05-12T10:00:00Z", event_id: 42},
  {start_utc: "2026-05-12T14:00:00Z", end_utc: "2026-05-12T15:00:00Z", event_id: 99}
]
  • Updated on every event create/edit/delete (the change stream feeds the rebuilder).
  • Recurring-event instances are pre-expanded into the index for the next ~6 months (rolling window); long-tail recurrences are computed on demand.
  • A query “is alice@ free at 14:00–14:30 UTC on May 12” reduces to: read one row, sort the intervals (already sorted), binary-search for overlap.

Per-attendee free/busy → answered in microseconds.

9.3 Multi-Attendee Intersection

For 30 attendees, parallel-fetch each one’s busy-day rows for the queried window. Then merge / intersect:

def find_free_slots(attendees_busy, t_min, t_max, duration):
    # all_busy: union of all attendees' busy intervals, sorted by start
    all_busy = sorted(flatten([b for a in attendees_busy for b in a]),
                      key=lambda x: x.start)
    merged = merge_overlapping(all_busy)
    # walk the gaps
    free_slots = []
    cursor = t_min
    for busy in merged:
        if busy.start > cursor:
            gap = busy.start - cursor
            if gap >= duration:
                free_slots.append((cursor, busy.start))
        cursor = max(cursor, busy.end)
    if cursor + duration <= t_max:
        free_slots.append((cursor, t_max))
    return free_slots

merge_overlapping is the standard interval-merge algorithm, O(N log N) for sort + O(N) for sweep. For 30 attendees × 50 events = 1500 intervals, sub-millisecond. Working hours and time zones overlay as additional intervals.

9.4 Calendly-Style Booking

Calendly’s value prop: a public link (calendly.com/alice/30min) that anyone can visit and pick a slot from Alice’s available time. The mechanics:

  1. Alice configures her schedule (e.g., “30-minute meetings, weekdays 9 AM–5 PM ET, with 15-minute buffer between meetings”).
  2. Calendly maintains Alice’s calendar feed (via Google Calendar API or CalDAV) — knows Alice’s existing busy times.
  3. A visitor opens the link; Calendly computes the intersection of “in working hours” + “not busy” + “with buffer” → returns the next ~14 days of available slots.
  4. Visitor picks → Calendly creates an event on Alice’s calendar (via the same API), invites the visitor, sends notifications.

The “find a slot” computation is what we just walked through, with extra constraint logic for buffers and working hours.

Calendly’s engineering blog discusses the architectural patterns: aggressive caching of upstream calendar data, eventual-consistency with explicit reconciliation when conflicts are detected, and the time-zone correctness rabbit hole (the visitor and the host might be in different time zones; both must see correct local times). It is worth noting that the underlying Google Calendar API offers both polling (via sync tokens) and push (the watch mechanism: a web_hook channel that POSTs a notification to a callback URL on every change, with channels that expire and must be explicitly renewed). So a Calendly-style aggregator is not forced to poll — it can subscribe to push and fall back to a periodic full re-sync to catch missed notifications and channel expirations.

9.5 The Federation Issue

If Alice’s calendar is in Google and one of her invitees is in Microsoft, the free/busy query has to span systems. iCalendar Free/Busy (.ifb files), Microsoft Exchange Web Services, and CalDAV all support free/busy queries; some support negotiated permissions (“Bob’s busy time is visible to Alice but not to others”). Cross-platform free/busy queries are async and slow (potentially seconds) due to network hops and the lack of a unified push mechanism. Calendly papers over this by polling each platform.

10. Deep Dive 3 — Multi-Device Sync and CalDAV

10.1 The Sync Problem

A user has a phone, a laptop, and a watch. They edit an event on their laptop while their phone is offline. When the phone reconnects, it needs to:

  1. Learn about the laptop’s edits.
  2. Push any edits made on the phone (probably none, but possibly).
  3. Resolve any conflicts.

The general solution is the incremental sync protocol with sync tokens.

10.2 The Sync Token Protocol

Each device, on first sync, fetches the full calendar state and receives a sync_token (a server-generated cursor). On subsequent syncs:

GET /calendars/abc/changes?since=<sync_token>
→ {
    changes: [
       {action: "create", event: ...},
       {action: "update", event: ...},
       {action: "delete", event_id: ...}
    ],
    next_sync_token: "..."
  }

The server gives only the changes since the token; the device applies them locally. Tokens may expire (after a few weeks); on token expiry, full re-sync.

This is essentially the change-stream pattern. Backed by the Kafka stream from §6.

10.3 CalDAV

RFC 4791 defines CalDAV — a calendar protocol over WebDAV (over HTTP). Used by Apple Calendar, Thunderbird’s Lightning, and many corporate calendars. The protocol supports:

  • Calendar discovery (find calendars on a server).
  • Listing events (querying by time range).
  • Creating / updating / deleting events.
  • ETag-based optimistic concurrency (if a device tries to update an event whose ETag has changed, the update is rejected; client refetches).
  • ScheduleSet (server-mediated invitation routing).

CalDAV is the open, standards-compliant federation protocol — but the claim that the big proprietary systems “also expose CalDAV for compatibility” is only partly true and worth getting right. Apple’s iCloud Calendar is genuinely CalDAV under the hood. Google offers a REST API as its primary interface and has historically supported CalDAV, but that CalDAV support is being de-emphasized in favor of the proprietary API. Microsoft Exchange Online / Microsoft 365 does not speak CalDAV natively — its interfaces are Exchange Web Services (EWS, now legacy) and Microsoft Graph; CalDAV interop with Microsoft requires third-party bridge tools. So “everyone exposes CalDAV” is a myth: in practice CalDAV is strong in the Apple/open-source world and weak-to-absent in the Microsoft world.

10.4 Conflict Resolution

When two devices edit the same event offline and both later sync, a conflict resolution policy is needed:

  • Last-write-wins (LWW). Server timestamps each write; later timestamp wins. Simple but can silently lose edits.
  • Field-level merge. If A changed the title and B changed the location, merge — both edits land. Doesn’t always work (concurrent title edits still conflict).
  • Conflict copies. Show both versions to the user, let them pick. Most user-friendly but interrupts the user.

Most calendar systems use LWW with explicit warnings for major conflicts (RSVP changes, attendee additions). The SEQUENCE field in iCalendar (SEQUENCE: 3) increments on every meaningful change; receivers can detect missed updates.

11. Scaling Strategy

11.1 Shard by User

Each user’s calendar (their multiple calendars, events, free/busy index, search index) lives on one shard. Cross-user operations are explicit (sharing, invitations) and rare relative to single-user reads.

11.2 Pre-Expand Recurrences for Hot Window

Recurring-event expansion is CPU-light but constant; for “show me my next 7 days” the expander runs hundreds of expansions per request. Optimization: cache the next-N-months expansions per recurring event, invalidate on edit.

11.3 Free/Busy Index in a Fast Store

Free/busy queries are latency-sensitive (Calendly waits for them on every page load). Store the per-user-per-day index in Redis or a fast KV store; rebuild on event changes via the change stream.

11.4 Reminder Scheduling at Scale

A billion users × ~5 reminders per user per day = ~5 × 10⁹ reminders per day = ~60K reminders/sec. The naive “scan a single timer table every minute for due reminders” doesn’t scale. Architectures:

  • Bucketed scheduling. Reminders are stored in time-bucketed queues (one queue per minute, with reminders for that minute). Scan only the current minute’s bucket.
  • Time-wheel. A circular array of N buckets where bucket i holds reminders due at minutes i mod N from now. The “tick” advances every minute, processing bucket contents.
  • Distributed message queue. Each reminder is a delayed message in a system like AWS SQS (with a delay parameter). The queue becomes ready at the firing time.

See Distributed Task Scheduler System Design for the full pattern.

11.5 Search Index Per User

Calendar search (find events by title) uses a per-user inverted index, similar to email. Smaller scale than email; same pattern.

11.6 Range Queries on Time

The question “give me all events between time T1 and T2” is a range query on start_utc. With B+ Tree indexes on (calendar_id, start_utc), this is fast. B+ Tree is the right index structure here precisely because of its range-query strength — given a calendar ID and a start time, walk the leaf nodes to gather events in the window.

12. Real-World Example

Google Calendar

  • ~500M+ users (estimated; bundled into many products).
  • Backed by Google’s broader calendar / Apps infrastructure; deeply integrated with Gmail (auto-event-detection from booking emails), Meet (one-click video calls), Tasks, Reminders.
  • Public API: Google Calendar API. Supports RFC 5545 RRULEs, free/busy queries, push notifications via Calendar Push.
  • Internal architecture not publicly documented at deep level; standard Google Spanner/Bigtable/GFS-derived infrastructure assumed.

Microsoft Outlook / Exchange

  • Corporate-dominant. Tight integration with the Exchange platform: Outlook on desktop talks MAPI/EWS, Outlook on web talks the unified Microsoft Graph.
  • Microsoft Graph is the unified API surface for calendar + mail + contacts + documents.
  • Exchange Server is the on-prem product; Exchange Online (Microsoft 365) is the hosted version. Both share the database (Exchange Information Store, “Jet Blue / ESE”).
  • Strong on enterprise features: room booking, resource calendars, free/busy across the entire company directory, delegated access.

Apple Calendar / iCloud Calendar

  • iCloud Calendar uses CalDAV under the hood. Apple’s iCloud Calendar API follows the standard.
  • Tight integration with EventKit framework on iOS / macOS for app developers.
  • Push notifications via APNS for fast cross-device sync.

Calendly

  • Booking-link layer atop Google Calendar, Office 365, iCloud Calendar. Doesn’t store the calendar itself; reads via OAuth.
  • Engineering blog (engineering.calendly.com) discusses time-zone correctness, conflict detection, and the upstream-sync rate-limit dance with Google and Microsoft.
  • Founded 2013; competitor list includes Doodle, when2meet, Reclaim.ai, and Microsoft’s recent Bookings feature.

Uncertain uncertain

Verify: the internal architecture of Google Calendar and Microsoft Exchange/Graph (storage engines, sharding, sync internals). Reason: neither is documented to the depth of the IETF specifications they implement — the API surfaces (RFC 5545 RRULE support, free/busy, push) are confirmed against primary docs above, but the backing infrastructure is inferred. The Exchange storage engine being ESE (“Jet Blue”) is well established historically; the rest is general-pattern inference. To resolve: primary engineering disclosures from Google/Microsoft.

13. Tradeoffs

DecisionOption AOption BWhen A winsWhen B wins
Time storageUTC onlyWall-clock + IANA zoneTruly point-in-time eventsRecurring or future events (always B)
RecurrenceMaterialize all instancesStore rule; expand on readTiny recurrencesReal recurrences (always B)
SyncPollingPushMobile-only / battery sensitiveCross-device (B for productivity)
Free/busyLive queryMaterialized per-user indexTiny scaleReal scale (always B)
FederationProprietary API onlyAlso expose CalDAV / iCalClosed ecosystemInteroperable (B for major)
Sharing modelRead full / noGranular (busy-only / full)PersonalWorkplace
Conflict resolutionLast-write-winsField-level mergeSimple modelMulti-user editing
Reminder schedulingOne global table scanTime-wheel / bucketedToy scaleProduction
Calendar modelSingle calendar per userMultiple calendarsTiny use caseReal (work + personal + shared)
Recurring instance editWhole-series onlyWhole-series + per-instance overridesSimpleReal-life flexibility (always B)

14. Pitfalls

  1. Storing UTC for non-instantaneous events. Recurring events in particular break across DST. Always store wall-clock + IANA zone for events that recur or are in the future.

  2. Forgetting tzdata updates. The IANA database is revised several times a year — for example four releases in 2023 (2023a–2023d) and two in 2024 (2024a, 2024b), the count tracking how many governments change DST or offset policy that year (per the IANA release index). If your service caches a stale tzdata, events for affected zones display at the wrong time. Pull the latest tzdata on every release.

  3. Misinterpreting “1MO” in BYDAY. The numeric prefix means “the Nth such weekday in the period of FREQ”, not “the Nth weekday of the year”. 1MO in MONTHLY = first Monday of the month; in YEARLY = first Monday of the year.

  4. Materializing all recurrences. A 10-year-daily recurrence = 3650 rows. Multiplied across millions of users, you eat your storage and lose the ability to edit “the rule” cheaply (you’d have to update all materialized rows).

  5. Hard-deleting an event without considering attendees. When an organizer deletes an event, attendees should receive a CANCEL notification (RFC 5546 iCalendar transport). Hard-delete with no notification leaves attendees showing the event.

  6. Confusion between “the event at this UTC time” and “the event at this wall-clock time”. When the user moves across time zones (or DST shifts), which interpretation? The answer is always wall-clock in the originating zone unless the event was specifically created as “anchored to UTC” (rare).

  7. Skipping nonexistent local times. During DST spring-forward, 2:30 AM doesn’t exist on the affected day. If the recurring rule lands on 2:30 AM, what happens? Most implementations skip; some slide. Document and stick to the chosen behavior.

  8. Doubling at fall-back. During fall-back, 1:30 AM happens twice. If unspecified, you might fire reminders or events twice. Treat the first occurrence as canonical.

  9. Ignoring SEQUENCE on edits. SEQUENCE: N increments on each meaningful event change; receivers should ignore lower-sequence updates as stale. Skipping this leads to “I updated the meeting but my colleague still saw the old time.”

  10. All-day events sliding when zone changes. An all-day event “May 12” in NYC should still be “May 12” if the user moves to LA — not “May 11 9 PM to May 12 9 PM PT”. Always-store all-day events as date-only (no time component) and never apply time-zone offset.

  11. Forgetting to invalidate free/busy on edit. If the materialized free/busy index isn’t rebuilt when an event changes, free/busy queries return wrong results — leading to “Calendly says I’m free but I’m in a meeting.”

  12. Calendly-style poll loops without rate limits. Reading upstream calendar (Google) too frequently triggers rate limits / quota exhaustion. Implement read caching with sane TTLs.

  13. Long recurrences with no end (UNTIL). A “forever every Monday” recurrence is fine in theory but generates an unbounded series; expansion algorithms must hard-cap at “10 years out” or similar to avoid runaway loops.

15. Common Interview Variants

  • “Design Google Calendar.” The flagship version covering all the above.
  • “Design Calendly / a meeting-booking system.” Focuses on the “find a slot” intersection, OAuth into upstream calendars, public booking links.
  • “Design a recurring event scheduler.” Deep dive on RRULE expansion algorithms, exception handling, DST.
  • “Design a meeting room booking system.” Same shape as Calendly but resource calendars (Conference Room A / B / C) and conflict resolution (room collisions).
  • “Design Doodle / when2meet (poll-based scheduling).” Different mechanic — instead of finding a slot in attendees’ calendars, attendees vote on options.
  • “Add reminders to an event system.” Distributed Task Scheduler System Design sub-problem.
  • “Federate calendars across organizations.” CalDAV / iCal feeds; cross-platform free/busy.
  • “How do you handle DST correctly?” Open-ended; the entire time-zone deep dive.

16. Open Questions / Uncertain

Uncertain uncertain

Verify: which storage engine Google Calendar uses for events (Spanner, Bigtable, or a custom store). Reason: Google has never published Calendar’s internal storage architecture. The “Spanner for mutable metadata + blob store for attachments” guess is a plausible inference from Google’s known infrastructure, not a documented fact. To resolve: a primary Google engineering disclosure — none known as of 2026-05.

Uncertain uncertain

Verify: how each major implementation (Google, Outlook, Apple) handles the spring-forward gap in practice. Reason: RFC 5545 §3.3.5 (not “§3.7.5”, which does not exist — corrected in §8.5) mandates the pre-gap-offset interpretation, but the spec governs the DATE-TIME value type, and real recurrence engines (e.g. dateutil.rrule, ICU, Outlook) have historically diverged in corner cases — some skip, some slide. To resolve: run a fixed RRULE landing on a nonexistent local time through each engine and compare. The spec is clear; implementation conformance is what remains uncertain.

Uncertain uncertain

Verify: Calendly’s current upstream-sync mechanism (push via Google’s watch webhooks vs. periodic polling vs. a hybrid). Reason: the underlying Google Calendar API supports both (push/watch and sync tokens) — confirmed — but which Calendly actually runs is not authoritatively documented and has changed over time; the engineering blog describes multiple strategies across different posts. To resolve: a current Calendly architecture post or conference talk naming the mechanism.

17. See Also