Design Google Calendar

System design Medium OpenAIAirbnb
Reference
Start your answer

Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.

Problem statement

Create events, invite people, handle recurring events and time zones, show free/busy, and send reminders, for hundreds of millions of users.

Requirements

We are building a multi-user calendar service like Google Calendar: people create events, invite others, repeat events on a schedule, see each other's free/busy, and get reminders, across web and mobile clients for hundreds of millions of users.

Functional requirements

  • Create, edit, and delete events on calendars a user owns or has write access to (title, time, time zone, location, description, attendees).
  • Recurring events defined by an RRULE (daily, weekly on Mon/Wed, monthly on the last Friday), with per-instance edits, cancellations, and "this and following" splits.
  • Invite attendees by email; each attendee sees the event on their own calendar and can RSVP (yes / no / maybe) without changing the organizer's event.
  • Show a day / week / month view for one or more calendars, rendered in the viewer's time zone.
  • Free/busy query: for a set of people and a window, return busy intervals without exposing event details.
  • Reminders (popup, push, email) N minutes before each instance; notify attendees on invite, update, and cancel.
  • Multi-device sync: a change on the phone shows up on the laptop within seconds; clients that were offline for days catch up.
  • Share calendars with view-free/busy, view-details, edit, or manage permissions.

Non-functional requirements

  • Read-heavy: window reads must return in under 200 ms p99.
  • A write is durable and visible to the writer immediately (read-your-writes); attendees see it within a few seconds.
  • Reminders fire within 30 s of their due time, exactly once from the user's point of view.
  • Correct across time zones and DST changes: a 9:00 weekly meeting in New York stays at 9:00 New York time after DST shifts.
  • 99.99% availability for reads; writes may degrade but must not lose data.

Back-of-the-envelope estimation

  • 500M users, 100M DAU. Each user has ~3 calendars and ~2,000 stored event rows (series count as one row plus exceptions).
  • 500M × 2,000 × ~1 KB ≈ 1 PB of event data before replication. With ~4 attendees per event, full 1 KB copies per attendee would add ~4 PB; a ~100 B projection row adds ~0.4 PB instead, which is why attendees get a thin projection, not a copy.
  • Writes: 100M DAU × 5 changes/day ≈ 500M writes/day ≈ 6K/s average, 30K/s peak (Monday 9 a.m. local, rolling across zones).
  • Reads: each client opens and syncs ~30 times/day → 3B window reads/day ≈ 35K/s average, 150K/s peak. Most sync polls return "no changes".
  • Reminders: ~5 timed instances per DAU per day × 1.5 reminders ≈ 750M fires/day ≈ 9K/s average, with spikes at :00 and :30 (most meetings start on the hour) of 10× → ~100K/s for a few seconds.
API design

All writes take an Idempotency-Key header; the server stores the key with the result for 24 h and replays it on retry.

  • POST /calendars/{calId}/events body {summary, start: {dateTime, timeZone}, end: {...}, recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE"], attendees: [{email}], reminders: [{method: "popup", minutes: 10}]} → 201 {eventId, etag, sequence}.
  • PATCH /calendars/{calId}/events/{eventId} with If-Match: <etag> → 200 {etag} or 412 on a lost race. Query ?scope=instance&originalStart=2026-10-14T09:00 edits one instance; scope=following splits the series.
  • DELETE /calendars/{calId}/events/{eventId}?scope=...&notify=true → 204.
  • POST /calendars/{calId}/events/{eventId}/rsvp body {status: "accepted", comment} → 200. Writes only the caller's attendee row; never the event body, so it cannot conflict with an organizer edit.
  • GET /calendars/{calId}/events?timeMin=&timeMax=&tz=America/New_York&pageToken= → {items: [expanded instances], nextPageToken}. Recurring series come back expanded into instances inside the window.
  • GET /calendars/{calId}/events?syncToken=abc → {items: [changed or deleted events], nextSyncToken}; 410 Gone means the token is too old and the client must do a full window read.
  • POST /freeBusy body {timeMin, timeMax, items: [{id: "alice@x.com"}, ...]} (max 50 people) → {calendars: {"alice@x.com": {busy: [{start, end}]}}}.
  • GET /users/me/calendarList and PUT /calendars/{calId}/acl/{rule} for subscriptions and sharing.

Pagination is cursor-based (opaque token encoding last (start_time, event_id)), never offset.

Data model

Primary store: a horizontally sharded relational store with strong per-shard transactions (Spanner, CockroachDB, or Vitess-sharded MySQL), partitioned by calendar_id. Nearly every read and write is scoped to one calendar, so an event write plus its change-log row commit in one local transaction.

Table Key / partition Key columns Notes
calendars PK calendar_id owner_id, default_tz, kind (user, resource, shared)
acl (calendar_id, principal_id) role Secondary index on principal_id for "calendars I can see"
events (calendar_id, event_id) ical_uid, organizer_calendar_id, start_utc, end_utc, start_tz, rrule, series_until_utc, status, sequence, etag, body JSON Index (calendar_id, start_utc), (calendar_id, series_until_utc) for open series
event_exceptions (calendar_id, event_id, original_start) override fields or cancelled One row per modified/cancelled instance
attendees (calendar_id, event_id, attendee_email) rsvp, rsvp_updated_at, is_optional Lives with the organizer's event
attendee_projection (attendee_calendar_id, event_id) organizer_calendar_id, rsvp, personal reminders, color, start/end bounds The attendee's thin pointer to the organizer's event
change_log (calendar_id, seq) event_id, op, ts Monotonic per-calendar seq; drives sync tokens; TTL 30 days
reminders_due (time_bucket_minute, shard) instance key, user_id, method, fire_at_utc Rolling window, separate store
free_busy (calendar_id, day) sorted busy intervals Derived cache

Invariants worth defending:

  • The organizer's calendar is the only authority for an event's time, title, and attendee list. Attendee copies are projections and are rebuilt from it.
  • Each attendee's RSVP has exactly one writer: that attendee.
  • start_tz is stored as an IANA zone name, never only an offset; start_utc is derived and recomputed when zone rules change.
  • change_log.seq is strictly increasing per calendar and written in the same transaction as the event change.
High-level design
flowchart LR
  C[Clients web and mobile] --> GW[API gateway]
  GW --> ES[Event service]
  GW --> RS[Read and expansion service]
  GW --> SS[Sync service]
  GW --> FB[Free busy service]
  ES --> DB[(Sharded event store)]
  RS --> DB
  SS --> DB
  DB --> CDC[Change log CDC]
  CDC --> Q[Event bus]
  Q --> FO[Attendee fan-out workers]
  FO --> DB
  Q --> FBW[Free busy indexer]
  FBW --> FBC[(Free busy cache)]
  FB --> FBC
  Q --> RSch[Reminder scheduler]
  RSch --> RQ[(Reminder time wheel)]
  RQ --> RW[Reminder workers]
  RW --> N["Notify: push, email"]
  Q --> PS[Push channel service]
  PS --> C
  Q --> SR[Search indexer]
  1. Write. Client sends POST /events. The event service checks the ACL, normalizes the RRULE and time zone, and in one transaction on the organizer's calendar shard writes events, attendees, and a change_log row with the next seq. Returns etag and sequence.
  2. Fan-out. CDC publishes the change. Fan-out workers upsert an attendee_projection row on each internal attendee's calendar (with its own change_log entry on that shard) and send iCalendar invites (iTIP REQUEST) to external addresses. The free/busy indexer and reminder scheduler consume the same event.
  3. Read a window. The read service loads, for the calendar and [timeMin, timeMax]: single events overlapping the window, plus series whose start_utc <= timeMax and series_until_utc >= timeMin (or open-ended). It expands each RRULE in the series' own time zone, applies event_exceptions, converts to the viewer's zone, and merges. Projections are joined to their organizer events in a batched read.
  4. Sync. Clients hold a sync token = (calendar_id, last seq). The push channel sends a tiny "calendar X changed" ping; the client then calls ?syncToken= and receives only changed events.
  5. Reminders. The scheduler computes each instance's fire time and inserts it into the time wheel for the next 24 h. Reminder workers pop due buckets, check the event still exists with the same sequence, and send.
Deep dives

Recurring events, exceptions, and materialization. Store the series once (RRULE + DTSTART + IANA zone) and exceptions keyed by the instance's original start time. Expansion happens at read time, which keeps writes cheap and makes "edit all" a one-row update. Expand in the series' zone, not UTC: FREQ=WEEKLY;BYDAY=MO at 09:00 America/New_York gives 13:00 UTC in summer and 14:00 UTC in winter. "This and following" splits the series: set UNTIL on the old row to just before the split, create a new series from the split point, and move later exceptions to the new series. Open-ended series cost CPU on every read, so cache expanded instances per (series, month) and keep a rolling materialized table of the next 12 months only for consumers that need instance rows (reminders, free/busy). Cap expansion (e.g. 730 instances per series per request) to bound bad RRULEs like FREQ=MINUTELY.

Approach Write cost Read cost Problem
Store every instance Huge for open-ended series Simple range scan "Edit all" rewrites thousands of rows; infinite series impossible
Pure read-time expansion One row RRULE math on each read Hard to range-index; expensive for free/busy across 50 people
Series + exceptions + rolling 12-month cache One row plus async refresh Cache hit mostly Cache invalidation on series edit (bounded by series id)

Sync tokens, reconnect, and hot shared calendars. The change_log gives each calendar a gapless seq. A sync token encodes the last seq the client saw; the server returns all events whose latest change seq is greater, deduped to the newest version, including tombstones for deletes. Tombstones and log rows live 30 days; an older token gets 410 and the client does a bounded window resync (e.g. −30 to +365 days). Pushes carry no data, only "pull now", so a lost push is harmless; clients also poll every 5–15 min as a safety net. A hot shared calendar (company holidays, an all-hands with 100K attendees) must not fan out 100K projection writes: above ~1K attendees, store the event once and have attendees subscribe to it as a shared calendar, so reads pull it instead of the writer pushing it. Its change-log tail and window reads are cached at the read tier with short TTLs.

Organizer data vs attendee projections, and RSVP correctness. Copying the full event to every attendee makes edits O(attendees) and copies drift. Instead the organizer row is authoritative and each attendee gets a projection that holds only personal fields (RSVP, own reminders, color, hidden flag) plus time bounds for indexing. RSVP writes go to the organizer's attendees row with a conditional update on that one row, so an RSVP and an organizer edit to the title never conflict. When the organizer changes the time, bump sequence and reset RSVPs to needsAction only for material changes (time, location), not title typos. External attendees follow iTIP: their REPLY carries SEQUENCE and DTSTAMP, and the server drops replies with a lower sequence than the current one. Fan-out is asynchronous; if a worker crashes, the CDC offset is not committed and the idempotent upsert reruns.

Reminder scheduling, notifications, and dedupe. Do not schedule reminders for every instance forever. A scheduler keeps a 24–48 h horizon: an hourly sweep expands upcoming instances and writes (fire_minute, shard, instance_key, user, method) into a time-bucketed queue (Redis sorted sets per minute bucket, or a Kafka-partitioned delay queue backed by a table). Edits enqueue a recompute for that series. Workers pull the current minute's bucket, and before sending re-check that the event exists, the instance is not cancelled, the user has not declined, and sequence matches; stale entries are dropped instead of deleted eagerly. The dedupe key is (user, event_id, original_start, method, minutes_before), stored with a 48 h TTL via set-if-absent, so a retried worker never double-sends. To flatten the :00 spike (~100K/s), pre-load the next minute's bucket 60 s early and let the push provider deliver against a scheduled timestamp; email reminders can be sent a few seconds early. Popup reminders on devices are scheduled locally by the client from synced data, so the server path only needs push and email.

Time zones and rule changes. Store wall time + IANA zone for anything a human scheduled. When the tz database changes (a country drops DST), a job finds future instances in affected zones and recomputes start_utc, reminder times, and free/busy, so the meeting stays at 9:00 local. Past events keep their already-recorded UTC. All-day events are stored as dates with no zone, so a birthday stays on the same date wherever you travel.

Trade-offs
  • Read-time RRULE expansion keeps writes and "edit all" cheap but costs CPU per read; the rolling instance cache gives some of that back at the price of invalidation logic.
  • Organizer-authoritative events with attendee projections avoid copy drift and make RSVPs conflict-free, but every attendee window read needs a join to another shard (batched, cached).
  • Partitioning by calendar gives single-shard transactions for nearly everything, but cross-calendar queries (free/busy for 50 people, search) need separate derived stores that lag by seconds.
  • Notify-then-pull sync is simple and survives lost pushes, at the cost of one extra round trip per change.
  • Large-meeting cutoff (subscribe instead of fan-out) protects write throughput but means those events behave a bit differently, for example personal reminders live on a subscription row.
  • Free/busy from a derived cache can be a few seconds stale; booking two people into the same slot is possible, which is acceptable for people but not for rooms. Room/resource calendars therefore take a conditional write on the resource's own shard to reject double booking.
  • Search and import/export run on separate async pipelines (inverted index fed by CDC; bulk ICS jobs) so a 50K-event import cannot slow interactive writes.
Interviewer expectations

Mid-level (L4)

  • Clean API and schema for events, attendees, and calendars; stores time with a time zone.
  • Stores a recurring event as a rule, not as thousands of rows, and handles one-instance edits.
  • A basic reminder queue and a way to show the event on attendees' calendars.

Senior (L5)

  • Organizer-authoritative event with attendee projections; RSVP that cannot clobber organizer edits; idempotent writes and etags.
  • Sync tokens from a per-calendar change log, 410 recovery, and push as a hint rather than the data.
  • Reminder horizon, re-validation before sending, dedupe keys, and handling the top-of-hour spike.
  • DST-correct expansion in the series' zone; "this and following" splits.

Staff (L6+)

  • Partitioning choice justified by access patterns, with derived stores (free/busy, search, instance cache) and their staleness budgets stated.
  • Hot shared calendars and 100K-attendee events handled by switching from push fan-out to subscription.
  • tz database updates rewriting future instances; interop with external calendars via iTIP sequence rules.
  • Resource calendars and delegated scheduling: conditional booking on the resource shard, delegate ACLs, audit of who acted on whose behalf.