Design a calendar
Design a shared calendar like Google Calendar or Outlook: events, meetings that repeat every week, invitations, finding a time that suits twelve people in four time zones, booking rooms, and reminders that arrive at the right minute. The data is small; the hard parts are time itself (repeating series, time zones, clock changes) and the burst of reminders just before every hour.
1. The prompt
"Design a calendar for a workplace suite: people create events and meetings, invite others, see each other's free and busy times, book rooms and get reminders."
What makes it hard:
- Series without an end. "Every Monday at 9:00, forever" can't be stored as rows, yet "show me March" must be fast, and one Monday can be moved or cancelled on its own.
- Time zones and clock changes. A weekly 9:00 meeting must stay at 9:00 for the organiser when the clocks change, which means it moves for colleagues in countries that change on a different date.
- Everyone at once. Meetings start on the hour, so reminders, notifications and calendar loads arrive in sharp peaks just before :00 and :30.
2. Frame the problem
How many users, and where?
100 million users across all time zones; the largest companies have 300,000 employees.
Peaks follow the sun: each region's Monday morning is the busiest hour for that region's data.
Do we need repeating events?
Yes: daily, weekly, monthly, "every second Tuesday", with or without an end date, and changes to one occurrence or "this and all following".
That rules out storing occurrences as rows; we need a rule plus exceptions (9.1).
Free/busy for whom?
Anyone in the same company can see when a colleague is busy, not what the meeting is, unless it's shared with them.
Free/busy is its own read model with its own permissions (9.3).
Rooms?
Yes, bookable like attendees; a room must never be double-booked.
A correctness constraint like seats in D15, but on time ranges.
Reminders: how precise?
Within a few seconds of the chosen time (10 minutes before, by default), by push, email or pop-up.
Millions are due in the same minute before each hour (9.5).
Other clients?
Web, phones (offline), and standard calendar apps through CalDAV and iCalendar feeds.
Phones need incremental sync, and the data model should map onto the iCalendar standard (RFC 5545).
3. What it must do
- Create and change events, including repeating ones, with exceptions for single occurrences.
- Invite people and rooms; they accept or decline; the organiser sees replies.
- Show calendars for any date range, and find a time using free/busy.
- Remind each attendee at their chosen time before each occurrence.
Not now: video-call links (an external service), tasks and to-dos, AI scheduling assistants, public event discovery.
4. How well it must do it
| Target | Why this number |
|---|---|
| Week view loads in under 200 ms at p99 speed | People open their calendar dozens of times a day. |
| Find a time for 20 people over 2 weeks in under 1 s speed | Scheduling is interactive. |
| Reminders delivered within 5 s of their time, even at :50 and :20 load shape | A reminder after the meeting started is useless. |
| No double-booked rooms, ever consistency | Two teams at one door is the bug people remember. |
| Changes visible to other attendees within a few seconds freshness | A moved meeting must not be missed. |
| 99.99% available for reads surviving failures | Companies run their day from it; reads can be served slightly stale if writes are down. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Event writes | 100 M users × ~5 changes a week ≈ 70 M a day ≈ 800 a second, a few thousand at peak | → so writes are modest: a sharded relational store, by calendar |
| Calendar reads | 100 M × 10 opens a day ≈ 1 B a day ≈ 12,000 a second; ~4× on a region's Monday morning | → so range reads must hit a precomputed index, not expand rules each time |
| Stored events | 100 M × ~2,000 events over the years × 2 KB ≈ 400 TB with attendees and history | → so shard by calendar; old years move to cheaper storage |
| Reminders in one minute | 40 M users in one region; 1 in 8 has a meeting at 10:00 → 5 M reminders due at 09:50, ≈ 80,000 a second if sent within the minute | → so reminders are scheduled into minute buckets and loaded ahead (9.5) |
| An all-hands invite | One event, 300,000 attendees | → so huge meetings are handled like celebrity posts: no per-attendee write fan-out (9.6) |
Illustrative numbers.
6. The nouns
- Calendar: belongs to a person, a room or a team; has a default time zone and sharing rules.
- Event: title, start and end as local time plus a time-zone name, an optional repeat rule, organiser, attendees.
- Exception: one occurrence of a series that was moved, edited or cancelled, identified by its original start.
- Occurrence: a concrete start and end in UTC, computed from an event and its rule. Stored only in indexes, never as the source of truth.
- Attendee: a person or room on an event, with a response.
- Reminder: who, which occurrence, when (in UTC), how.
event (calendar_id, event_id) → organizer, title, start_local, end_local, tz ('Europe/London'),
rrule ('FREQ=WEEKLY;BYDAY=MO'), until?, version, updated_at
exception (event_id, original_start) → cancelled | new start/end/title
attendee (event_id, user_or_room_id) → response (needs-action | yes | no | maybe)
occurrence_idx (calendar_id, day_utc) → [(event_id, start_utc, end_utc)] (materialised 12 months ahead)
busy (user_id, start_utc, end_utc, event_id) (free/busy read model)
reminder (due_minute, shard) → [(user_id, event_id, occurrence_start, channel)]
change_log (calendar_id, seq) → event_id, kind (sync tokens)
7. The contract
POST /v1/calendars/{cal}/events {title, start: {local, tz}, end, rrule?, attendees[], reminders[]} → event
PATCH /v1/events/{id}?scope=one|following|all {changes, occurrence_start?} (If-Match: version)
GET /v1/calendars/{cal}/events?from=…&to=… → occurrences, in the viewer's time zone
POST /v1/freebusy {users[], rooms[], from, to} → busy ranges per person
POST /v1/events/{id}/responses {occurrence_start?, response}
GET /v1/calendars/{cal}/changes?sync_token=… → changed and deleted events, next token (410 → full resync)
The organiser and responder come from the auth token. If-Match stops two people editing the same meeting from silently overwriting each other (Module 07).
8. First working version
An events service owns events, exceptions and attendees in a relational store sharded by calendar. Every change also writes a row to an outbox (Module 22), from which workers update the read models: the per-calendar occurrence index, the free/busy table, invitations (through the notification system, D05) and the reminder schedule.
8.1 Creating a weekly meeting
8.2 Opening your week
9. Make it hold up
9.1 Repeating events: a rule plus exceptions speed · correctness
| Approach | Why | |
|---|---|---|
| ✗ Bad | A row per occurrence, generated when the series is created | "Forever" has no last row; editing the series rewrites thousands of rows; a time-zone rule change means regenerating them all. |
| ~ Good | Store the rule; expand it on every read | Correct and compact, but every week view expands every series the person has, including colleagues' shared calendars. |
| ✓ Great | Store the rule and its exceptions as the truth; keep a derived occurrence index for the next 12 months, extended by a nightly job and rebuilt for a series whenever it changes | Reads are a range scan; the truth stays small and editable; the index can always be rebuilt from the rule. |
Three kinds of edit. This occurrence: write an exception keyed by its original start. All occurrences: change the event and keep the exceptions that still make sense. This and following: end the old series the day before (set its until) and create a new series from that date, so the history before the change stays as it was.
9.2 Time zones and clock changes correctness
Store a repeating event's start as local time plus a time-zone name (09:00, Europe/London), never as a fixed UTC instant. Each occurrence is converted to UTC when expanded, using the IANA time-zone database. A single event in the future also keeps its zone name, because governments change their rules with little notice and the stored UTC instant would then be wrong.
The effect people notice: countries change their clocks on different dates. The US moves in early-to-mid March; the UK and EU at the end of March; south-eastern Australia in April and October; India and Brazil not at all. For those weeks, a meeting fixed in London time moves by an hour for colleagues in New York. That is correct, and the calendar should show it. The lab in section 13 shows it week by week.
- Two clock-change traps: a 02:30 event on the spring-forward day doesn't exist (move it to 03:30); one on the fall-back day happens twice (pick the first).
- All-day events are dates, not times: a birthday on 14 March must not become 13 March for a viewer in another zone.
- Keep the time-zone database up to date on every server and client, and re-expand affected series when it changes.
9.3 Finding a time for twelve people speed · privacy
The busy table is a read model: one row per occurrence per attendee who accepted or hasn't answered, with no titles. Permission to see free/busy is checked once per person; permission to see details is a separate, stricter check. Working hours and time zones come from each person's settings, so the search only offers slots inside everyone's day.
9.4 Rooms that can't be double-booked consistency
A room is an attendee with its own calendar, and booking it must be atomic. For single events, a database range constraint on the room's bookings (PostgreSQL's exclusion constraint on a time range, as in D15's stays variation) refuses an overlap. For a repeating booking, the service expands the next 12 months, checks every occurrence and inserts them all in one transaction; occurrences beyond that are booked by the nightly job, and a clash found then is reported to the organiser. Popular rooms are contended at round hours, so the booking path is short: no external calls inside the transaction.
9.5 Five million reminders at 09:50 load shape
| Approach | Why | |
|---|---|---|
| ✗ Bad | A cron job every minute: SELECT … WHERE due = now() across all events | Expands every series every minute, and one query must return 5 million rows in seconds. |
| ~ Good | A timer per reminder in a job scheduler | Works (D21), but tens of millions of timers fire in the same minute and the scheduler's own peak becomes the problem. |
| ✓ Great | Minute buckets split into shards, loaded a few minutes early, sent by a worker pool sized for the peak; the send is idempotent per (user, occurrence, reminder) | The heavy reading happens before the minute; the minute itself is just sending; duplicates after a retry are dropped. |
9.6 Huge meetings and phones that sync scale · freshness
- Huge meetings. Writing an occurrence-index row and a busy row for each of 300,000 attendees takes minutes and is redone on every change. Above a threshold (say 1,000 attendees), the event is stored once, attendees reach it through a "large events I'm invited to" list read at view time, and their busy rows are written lazily, only when someone asks for their free/busy.
- Phones. Each calendar has a change log with an increasing sequence number. A phone sends its last sync token and receives only events changed or deleted since; if the token is too old, the server answers
410 Goneand the phone does a full sync. The same log feeds web clients' live updates over a WebSocket. - Standard apps connect through CalDAV and iCalendar, which map directly onto events, rules and exceptions: one reason to model them the standard's way.
9.7 The whole design after the deep dives
10. Interviewer follow-ups
A country abolishes daylight saving time with two months' notice.
Because events store local time plus a zone name, nothing in the source data changes. Ship the new time-zone database, then re-expand the occurrence index and reminder schedule for series in that zone. Single events stored only as UTC would have been silently wrong.
Two people edit the same meeting at once.
Each PATCH carries the version it read (If-Match); the second gets 412 and the client shows the newer version to merge. Responses (accept, decline) are separate rows, so they never conflict with edits.
Invitations to people at another company.
Send an iCalendar invitation by email (an .ics with a unique ID and sequence number); their reply comes back by email and updates the attendee row. The unique ID makes updates and cancellations apply to the right event in their calendar.
Show colleagues' calendars side by side.
Read each person's occurrence index for the range in parallel and filter by permission: details if shared, otherwise just busy blocks. Cache the busy view per person for a minute.
A user deletes their account.
Delete their calendars and events; on meetings they organised, either transfer ownership or cancel and notify attendees; remove them from attendee lists of others' events. The change log tells sync clients to delete local copies.
Reminder sent twice after a worker crash.
The send is keyed by (user, event, occurrence start, reminder offset). The worker records the key before sending (or the push service deduplicates by it), so a retry after a crash finds it and skips.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Index workers lag | A new meeting appears in others' calendars late | Outbox age | The organiser's own view reads the event directly; workers scale out |
| Reminder shard slow at :50 | Late reminders for some users | Send delay per bucket | Buckets loaded minutes early; spare workers take over shards |
| Events database shard down | Those calendars can't be edited | Error rate per shard | Reads served from replicas and the index; writes queue in the client |
| Time-zone data out of date | Meetings an hour off in one country | Version check on deploy | Zone names stored, so re-expansion fixes everything |
| Notification provider down | Invites and email reminders delayed | Provider errors | Retries with backoff; push and pop-up reminders unaffected (D05) |
12. Wrap-up and the clock
Trade-offs we made:
- Rules and exceptions as the truth, plus a derived 12-month index: small truth, fast reads, a nightly job to keep it extended.
- Local time plus a zone name, not UTC, for anything that repeats or lies in the future.
- Separate read models (occurrences, busy, reminders) fed from an outbox: eventually consistent by seconds, simple writes.
- Database-enforced room bookings, keeping that transaction short.
- Huge meetings read on demand instead of fanned out.
What we'd watch: week-view p99 · outbox age · reminder delay at :50 and :20 · room-booking conflicts · sync 410 rate · time-zone database version across servers.
At 10×: more shards; the reminder minute is the first thing to grow, so more shards per bucket and more workers. The model doesn't change.
A 60-minute interview
| 0–5 | Frame: repeats, zones, rooms, reminders |
| 5–12 | Must do, how well, numbers (the reminder minute) |
| 12–18 | Nouns: rule, exception, occurrence |
| 18–30 | First version: create a series, open a week |
| 30–52 | Deep dives: repeats (9.1), zones (9.2), reminders (9.5) |
| 52–60 | Rooms or free/busy, follow-ups, wrap-up |
A 90-minute interview
| 0–8 | Frame, including external clients |
| 8–16 | Must do, how well, numbers |
| 16–24 | Nouns and contract, edit scopes |
| 24–38 | First working version |
| 38–78 | Deep dives 9.1–9.6 and the final picture |
| 78–86 | Follow-ups: zone rule change, concurrent edits, external invites |
| 86–90 | Trade-offs, metrics, 10× |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Events and attendees in a database, recurrence stored as a rule, reminders via a scheduler, range queries for the week view. |
| Senior | Rule plus exceptions with a derived index, local time plus zone name, the three edit scopes, free/busy as a read model, room bookings enforced by the database, the reminder peak. |
| Staff+ | Time-zone rule changes and re-expansion, huge meetings vs fan-out, sync tokens and standards (iCalendar, CalDAV), privacy of free/busy, operating the nightly extension job. |
Common mistakes:
- Storing every occurrence of a series as a row.
- Storing repeating times in UTC, so meetings drift by an hour twice a year.
- Treating all-day events as midnight UTC.
- Ignoring the burst of reminders before each hour.
- Checking room availability and then inserting, without a constraint.
13. Try it, check yourself, read more
The clock-change lab. A weekly meeting is created by an organiser in one city. Pick where they are, the meeting time, and where a colleague is. Then compare storing it as local time plus a zone name with storing a fixed UTC time.
Computed in your browser with its own time-zone database, for Mondays from 2 March to 4 May 2026.
How should a weekly meeting with no end date be stored?
What should a repeating 9:00 meeting store as its start?
A user changes "this and all following" occurrences of a weekly meeting. What happens to the data?
5 million reminders are due at 09:50. What keeps them on time?
| Resource | Why |
|---|---|
| RFC 5545: iCalendar | The standard for events, repeat rules (RRULE) and exceptions (RECURRENCE-ID, EXDATE). |
| RFC 4791: CalDAV | How calendar apps sync with a server. |
| IANA time zone database | The source of every zone's rules and their changes. |
| Google Calendar API: synchronize resources efficiently | A product's version of sync tokens, including the 410 full-resync rule. |
| Microsoft Graph delta queries | The same idea in Outlook's API. |