← System design

Communication

Design Google Calendar

A calendar looks like a list of rows with start and end times until you add "every weekday, forever, in New York time". Then you need occurrences that do not exist until someone looks, meetings that keep their local time through clock changes, guests whose copies stay in step, and a reminder system that wakes up millions of phones just before every hour.
Core: store rules, expand per window + busy-interval cache900M accountsRead path computes, not just readsTime zone correctness

01

What the calendar must do

02

What the calendar must do

Functional
  • Create, edit and delete events, including repeating events described by RFC 5545 rules such as "every second Tuesday" or "weekdays until 31 March".
  • Change one occurrence of a series, or this and all following ones, without disturbing the rest.
  • Show day, week and month views that combine the user's own calendars with any they subscribe to.
  • Invite attendees, collect responses, and keep everyone's copy in step when the organiser makes a change.
  • Answer free and busy questions across many people and rooms, so the scheduling assistant can suggest a slot.
  • Fire reminders by push, email or pop-up at the right moment in the user's own time zone.
  • Share calendars with view, edit or manage rights, and sync with phones and CalDAV clients.
Non-Functional
  • Week view loads in p99 under 150 ms, including expansion of every repeating event in the window.
  • Free and busy for a dozen people answers in under 250 ms.
  • Reminders fire within a few seconds of their scheduled time, including at the top of every hour.
  • Edits from one device show on the user's other devices within seconds.
  • Events land at the correct local time through daylight saving changes and government time zone changes.
  • Available 99.99%: people plan their working day around it, and a missing meeting is a real cost.
Questions worth asking

Is this mostly personal calendars or company calendars with rooms and large groups? A 4,000-person all-hands invitation stresses very different parts of the system from a family calendar. Ask whether third-party clients must sync over CalDAV, since that fixes parts of the data model. And ask how far into the future scheduling looks: people book rooms months ahead, which matters for how much you precompute.

02

Sizing the calendar

03

Sizing the calendar

Assume 900 million accounts, 300 million of them opening a calendar view on a given day, and about 0.8 new events or edits per active user a day. Reminders are the surprise: many people get several a day, and they bunch up at hour and half-hour boundaries.

QuantityWorkingResult
View loads per day300M users x 15 views and syncs4.5B
Average read rate4.5B / 86,400 sabout 52,000 / s
Weekday morning peak3x average, as several big time zones start workabout 156,000 / s
Writes per day300M x 0.8240M, about 2,800 / s
Attendee updates35% of events have guests, 6 guests on averageabout 500M attendee rows touched a day
Free and busy queriespeak 12,000 / s x 8 calendars eachabout 96,000 calendar lookups / s
Reminders fired300M users x 4 a day1.2B, about 14,000 / s average
Top-of-hour spikeabout 20% of an hour's reminders fall in the minute before :00about 170,000 / s for that minute
Stored events900M accounts x about 2,500 events and seriesabout 2.2 trillion rows
Storage2.2T x about 1.2 KB, 3 replicasabout 2.6 PB raw, 8 PB stored
Reading the table

Reads beat writes by about twenty to one, which is ordinary. Two numbers are not. Every read has to expand repeating rules, so the read path does computation, not just lookups. And reminders turn a smooth daily load into a sawtooth that peaks just before every hour.

03

Client-facing endpoints

04

Client-facing endpoints

POST/v1/calendars/{calendar_id}/eventsCreate an event or a series
{ "summary": "Platform team standup", "start": { "local": "2026-10-05T09:30:00", "tz": "Asia/Kolkata" }, "end": { "local": "2026-10-05T09:45:00", "tz": "Asia/Kolkata" }, "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"], "attendees": [{ "email": "kabir@corp.example" }, { "email": "lena@corp.example" }], "reminders": [{ "method": "push", "minutes_before": 5 }] } // stores one series row; no individual occurrences are written
GET/v1/views?calendars=primary,team-oncall&from=2026-10-05&to=2026-10-12&tz=Asia/KolkataWeek view with expanded occurrences
{ "items": [ { "event_id": "ev_31a", "occurrence": "20261005T040000Z", "summary": "Platform team standup", "start": "2026-10-05T09:30:00+05:30", "my_response": "accepted" } ], "sync_token": "st_00a91f4" }
POST/v1/events/{event_id}/occurrences/{original_start}Change one occurrence
// original_start identifies which occurrence, even after it has been moved { "start": { "local": "2026-10-08T11:00:00", "tz": "Asia/Kolkata" }, "scope": "this" } // scope "this_and_following" ends the series here and starts a new one
POST/v1/availabilityFree and busy across people and rooms
{ "from": "2026-10-06T03:30:00Z", "to": "2026-10-06T12:30:00Z", "who": ["kabir@corp.example", "room-blr-4b"] } { "busy": { "kabir@corp.example": [["04:00Z", "04:15Z"], ["08:30Z", "09:30Z"]], "room-blr-4b": [] } } // only intervals, never titles, unless the caller can read the calendar
GET/v1/calendars/{calendar_id}/changes?sync_token=st_00a91f4Incremental sync for devices
// returns changed and deleted events since the token, plus a new token // 410 when the token has expired; the client does a full fetch of a bounded window

04

Architecture overview

05

Architecture overview

Event store, expansion, availability and remindersDiagram
flowchart LR
	C["Web, mobile and CalDAV clients"] --> GW["API gateway"]
	GW --> EV["Event service"]
	GW --> VW["View service with expander"]
	GW --> AV["Availability service"]
	EV --> DB[("Event store, sharded by calendar")]
	VW --> DB
	EV -. "change log" .-> K[["Kafka: calendar changes"]]
	K --> FO["Attendee fan-out"]
	FO --> DB
	K --> BC["Busy-interval builder"]
	BC --> BUSY[("Busy cache, next 12 weeks")]
	AV --> BUSY
	K --> RS["Reminder scheduler"]
	RS --> PUSH["Push, email and pop-up delivery"]

All authoritative data lives in an event store partitioned by calendar id: series rows with their rules, single events, per-occurrence exceptions, attendee rows and ACLs. The view service reads the rows overlapping the requested window and expands repeating rules into occurrences in memory. Every write appends to a per-calendar change log, which feeds three derived systems: fan-out to attendees' calendars, a cache of busy intervals for availability queries, and the reminder scheduler.

Keeping the derived systems behind the change log matters. A slow reminder pipeline or a rebuild of the busy cache never blocks someone saving an event, and every derived view can be recomputed from the store if it goes wrong.

Event store

A sharded relational or wide-column store. Rows for one calendar sit together and are indexed by (calendar, end time) plus a flag for series whose rule has no end, so a window query is one range scan plus the open-ended series.

View service

Stateless. It expands rules with a well-tested RFC 5545 library, overlays exceptions, converts to the viewer's zone, and applies the viewer's own reminders and colours.

Availability service

Answers free and busy from a cache of merged busy intervals per calendar for the next twelve weeks, built from the change log, and expands on demand for dates beyond it.

Reminder scheduler

Keeps the next 48 hours of reminder fire times in time-bucketed queues and hands them to delivery a little early, with a deliver-at time.

05

Deep dive: repeating events, time zones and availability

06

Deep dive: repeating events, time zones and availability

The central problem

A daily standup with no end date has an unbounded number of occurrences. You cannot write them all, yet the week view must show the right five, in the right local times, with this Thursday's moved occurrence in its new slot, in well under 150 ms.

Store the rule, the exceptions and the zone

A series is a single row: start as a local wall-clock time plus an IANA zone (for example 09:30 in Asia/Kolkata), duration, the rule string, and an optional end. Changes to single occurrences are separate exception rows keyed by (series_id, original_start). An exception either cancels that occurrence or overrides fields such as time, title or location. The original start is the identity of the occurrence and never changes, so even an occurrence moved twice can still be matched to its slot in the rule.

To render a window, load series that could overlap it, generate occurrences from the rule between the window bounds, drop or replace any that have exceptions, and add exceptions that were moved into the window from outside. Rule expansion is cheap, a few microseconds per occurrence, and a week view rarely produces more than a few hundred. The expensive part is loading rows, which is why the index needs the open-ended flag: without it, a series that started in 2019 and never ends would be missed by a naive "start within window" query.

Building one week viewDiagram
flowchart TD
	Q["Window and viewer zone"] --> L["Load single events ending after window start"]
	Q --> LS["Load series with no end or end after window start"]
	LS --> X["Expand rule in its own zone"]
	X --> EX["Apply cancellations and overrides"]
	EX --> MV["Add occurrences moved into the window"]
	L --> M["Merge and sort"]
	MV --> M
	M --> TZ["Convert to viewer zone, apply personal settings"]

Why the zone name matters

A weekly 10:00 meeting in New York is at 14:00 UTC in summer and 15:00 UTC in winter. If you stored the series as a UTC instant, it would drift by an hour twice a year. If you stored a fixed offset like -04:00, the same thing happens. Only the zone name plus the tz database can produce the right instant for each occurrence, so expand in the series' own zone first, then convert to the viewer's zone. Edge cases need written policies: a 02:30 occurrence on the night clocks jump from 02:00 to 03:00 does not exist, and RFC 5545 says to shift it forward by the length of the gap; a 01:30 occurrence on the night clocks fall back happens twice, and you pick the first.

Governments change zone rules with a few weeks' notice. When a new tz database release arrives, roll it out to every server, then recompute derived data (busy cache, scheduled reminders) for calendars in the affected zones. Because series store local time and zone, the source data needs no change at all.

"This and following"

Editing the rest of a series is a split. The original series gets an UNTIL just before the chosen occurrence, and a new series starts there with the new details, linked to the old one so clients can show them as related. Exceptions after the split point move to the new series or are dropped if they no longer match. Doing this in one transaction on the calendar's shard keeps the two halves from overlapping or leaving a gap.

Invitations without cross-shard reads

A meeting with six guests lives on the organiser's calendar, but each guest's week view must show it quickly. Reading the organiser's shard for every guest's view would turn each page load into many cross-shard lookups. Instead, write an attendee copy into each guest's calendar: the time fields, rule and exceptions copied from the organiser, plus the guest's own response, reminders and colour. The fan-out worker applies organiser edits to every copy from the change log, so guests see changes within seconds. For a 4,000-person all-hands, fan-out runs in batches, and guests in the same organisation share one cached canonical record to cut the work.

Answering free and busy fast

The scheduling assistant asks for busy intervals for many people across a working day or a week. Expanding every person's series on every query would work but wastes CPU, because the answer rarely changes. The busy-interval builder listens to the change log and keeps, per calendar, a sorted and merged list of busy intervals for the next twelve weeks, stored as compact pairs of minutes. A query reads one small list per person and intersects them, which takes microseconds. Beyond twelve weeks, the service expands on demand. Privacy is enforced here as well: availability returns only intervals unless the caller is allowed to see details.

Reminders at the top of the hour

Most meetings start on the hour or half hour, and most reminders are set for 5, 10 or 30 minutes before. So reminder load piles up just before each boundary. The scheduler expands the next 48 hours of reminders into per-minute buckets keyed by fire time, rebuilds affected entries from the change log when events move, and dispatches each bucket 30 seconds early to the push system with a deliver-at time. Devices hold the notification until that moment. The heavy lifting happens in a smooth stream ahead of time, and the exact-minute spike is absorbed by phones, not by our servers.

06

Decisions and their costs

07

Decisions and their costs

Chosen
Expand rules when reading

One row per series whatever its length, edits are a single write, and open-ended rules need no extension job. Every view pays some CPU for expansion, which is small and bounded by the window.

Natural fit for rules
Alternative
Materialise occurrences ahead of time

Views become plain range scans. But you need a rolling job, a horizon, and bulk rewrites on every series edit, and storage grows with frequency times horizon.

Useful only for derived caches
Chosen
Attendee copies kept in step by fan-out

Each person's calendar is readable from one shard, so views stay fast. Guests may see an organiser's edit a few seconds late, and large meetings mean real fan-out work.

Fast reads for everyone
Alternative
One shared record, read at view time

No copies to keep consistent, and edits are visible instantly. Every view joins across many shards, and a popular organiser's shard becomes a hotspot.

Fine for small deployments
Chosen
Busy-interval cache for the near future

Availability for a dozen people costs a handful of small reads. The cache must be rebuilt when zone rules change or a builder bug is found, and it needs a fallback for far-off dates.

Fast scheduling assistant
Alternative
Expand every calendar per query

Always exact and needs no extra system. At peak, that is around a hundred thousand calendar expansions a second, mostly producing the same answers as a minute ago.

Wasteful at peak
Chosen
Partition by calendar

Views, sync tokens and ACL checks for a calendar stay on one shard. Users with many calendars touch several shards, which is acceptable because the view service reads them in parallel.

Matches how data is read
Alternative
Partition by user

Keeps everything a person owns together, but shared and resource calendars belong to nobody in particular and end up awkwardly placed.

Breaks down for shared calendars

07

Tempting shortcuts to avoid

--

Tempting shortcuts to avoid

ShortcutWhat goes wrongDo this instead
Write every occurrence of a series as a rowOpen-ended series need an arbitrary cutoff, a background job to extend them, and edits to the series rewrite thousands of rows.One series row plus exception rows, expanded per window.
Store times as UTC or as a fixed offsetRepeating meetings shift by an hour at each daylight saving change, and travellers see wrong local times.Local time plus an IANA zone name for anything that repeats.
Identify an occurrence by its current start timeOnce an occurrence is moved, edits and responses can no longer find the right one.Use the original start from the rule as the permanent key.
Query only events whose start falls in the windowLong events and old series that still repeat disappear from the view.Index by end time and keep a separate list of open-ended series.
Send one notification per changed occurrenceEditing a daily series on a big shared calendar sends tens of thousands of alerts.One notification per person per change, describing the series edit.
Fire every reminder exactly on the minuteThe push pipeline sees a burst many times its average just before every hour.Dispatch ahead of time with a deliver-at time.

08

Failure modes

08

Failure modes

🕑
Rule edge cases produce wrong occurrences

"Monthly on the 31st" skips February entirely, a 02:30 meeting vanishes on the night clocks spring forward, or "last Friday of the month" is miscomputed in a leap year.

Use one mature RFC 5545 library everywhere, server and clients, write down the policy for each edge case, and test with property-based generators that sweep across years and zones.
🌐
A country changes its time zone rules

A government abolishes daylight saving with three weeks' notice. Servers on the old tz data put meetings an hour off; reminders fire at the wrong time.

Ship tz database updates as a routine fast release, then rebuild busy caches and reminder buckets for affected zones. Stored series are untouched because they hold local time and a zone name.
📣
An edit to a huge meeting floods everyone

An admin moves a weekly all-hands with 12,000 guests. Fan-out and notification work spike, and the queue delays unrelated updates.

Give large fan-outs their own lower-priority queue, batch writes per shard, and send one summary notification per guest. Small meetings keep their fast lane.
🔔
Reminders arrive late or twice

A scheduler node dies holding a bucket for 09:55. Its reminders are missed, or a takeover node and a recovered node both send them.

Store buckets durably with a lease per bucket, so a takeover node continues where the failed one stopped. Give each reminder an id of event, occurrence and user, and let delivery drop duplicates.
🔄
Conflicting edits from two devices

A user moves a meeting on their laptop while their phone, offline on a train, changes its title. Both sync when the phone reconnects.

Each event carries a version, and updates are conditional on it. On conflict, merge field by field when changes touch different fields and ask the user only when they touch the same field.
🧮
Busy cache drifts from the truth

A bug in the interval builder drops cancelled exceptions, so the scheduling assistant shows people busy when they are free.

Sample queries and compare cached answers with on-demand expansion, alert on mismatch, and keep a one-command rebuild from the event store.

09

How to run the interview

09

How to run the interview

1

Raise repeating events in the first few minutes. Store the rule, expand per window. This is the problem's centre of gravity, and treating it as a plain CRUD app is the common miss.

2

Be specific about time zones. Local time plus an IANA zone, expand in the event's zone, convert for the viewer. Give the spring-forward example to prove you have thought about it.

3

Explain exceptions by original start. It answers "what if someone moves one occurrence and then edits the series" cleanly.

4

Treat free and busy as an interval problem. Merged, sorted intervals per calendar, then an intersection. Caching those intervals is a natural optimisation.

5

Spot the reminder spike. Point out that reminders cluster just before the hour and explain how you would smooth the load.

6

Choose between copies and joins for invitations. Either can be defended; say what you pay for each.

10

How the design grows

11

How the design grows

Stage 1: a small product
One database, one cron job

Events in a single table with a rule column, expansion in the application, and a cron job that scans for upcoming reminders each minute. Enough for a few hundred thousand users.

Stage 2: tens of millions of users
Sharding and exceptions done properly

Partition by calendar, add exception rows keyed by original start, and move expansion into a dedicated view service.

Stage 3: many devices per user
Change log, sync tokens and push

Every write goes to a per-calendar change log. Clients sync incrementally, CalDAV is served from the same log, and push replaces polling.

Stage 4: company calendars
Availability service and rooms

A busy-interval cache powers the scheduling assistant, room calendars become first-class resources, and large meetings get a separate fan-out lane.

Stage 5: global scale
Reminder pipeline and regional serving

Reminders move to time-bucketed queues with early dispatch. Calendars are served from the region nearest their owner, with replicas elsewhere for reads.

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.