Engineering Atlas › System Design Interviews

Design a shared calendar service

18 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: room booking policy (a room is just a calendar), video links, smart scheduling and the email transport. iCalendar (RFC 5545) is the interchange format. Its JSON successor, JSCalendar (RFC 8984), carries the same model (rules, overrides, zone IDs) and is a reasonable internal API shape.

Back-of-envelope

Users: assume 500 million monthly and 100 million daily.

Reads: 10 view loads per daily user is 1 billion a day, or 1 billion / 86,400 = 11,600 per second. Weekday mornings peak at about 3x: 35,000 per second.

Free/busy: 20 million queries a day over 5 attendees each is 100 million per-user lookups, about 1,200 per second, 4,000 at peak.

Writes: 1.5 creates or edits per daily user is 150 million a day, 1,700 per second, 5,000 at peak. With 3 attendees on average, derived writes (attendee copies, busy rows) reach about 15,000 per second at peak.

Storage: 60 million new events a day at about 1 KB is 60 GB; 180 million attendee copies at 200 bytes is 36 GB. Call it 100 GB a day, 36 TB a year, 180 TB over 5 years, 540 TB with three replicas. The busy index is bounded by the horizon: 500 million users x 400 intervals x 32 bytes is about 6.4 TB.

Reminders: 4 per daily user is 400 million a day, about 4,600 per second on average. They cluster at :50 and :20 for meetings on the hour and half hour, so a large time zone's peak minute can carry a few hundred thousand, tens of times the average.

Bandwidth: 35,000 week views x 15 KB is about 525 MB/s at peak, mostly cacheable.

API

POST /v1/calendars/{cal}/events                 Idempotency-Key: <uuid>
  body { title, start: {local: "2026-11-02T09:00", tz: "Europe/Berlin"},
         end: {...}, rrule: "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=20",
         attendees: ["a@x.com", "b@y.org"], reminders: [{minutes: 10, via: "push"}] }
  -> 201 { event_id, version: 1 }

GET  /v1/calendars/{cal}/instances?from=2026-11-02T00:00Z&to=2026-11-09T00:00Z
  -> { instances: [ { event_id, original_start, start, end, title, rsvp } ] }

PATCH /v1/calendars/{cal}/events/{id}?scope=this|following|all
  body { original_start: "2026-11-09T09:00", changes: {...}, if_version: 7 }
  -> 200 { event_id, version: 8 }   | 409 { error: "VERSION_CONFLICT" }

POST /v1/calendars/{cal}/events/{id}/rsvp   body { response: "accepted" }

POST /v1/freebusy
  body { users: ["a@x.com", ...], from, to }
  -> { busy: { "a@x.com": [ [start, end], ... ] } }

GET  /v1/calendars/{cal}/changes?sync_token=abc
  -> { changed: [...], deleted: [...], next_sync_token }   | 410 Gone (full resync)

instances returns expanded occurrences; original_start (iCalendar's RECURRENCE-ID property) identifies an occurrence even after it has been moved. Google Calendar's API uses the same idea: an instance's originalStartTime identifies it within the series even if it was rescheduled (Google Calendar API). The scope parameter on edits is how the client tells the server which of the three edit meanings it wants. if_version gives optimistic concurrency, because two people editing a shared event at once is normal.

Data model

calendars(calendar_id PK, owner_id, default_tz, acl).

events(calendar_id, event_id), partitioned by calendar_id. Columns: organizer_calendar_id, start_local, end_local, tzid, all_day, rrule, exdates, attendees (a list with RSVP status), version, change_seq. The organizer's row is authoritative; an attendee copy holds a pointer to it plus private fields (reminders, RSVP). A series edit is a single-row, single-shard write.

overrides(calendar_id, event_id, original_start): one row per modified or cancelled occurrence, holding only the changed fields. This is RFC 5545's RECURRENCE-ID model, and it keeps a series with three exceptions at four small rows instead of hundreds.

busy_index(user_id, start_utc, event_id, end_utc, status): partition user_id, clustered by start_utc. Busy-blocking occurrences inside the horizon, in UTC because overlap tests need a common timeline. One range scan per user.

change_log(calendar_id, seq, event_id, op): an append-only sequence per calendar. A sync token is an encoded (calendar_id, seq). Rows older than about 30 days are truncated; a client presenting an older token gets 410 Gone, discards its local copy and does a full sync, the same contract Google Calendar's sync API uses (Google Calendar API).

reminder_queue(fire_minute, shard, reminder_id, user_id, payload): partitioned by the minute it fires plus a shard number, so the workers for one minute read a few known partitions instead of scanning.

A wide-column or NewSQL store (Spanner, CockroachDB, Cassandra) fits, because every access is "this calendar" or "this user between two times".

High-level design

flowchart LR
  C["Clients (web, mobile, CalDAV)"] --> GW[API gateway]
  GW --> ES[Event service]
  GW --> VS[View service]
  GW --> FB[Free/busy service]
  ES --> DB[("Events + overrides")]
  ES --> OB[("Outbox / change_log")]
  OB --> Q[Event stream]
  Q --> FO[Invitation fan-out]
  FO --> DB
  FO --> MAIL[Email / iMIP gateway]
  Q --> IDX[Busy indexer]
  IDX --> BI[("Busy index by user")]
  Q --> RS[Reminder scheduler]
  RS --> RQ[("Reminder queue by minute")]
  RQ --> RW[Reminder workers]
  RW --> PUSH[Push / email]
  VS --> DB
  FB --> BI
  TZ["tzdata release"] --> IDX

Write path: the event service validates the rule, then writes the event row and a change_log row in one single-partition transaction and returns. That outbox write is the only synchronous work. A relay publishes the change to a stream keyed by calendar_id, and three consumers react: fan-out writes attendee copies and sends iTIP email to external attendees, the busy indexer expands the series over the horizon, and the reminder scheduler enqueues the next few days of reminders.

Read path: the view service reads one calendar partition, expands each rule over the window in the event's own zone, applies overrides, converts to the viewer's zone, and joins attendee copies to their cached masters. Free/busy runs one range scan per user in parallel.

Deep dives

Recurring events: rules, materialization and "this and following"

Materializing every occurrence makes queries simple range scans, but an endless daily event is infinite, a series edit rewrites hundreds of rows, and a rule typo can create millions. Storing only the rule makes edits one row, but "is Alice busy Tuesday?" means expanding all her series, which is expensive across many users.

I choose a hybrid. Rule plus overrides is the truth. Views expand on the fly, since a view covers a week or a month. Free/busy and reminders use a rolling-horizon materialization, rebuilt when a series changes and extended nightly.

Edits have three scopes. "This occurrence" writes an override keyed by original_start. "All occurrences" edits the master; whether conflicting overrides survive is a product policy to decide explicitly. "This and all following" splits the series: the old master gets an UNTIL just before the split point, and a new master starts at the split with the new values. Both must be written atomically, which is easy because both live on the organizer's partition.

RFC 5545 allows FREQ=SECONDLY, so cap expansion per query and reject sub-daily rules at write time. Expansion also has a trap with impossible dates: a rule for "the 31st of every month" or "every 29 February" generates invalid dates, and RFC 5545 says those instances are silently dropped, not moved (RFC 5545 §3.3.10). Users usually expect the last day of the month instead. RFC 7529 adds an RSCALE rule part with SKIP=BACKWARD or SKIP=FORWARD to say exactly that (RFC 7529); without it, write such rules as BYMONTHDAY=-1.

Time zones and daylight saving

An event at 09:00 every Monday in Berlin is 08:00 UTC in winter and 07:00 UTC in summer. If you store the first instance as a UTC instant and add 7 x 24 hours, the meeting drifts by an hour twice a year. So the master stores start_local plus tzid, and each occurrence's instant is computed with the time zone database during expansion.

Two edge cases need explicit rules. In the spring-forward gap, a local time like 02:30 does not exist; RFC 5545 says to interpret it using the offset before the gap, which effectively moves it forward by the gap length. In the fall-back overlap, 01:30 happens twice; the RFC says it means the first occurrence (RFC 5545 §3.3.5). Pin both rules in your shared library and test them, because language runtimes differ in their defaults. All-day events are "floating" dates with no zone: a birthday on 3 March is 3 March wherever you are.

Governments change zone rules with little notice, and IANA ships several tzdata releases a year: five in 2026 by the end of September, the latest moving Manitoba to permanent UTC-5 from 31 October 2026 (tzdb NEWS). Busy rows record the tzdata version that produced them, and a new release triggers re-expansion for affected zones; otherwise free/busy silently disagrees with the freshly computed view.

Invitations, cross-organization attendees and free/busy

Internal attendees get a lightweight copy on their own partition, so each view is a single-partition read. An RSVP is a conditional update of one attendee entry on the master, which bumps its version and fans out again.

For attendees in other systems, the interchange is iTIP (RFC 5546) messages carried in email as iMIP (RFC 6047): a METHOD:REQUEST iCalendar attachment for invites and METHOD:REPLY for responses. CalDAV servers can run the same fan-out themselves: RFC 6638 makes the server, not the client, deliver scheduling messages when an organizer saves an event, which is exactly the role of our fan-out worker (RFC 6638). The fan-out worker records a sent marker per (event_id, version, attendee) so retries do not duplicate invitations.

An all-hands with 40,000 attendees breaks naive fan-out: one edit means 40,000 writes and emails. Above a threshold, attendees get no copy; views join the master by reference and RSVPs go to sharded counters. That is the hot key shape, solved by changing the data layout for the few hot keys.

Free/busy exposes only intervals unless the ACL allows more. Merging the lists into free slots is a sorted sweep, the practice task below.

Down the stack

Layer 1The contract

POST /freebusy for up to 50 users (the same per-query cap Google Calendar's free/busy API sets (Google Calendar API)) and a window of at most 90 days returns, for each user, a sorted list of non-overlapping busy intervals in UTC, covering every opaque, non-declined occurrence that starts inside the window. The organizer's own just-committed writes are reflected (the service reads the organizer's busy rows from the leader). Other users' changes appear within the indexer lag, which has a 5-second target. Users the caller may not see return an access_denied entry rather than failing the whole request.

Layer 2The mechanism

The busy index is a wide row per user_id, clustered by start_utc, in an LSM store such as Bigtable or Cassandra. A query is one range scan from window_start - max_event_length to window_end, so events that started before the window but overlap it are found. The indexer consumes the stream in calendar_id order, and each upsert carries the master's version; a lower version than the stored one is ignored, so replays are harmless.

Layer 3The machine

A busy user has about 400 future intervals in the 13-month horizon. At 32 bytes each (8-byte start and end, 16-byte event ID) that is about 13 KB, which fits in one or two 64 KB SSTable blocks. If the block is in the block cache, the scan costs tens of microseconds of CPU; if not, one NVMe read is about 100 microseconds. So the tail of a 10-user query is set by the slowest of 10 servers, not by work, and hedged requests (a second read to another replica after the p95 delay) cut it. Expansion for the view path calls a time zone conversion per occurrence; with the zone's transition table cached, a binary search over a few hundred transitions takes on the order of 100 ns, so expanding a month for 200 series is a few milliseconds of CPU at most.

Why it's built this way

  1. People schedule in local wall-clock time, and time zone rules change, so the source of truth is local time plus a zone ID, and instants are derived.
  2. Derived instants for an infinite rule cannot all be stored, so they are materialized only over a bounded horizon.
  3. Free/busy for many users must be fast, so the derived data is laid out per user and sorted by start time, making each lookup a single sequential scan.
  4. Derived data lags and can be wrong after a tzdata or code change, so it is versioned and rebuildable from the masters.
  5. Cross-partition writes are expensive and fragile, so the only synchronous write is to the organizer's partition, and everything else flows through an outbox.

Failure modes

In the wild

Trade-offs

ChoiceStrengthsWeaknessesChoose when
Store rule only, expand on readTiny storage, one-row editsFree/busy and reminders must expand many seriesSmall scale or view-only features
Materialize all occurrencesSimple range queriesInfinite series, write amplification on editsBounded series only, such as class timetables
Rule as truth plus materialized horizonFast free/busy, cheap editsTwo representations to keep in syncGeneral-purpose calendars (chosen)
Copy event to every attendeeSingle-partition reads per userFan-out cost on every edit; huge events hurtNormal meetings with a few attendees
Reference organizer's masterOne write per editCross-partition read on every viewVery large events and shared calendars
Store UTC instantsEasy comparisonsDST drift and broken future events after rule changesPast events and logs only

Leadership lens

Check yourself

  1. Why store a weekly 09:00 Berlin meeting as local time plus tzid instead of a UTC instant for the first occurrence?

    Show answer

    Adding 7 x 24 hours to a UTC instant makes the meeting drift by an hour twice a year at daylight saving changes, and breaks future events when a government changes its zone rules. Storing local wall-clock time plus the IANA zone ID and deriving each instant during expansion keeps it at 09:00.

  2. A country abolishes daylight saving on two weeks' notice. What silently goes wrong if nothing is done, and how does the design recover?

    Show answer

    The busy index was expanded under the old rules, so free/busy disagrees with freshly computed views. Busy rows record the tzdata version that produced them, so the new release triggers re-indexing of affected users from a work queue, tracked by a dashboard of rows still on the old version.

  3. Why is the chosen recurrence model a rule plus overrides with a materialized horizon, rather than storing only the rule or materializing every occurrence?

    Show answer

    Rule-only makes free/busy expand every series for every user, while full materialization cannot store infinite series and turns a series edit into hundreds of row writes. The hybrid keeps edits to one row and gives free/busy and reminders fast range scans, at the cost of two representations to keep in sync.

  4. Estimate the size of one busy user's busy-index row and explain why a 10-user free/busy query's tail is set by servers rather than work.

    Show answer

    About 400 future intervals at 32 bytes each is about 13 KB, one or two SSTable blocks, so a scan costs tens of microseconds cached or about 100 microseconds from NVMe. The query waits for the slowest of 10 servers, which is why hedged requests to another replica cut the tail.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →