Communication
Design Google Calendar
01
What the calendar must do
What the calendar must do
- 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.
- 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.
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
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.
| Quantity | Working | Result |
|---|---|---|
| View loads per day | 300M users x 15 views and syncs | 4.5B |
| Average read rate | 4.5B / 86,400 s | about 52,000 / s |
| Weekday morning peak | 3x average, as several big time zones start work | about 156,000 / s |
| Writes per day | 300M x 0.8 | 240M, about 2,800 / s |
| Attendee updates | 35% of events have guests, 6 guests on average | about 500M attendee rows touched a day |
| Free and busy queries | peak 12,000 / s x 8 calendars each | about 96,000 calendar lookups / s |
| Reminders fired | 300M users x 4 a day | 1.2B, about 14,000 / s average |
| Top-of-hour spike | about 20% of an hour's reminders fall in the minute before :00 | about 170,000 / s for that minute |
| Stored events | 900M accounts x about 2,500 events and series | about 2.2 trillion rows |
| Storage | 2.2T x about 1.2 KB, 3 replicas | about 2.6 PB raw, 8 PB stored |
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
Client-facing endpoints
04
Architecture overview
Architecture overview
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.
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.
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.
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.
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
Deep dive: repeating events, time zones and availability
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.
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
Decisions and their costs
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 rulesViews 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 cachesEach 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 everyoneNo 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 deploymentsAvailability 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 assistantAlways 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 peakViews, 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 readKeeps everything a person owns together, but shared and resource calendars belong to nobody in particular and end up awkwardly placed.
Breaks down for shared calendars07
Tempting shortcuts to avoid
Tempting shortcuts to avoid
| Shortcut | What goes wrong | Do this instead |
|---|---|---|
| Write every occurrence of a series as a row | Open-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 offset | Repeating 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 time | Once 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 window | Long 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 occurrence | Editing 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 minute | The 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
Failure modes
"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 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 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.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.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.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
How to run the interview
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.
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.
Explain exceptions by original start. It answers "what if someone moves one occurrence and then edits the series" cleanly.
Treat free and busy as an interval problem. Merged, sorted intervals per calendar, then an intersection. Caching those intervals is a natural optimisation.
Spot the reminder spike. Point out that reminders cluster just before the hour and explain how you would smooth the load.
Choose between copies and joins for invitations. Either can be defended; say what you pay for each.
10
How the design grows
How the design grows
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.
Partition by calendar, add exception rows keyed by original start, and move expansion into a dedicated view service.
Every write goes to a per-calendar change log. Clients sync incrementally, CalDAV is served from the same log, and push replaces polling.
A busy-interval cache powers the scheduling assistant, room calendars become first-class resources, and large meetings get a separate fan-out lane.
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.