Design a Calendar

Design a shared calendar: invites, recurring events stored as rules, time zones and daylight saving, free/busy across many people, reminders and sync.

Case study33 minLesson 9 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Ana in New York books a weekly Monday 9:00 meeting with Ben in London and Chen in Singapore. It reaches their phones within seconds at the right local time, reminds them ten minutes before, and stays at 9:00 for Ana after the clocks change in March. The shared calendar in the big office suites is the kind of product this question means.

Interviewers like it because the boxes are ordinary and the data is not: "every Monday, forever" cannot be stored as rows, local time is not UTC, and "when are these eight people free?" touches eight shards. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design a calendar service like the ones in office suites.

You

I'll assume the core is: create events, invite people who can accept or decline, repeating events, a free/busy view for finding a slot, and reminders. Should I include meeting rooms, shared team calendars, or calendars from other providers?

Interviewer

Rooms can be people with a calendar. Skip other providers.

You

How many users, and how many meetings does a typical person have?

Interviewer

100 million users, 50 million active each day. Most people have about four meetings a day.

You

Then reads dominate and reminders come in bursts on the hour. My priorities: a saved event is never lost and appears on my own devices at once; invitees may see it seconds later; no reminder is skipped or more than a minute late; and every occurrence lands at the right local time across daylight saving.

  • Functional: create, edit and delete events; invite people and record replies; repeating events with per-occurrence changes; day, week and month views; free/busy for many people; reminders; sync to every device.
  • Non-functional: saved events are durable and read-your-writes for their owner; attendees see changes within about 5 seconds; a week view loads in under 200 ms; reminders arrive within 60 seconds of their time and are never skipped; correct local times across time zones and daylight saving.
  • Out of scope: other providers, video calls, room rules beyond "a room has a calendar".

Estimate the scale

These are this scenario's assumptions, not any real product's. A day has 86,400 seconds; assume peaks of 3 times the average.

  • Writes: about 2 a day per active user (creates, edits, replies): 100 million a day ÷ 86,400 ≈ 1,200 a second, 3,500 at peak.
  • Reads: about 20 views or syncs a day per active user: 1 billion ÷ 86,400 ≈ 12,000 a second, 35,000 at peak. Reads outnumber writes 10 to 1.
  • Free/busy: if half of the 50 million events created daily use the scheduling view, that is 25 million ÷ 86,400 ≈ 290 lookups a second, 870 at peak; at about 5 people each, 4,300 per-person lookups a second at peak.
  • Reminders: 50 million users × 4 meetings = 200 million reminders a day, about 2,300 a second on average.
Estimate: Event storage per year

How much new event data is stored each year?

You know
  • 50 million active users each create about 1 event a day
  • About 1 KB per event (title, description, rule, times)
  • About 4 attendees per event, each with a 250-byte per-person entry

Show the working
  1. Bytes per event: 1,000 + 4 × 250 = 2,000 B
  2. Per day: 50 × 106 × 2,000 B = 100 GB
  3. Per year: 100 GB × 365 ≈ 37 TB, about 110 TB with 3 copies

Estimate 37 TB (anything from 10 TB to 120 TB is a good answer)

Too much for one machine, small for a sharded cluster. Size is not the hard part; recurrence and fan-out are.

Estimate: The reminder burst

Meetings start on the hour, so reminders bunch up. If the busiest moment of the day carries 3% of the day's reminders and they must all go out within 60 seconds, what rate must the reminder pipeline sustain?

You know
  • 200 million reminders a day
  • 3% fall due at the busiest top of the hour
  • Each must be delivered within 60 seconds of its time

Show the working
  1. 200 × 106 × 0.03 = 6 million reminders due at the same instant
  2. 6,000,000 ÷ 60 s = 100,000 a second
  3. That is 100,000 ÷ 2,300 ≈ 43 times the daily average

Estimate 100 thousand/s (anything from 30 thousand/s to 300 thousand/s is a good answer)

Size the reminder path for the burst, not the average. The deep dive shows how to load it before the minute arrives.

Your turn: Define the API

Write the endpoints for creating an event, reading a week, editing one occurrence of a repeating event, replying to an invite, free/busy, and device sync. How does a client avoid overwriting someone else's edit?

Compare with a model answer

Times go as local time plus a zone name. Reads take a window and return expanded occurrences. Edits carry the version (If-Match) and a scope: this, this and following, or all. Sync is "changes since token".

Define the API

HTTP API
POST /calendars/{cal}/events            Idempotency-Key: 7f3a...
  body: { "title": "Team sync",
          "start": { "local": "2027-03-01T09:00", "tz": "America/New_York" },
          "duration_min": 30,
          "rrule": "FREQ=WEEKLY;BYDAY=MO",          optional
          "attendees": ["ben@...", "chen@..."],
          "reminders": [{ "before_min": 10, "via": "push" }] }
  201 { "event_id": "e81", "version": 1 }
 
GET /calendars/{cal}/events?from=2027-03-01&to=2027-04-01&tz=Europe/London
  200 { "occurrences": [ { "event_id": "e81", "original_start": "...",
                           "start": "...", "end": "...", "title": "..." } ] }
  400 window longer than 1 year
 
PATCH /events/{id}?scope=this|following|all&occurrence=2027-03-22T09:00
  If-Match: 4
  200 { "version": 5 }
  412 Precondition Failed   someone else changed it; body has the current event
 
POST /events/{id}/reply      { "response": "accepted", "occurrence": ...? }
POST /freebusy               { "people": [...], "from": ..., "to": ... }
  200 { "busy": { "ben@...": [ ["2027-03-01T14:00Z", "2027-03-01T14:30Z"] ] } }
GET  /calendars/{cal}/changes?since=token
  200 { "changes": [...], "next": "token" }
  410 Gone   token too old; do a full resync

The decision worth arguing is who expands repeating events. The view endpoint expands on the server, so a web page gets plain occurrences. The sync endpoint sends the raw rule and exceptions, so a phone can expand any month offline. Both must use the same expansion library, or phone and web will disagree on edge cases.

Your turn: Model the data

Sketch the records. How do you store "every Monday, forever" with one Monday cancelled and another moved, and how do you find everything on Ben's calendar this week with one index lookup?

Compare with a model answer

Store the rule once, plus exceptions keyed by the original start time. Give each attendee an entry on their own shard holding the span the event covers, with "forever" as a far-future date, so one index on the span's end finds one-off and repeating events alike.

Model the data

Events, exceptions, per-person entries, change log
events                       -- on the organizer's shard
  event_id      BIGINT PRIMARY KEY
  organizer     BIGINT
  title, description, location   TEXT
  start_local   TIMESTAMP        -- 2027-03-01 09:00, no zone attached
  tz            VARCHAR(40)      -- "America/New_York"
  duration_min  INT
  rrule         TEXT NULL        -- "FREQ=WEEKLY;BYDAY=MO"
  version       INT
 
exceptions                   -- one row per changed occurrence
  event_id, original_start       PRIMARY KEY
  cancelled     BOOLEAN
  new_start_local, new_duration, new_title   NULL = unchanged
 
entries                      -- one per attendee, on THEIR shard
  calendar_id, event_id          PRIMARY KEY
  span_start_utc, span_end_utc   -- span_end = 9999-12-31 if no end
  start_local, tz, duration_min, rrule, title   -- copied from the event
  exceptions    JSON             -- copied, plus this person's own declines
  response      VARCHAR(10)      -- needs-action | accepted | declined
  reminders     JSON
  INDEX (calendar_id, span_end_utc)
 
changes                      -- per calendar, for device sync
  calendar_id, seq               PRIMARY KEY
  event_id, op

The hot read is "Ben's events overlapping this week": WHERE calendar_id = ? AND span_end_utc > week_start AND span_start_utc < week_end. The index skips everything that ended before this week, so years of history cost nothing; what is left is upcoming events, trimmed to this week by the start filter, plus a few dozen open-ended series to expand. Entries carry a copy of the times, rule and exceptions, so the read never leaves Ben's shard.

A relational database, sharded by calendar, fits: an edit changes the event, its exceptions and the change log atomically, which needs transactions on one shard, and the read is an indexed range scan.

Your turn: High-level design

Sketch the boxes: where an edit is written, how it reaches the attendees' calendars and devices, where free/busy comes from, and where reminders live.

Compare with a model answer

One transaction on the organizer's shard, including an outbox row. A change stream carries it to workers that update attendees' entries, nudge devices and plan reminders. Free/busy reads a per-person busy cache.

High-level design

The organizer's write is synchronous and small. Everything that touches other people happens after the reply, through a stream, so 40 guests do not mean 40 writes before Ana sees "Saved".

One synchronous write to the organizer's shard; fan-out to attendees, devices and reminders happens behind a change stream.Apps and browsers to API gateway (HTTPS); API gateway to Calendar service; API gateway to Free/busy service (free/busy); Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded) (txn + outbox); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service (attendee copies); Fan-out workers and Job scheduler (plan / fire); Fan-out workers to Push service, asynchronous; Push service to Apps and browsers (sync nudge), asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob schedulerHTTPSfree/busytxn + outboxattendee copiesplan / firesync nudge

request and replyasynchronous message

One synchronous write to the organizer's shard; fan-out to attendees, devices and reminders happens behind a change stream.
Ana invites two people, Ben reads his week, then a crash and a raceUse the arrows or ← → keys
  1. Ana invites two people, Ben reads his week, then a crash and a race, step 1Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob scheduler

    1Ana creates "Team sync", Mondays 9:00 New York time, with Ben and Chen. An idempotency key stops a retry from creating two series.

  2. Ana invites two people, Ben reads his week, then a crash and a race, step 2Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)e81 v1Change streamPush serviceFan-out workersJob scheduler
    version
    1

    2One transaction on Ana's shard writes the event with its rule, Ana's entry, a row in her change log and an outbox row. Version 1.

  3. Ana invites two people, Ben reads his week, then a crash and a race, step 3Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob scheduler
    version
    1

    3Ana gets 201 straight away. Her own calendar is up to date because she reads her own shard.

  4. Ana invites two people, Ben reads his week, then a crash and a race, step 4Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob scheduler
    version
    1

    4The outbox row reaches the change stream and a fan-out worker picks it up, off Ana's critical path.

  5. Ana invites two people, Ben reads his week, then a crash and a race, step 5Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)+2 entriesChange streamPush serviceFan-out workersJob scheduler
    version
    1
    entries written
    2

    5The worker writes an entry on Ben's shard and one on Chen's, each with a new row in that person's change log. Each is an upsert carrying version 1, ignored if the stored version is newer, so a late or repeated message cannot roll an entry back.

  6. Ana invites two people, Ben reads his week, then a crash and a race, step 6Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob scheduler
    version
    1
    seen by Ben after
    2 s

    6A silent push nudges Ben's and Chen's devices to call changes?since=token. They have the event about 2 seconds after Ana saved it.

  7. Ana invites two people, Ben reads his week, then a crash and a race, step 7Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob schedulernone yet
    version
    1

    7The worker plans reminders only for the next 48 hours. The first Monday is a week away, so a daily planner will add it later.

  8. Ana invites two people, Ben reads his week, then a crash and a race, step 8Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersMon 14:00API gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workersJob scheduler

    8Ben opens his week. One index range on his shard returns the series; the service expands Monday 9:00 New York and converts it to London: 14:00.

  9. Ana invites two people, Ben reads his week, then a crash and a race, step 9Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar serviceCalendar DB(sharded)Change streamPush serviceFan-out workerscrashedJob scheduler
    version
    1
    delivery
    at least once

    9Suppose instead the worker had crashed after Ben's entry but before Chen's. The message was never acknowledged, so another worker redoes both upserts: Ben's is unchanged, Chen's arrives a few seconds late.

  10. Ana invites two people, Ben reads his week, then a crash and a race, step 10Apps and browsers to API gateway; API gateway to Calendar service; API gateway to Free/busy service; Free/busy service to Busy-time cache; Calendar service to Calendar DB (sharded); Calendar DB (sharded) to Change stream, asynchronous; Change stream to Fan-out workers, asynchronous; Fan-out workers to Calendar service; Fan-out workers and Job scheduler; Fan-out workers to Push service, asynchronous; Push service to Apps and browsers, asynchronous.Apps andbrowsersAPI gatewayFree/busyserviceBusy-time cacheCalendar service412 for v1Calendar DB(sharded)v2Change streamPush serviceFan-out workersJob scheduler
    version
    2

    10Ana renames the event on her laptop while her phone, offline since version 1, sends a time change. Both say If-Match: 1. The laptop's lands first and makes version 2; the phone gets 412 and the current event.

1 / 10

Deep dives

Repeating events and time zones

"Every weekday at 9:00, no end date" has infinitely many occurrences. You cannot store them all, and storing a few years of them hurts the moment someone edits the series.

Storing repeating events
OptionGood atBad atPick it when
One row per occurrence, far aheadReads are a plain range query.A weekly meeting until 2100 is about 3,800 rows per attendee. "Move all to 10:00" rewrites all of them, and there is still an end.Never for open-ended series.
Rows for a rolling windowSimple reads; bounded storage.A job must keep extending every series, and edits rewrite the window.A downstream system needs plain rows.
Rule + exceptions, expand on read Usual defaultOne row per series. Editing all occurrences changes one row. Matches the iCalendar standard that devices already speak.Every read runs expansion code, and the code has subtle edge cases.The primary store. The usual answer.

Store the rule in the iCalendar RRULE format, the start as local time plus a zone, and exceptions keyed by the original start time of the occurrence they change. That key never changes, even when the occurrence moves. Here is Ana's series for March 2027, with March 8 cancelled and March 22 moved to 10:00:

Original start (New York)ExceptionUTCBen in London
Mon Mar 1, 09:00none14:0014:00
Mon Mar 8, 09:00cancellednonenone
Mon Mar 15, 09:00none13:0013:00
Mon Mar 22, 09:00moved to 10:0014:0014:00
Mon Mar 29, 09:00none13:0014:00

Edit scopes map to the data: "this occurrence" adds an exception; "all" changes the series row; "this and following" splits the series, ending the old rule before the chosen occurrence and starting a new series there. Reads cap the window at a year, so expansion is always bounded.

Look at the UTC column. Ana's meeting stays at 9:00 in New York, so it moves an hour in UTC when New York changes its clocks on March 14, 2027. London changes on March 28, so for two weeks Ben sees 13:00, then 14:00 again. That is correct, and it only happens if you expand in the event's own zone, then convert.

  • Repeating events store a local time and a zone name such as America/New_York, never an offset like −05:00, which is right for only half the year.
  • All-day events are dates with no zone. A birthday on March 3 is March 3 everywhere.
  • Missing and doubled times: 2:30 on the spring-forward night does not exist; 1:30 on the fall-back night happens twice. Use one rule on every server and device, such as iCalendar's for a single date-time: move a missing time forward by the gap, take the first of a doubled one.
  • The rules change. Governments change daylight saving at short notice. Because you stored local time and a zone, a time zone database update fixes future occurrences on the next expansion. Anything derived in UTC (span columns, busy caches, planned reminders) must be rebuilt for that zone.

Free/busy for many people

Chen asks when Ana, Ben and five others are free next week: seven shards, each with repeating events to expand. The answer needs only busy intervals, never titles: free/busy reveals when, not what.

Answering free/busy
OptionGood atBad atPick it when
Expand on every lookupAlways exact; nothing to keep in sync.One expansion per person per lookup; the slowest shard sets the latency.Small scale, or as the fallback.
Cached busy intervals per person Usual defaultOne cache read per person, exact times. Eight weeks of a person's meetings is about 224 intervals × 16 B ≈ 3.6 KB.Workers must rebuild a person's entry on every change, and after a time zone update.The usual default for the scheduling view.
Bitmap of 15-minute slots96 bits a day; combining many people is a bitwise OR.Rounds meetings to slots, so a 9:05 start blocks 9:00.Searching hundreds of rooms for any free slot.

The lookup reads 7 small entries in parallel, merges the sorted intervals (the intervals pattern) and returns the gaps inside working hours. For 100 million users the cache is 100 million × 3.6 KB ≈ 360 GB, a modest cluster. Entries lag changes by a few seconds, which is fine for suggesting slots.

Reminders at scale

A repeating event has no last reminder, so reminders cannot all be scheduled up front. Use a rolling horizon on top of a job scheduler:

  • Plan: a daily planner expands each calendar's next 48 hours, and fan-out workers plan new or changed occurrences inside that window. Jobs are keyed by (calendar, event, original start, offset), so planning twice creates one job.
  • Re-check on fire: when a job fires, the worker reads the entry and drops the reminder if the event was cancelled, moved or declined. Cancellation becomes a cheap check instead of a hunt for jobs to delete.
  • Survive the burst: 6 million reminders due at 9:50 is 100,000 a second for a minute. The scheduler loads each minute's jobs a minute early and hands them to the notification system, whose queues are built for bursts.
  • Use the device: a synced phone can raise the alarm itself, so a server outage at 9:50 does not silence most reminders.

The scheduler runs jobs at least once, so a crash can repeat a reminder. A "sent" record per job key catches most repeats; a rare double beats a missed meeting.

Edits from many devices

Every edit sends If-Match with the version it started from, and one conditional update applies it only if the version is unchanged. A stale edit gets 412 and the current event; the client merges field by field (phone changed the time, laptop the title: keep both) and retries, or asks the user if both changed one field.

Devices sync from the per-calendar change log. Each change takes the next number from a per-calendar counter in the same transaction, so numbers commit in order and "everything after 1,042" misses nothing; the sync token is that number. Log rows older than about 30 days are trimmed, and an older token gets 410 and a full resync. The push is only a nudge: if it is lost, the next app open syncs anyway.

Bottlenecks and failures

At 10x (35,000 writes and 350,000 reads a second at peak), add calendar servers and read replicas (owners read their own calendar from the primary), and split shards by calendar. The first real pain is a huge guest list: an all-hands for 50,000 people is 50,000 entry writes per edit. Above about 1,000 guests, give each guest only a pointer row, written once, and read the times from the one shared event: the hybrid a news feed uses for accounts with millions of followers.

When this diesWhat happensWhat you do
A calendar DB primaryWrites for its calendars stop until a replica takes over.Replicate synchronously to one replica so a saved event survives; fail over in seconds.
The change streamAttendees and devices stop hearing about changes.Organizers' writes still succeed; the outbox rows wait and are sent when it recovers.
A fan-out workerIts messages are redelivered.Its upserts are idempotent, so redoing them is safe.
The busy-time cacheLookups expand on each shard instead.Slower but correct. Rebuild entries lazily.
The job schedulerServer reminders stop.Device alarms still fire; on recovery, send overdue reminders only if they are under 5 minutes late.

What a strong answer sounds like

LevelWhat the answer does
JuniorStores events with a start and end, lists them by user, and adds a cron job for reminders. Repeating events become many rows.
MidStores a rule with exceptions, expands within a window, fans invites out asynchronously, and uses a scheduler for reminders.
SeniorAlso gets local time and zone right across daylight saving, makes reminders a rolling horizon with re-checks, sizes for the top-of-the-hour burst, versions edits, and names the large-meeting fan-out problem.
The daylight saving questionStrong answer
Interviewer

Ana's weekly 9:00 meeting: what do you store, and what does Ben in London see in March?

You

I store 09:00 with the zone America/New_York and the rule, not a UTC time. I expand in New York time and then convert. So Ben sees 14:00, then 13:00 for the two weeks between the US and UK clock changes, then 14:00 again. That shift is right: the organizer's 9:00 is what the meeting means.

Interviewer

And if the US changed its daylight saving rules?

You

Future occurrences fix themselves on the next expansion, because I stored local time. I must rebuild what I cached in UTC for that zone: spans, busy caches, planned reminders.

Follow-up questions

How do you book a meeting room without double booking?

A room is a calendar that accepts automatically if no confirmed occurrence overlaps. The check and the accept run in one transaction that also bumps the room's change counter, so two bookings for that room cannot interleave. The loser sees "declined".

How do invites to people at other companies work?

By email with an iCalendar attachment. Replies come back the same way, matched by the event's unique ID and sequence number.

An attendee declines one occurrence. Where is that stored?

As an exception on that attendee's entry, keyed by the original start; the shared event is untouched.

How do you search a calendar by title?

Feed the change stream into a search index partitioned by calendar. Search returns series; the view expands them.

How would you run this in several regions?

Home each calendar in one region and send its writes there, so versions and change logs stay simple. Replicate reads and the busy cache elsewhere, accepting a second of lag.

Self-review: calendar0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure