“Design Google Calendar” sounds like a CRUD app: a user creates an event, it shows up in a grid, the phone syncs it. The interviewer lets that run for a minute, then asks the four questions the toy version cannot answer. First: a user creates “team standup, every weekday at 9am, forever” - do you really write one row per day until the heat death of the universe? Second: someone opens the scheduling assistant, picks eight coworkers across four timezones, and expects to see everyone’s free-busy for next Tuesday in under a second. Third: a billion users have reminders set, and each must fire within a few seconds of its due time even though “due time” depends on the attendee’s timezone and DST rules. Fourth: I edit an event on my laptop offline on a plane, my phone edits the same event, and both must converge without losing data when I land.
Those four problems - recurring events without storage explosion, free-busy across many attendees and timezones, reminders that fire on time at scale, and multi-device offline sync - are the spine of the system. Everything hard hangs off them: how you store a rule instead of a million instances, how you expand that rule for a view without recomputing the world, how you index availability so an eight-person query is fast, how you schedule a billion timers cheaply, and how you converge concurrent edits from a laptop and a phone. Let me build it properly.
Functional Requirements (FR)
In scope:
- Create, read, update, delete events. A user creates an event with a title, start/end time, timezone, location, and description. Single events and all-day events.
- Recurring events. “Every weekday,” “first Monday of the month,” “every year on my birthday,” with an end condition or forever. Plus the messy part: edit or delete one instance of a series, this and future, or the whole series.
- Invite others and RSVP. An organizer invites attendees; each attendee sees the event on their own calendar and responds yes/no/maybe. Attendees can be internal users or external email addresses.
- Free-busy / availability. The scheduling assistant shows, for a set of attendees over a window, which slots everyone is free - across their timezones.
- Reminders / notifications. A user sets one or more reminders per event (10 minutes before, 1 day before) delivered by push, email, or popup. Reminders must fire close to on time.
- Multi-device, multi-timezone sync. The same account on laptop, phone, and tablet stays consistent. Offline edits reconcile on reconnect. A user who flies from Delhi to New York sees times shift correctly.
- Multiple calendars per user. Personal, work, a shared team calendar, subscribed public calendars (holidays), each toggle-able.
Explicitly out of scope (say it so you own the scope):
- Video-conferencing internals. We attach a meeting link as an opaque field; we do not build Meet.
- Email delivery infrastructure. Invite and reminder emails hand off to an existing mail system; we design the trigger and the payload, not the SMTP fleet.
- Rich search / smart suggestions / ML (“find a time” auto-scheduling ranking). We design free-busy retrieval and leave a seam for a suggestion service.
- Room/resource booking as finite inventory. We treat a room as just another attendee with a calendar; hard double-booking prevention for scarce rooms is a bolt-on, not the core.
The one decision that drives everything: a calendar is not a list of events, it is a set of rules that generate events over a time window, read far more than written, and viewed through the lens of a timezone. A recurring event is a rule, not a million rows. A “show me this week” query is an expansion of rules over a range. Availability is an interval-overlap problem across attendees. And every timestamp has two identities - an absolute instant and a wall-clock time in some zone - and confusing them is the bug that eats calendar systems alive. Those themes - rules over ranges, interval overlap, and instant-vs-wall-clock - recur everywhere.
Non-Functional Requirements (NFR)
- Scale: ~1B users, ~100M events created/day, a far larger number of event reads and view renders. Most events have 1-a few attendees; some (all-hands, shared calendars) have thousands. Reminders fire continuously, on the order of the event volume.
- Latency: open a day/week/month view under ~200ms (this is the dominant read). Free-busy for up to ~20 attendees over a 1-2 week window under ~1s. Create/update under ~300ms. A reminder must fire within a few seconds of its due instant - lateness is the visible failure.
- Availability: 99.99% on read and view paths. Create/update highly available. Reminder delivery is at-least-once and near-real-time; a missed reminder is a product failure, a duplicate reminder is a minor annoyance.
- Consistency: a user reading their own calendar wants read-your-writes - I create an event and it is there on refresh. Across attendees, invite propagation is eventually consistent (seconds). Free-busy may be slightly stale. The one hard invariant is convergence: concurrent edits from multiple devices must deterministically converge to the same final state, never silently lose an edit.
- Durability: events are user data - durable, replicated, never lost. A deleted event is soft-deleted and tombstoned so the delete propagates to every device rather than resurrecting from a stale replica.
- Timezone correctness (the quiet invariant): a recurring event’s wall-clock time is anchored to a named timezone, and every instance’s absolute instant is derived through that zone’s DST rules at expansion time. “9am every day” stays 9am local across a DST transition even though the UTC offset changed.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because they dictate where the pressure is.
Writes (event create/update):
100M events created/day.
= 100,000,000 / 86,400
≈ 1,160 creates/sec average
Updates (RSVPs, edits, moves) ~3x creates ≈ 3,500 updates/sec
Peak (Monday morning, start of quarter, ~5x) ≈ 6,000 creates/sec, ~18,000 writes/sec
Writes are non-trivial but not the hard part. Fan-out is: one event with 500 attendees is one write that must appear on 500 calendars.
Reads (the dominant load - view renders + sync polls):
1B users, say 400M open a calendar view on a given day, ~10 view renders each
(switch day/week/month, navigate weeks) = 4B view renders/day
= 4,000,000,000 / 86,400
≈ 46,000 view renders/sec average, ~200,000/sec peak.
Each view expands recurring rules over the visible range for several calendars.
Reads dwarf writes by ~30x, and each read is not a row fetch - it is a rule expansion over a time range. That is where the CPU goes.
Reminders (a continuous firing load):
Assume ~1.5 reminders per event, most events have a reminder.
Steady state roughly tracks the event instance rate. With recurring series, the
number of *instances due today* is far larger than events-created-today.
Order of magnitude: hundreds of millions of reminders/day
≈ 100,000,000+ / 86,400 ≈ 1,200+ /sec average, bursty on the minute boundary
(everyone's "9:00 standup, remind 10 min before" fires at 8:50 sharp).
Bursts at :00, :30 wall-clock across every timezone are the real challenge.
Reminders are bursty, not smooth: they clump on round-minute boundaries, per timezone. The scheduler must absorb spikes without firing late.
Storage:
Events: model recurring as ONE rule row, not N instances.
Say 1B users * ~500 events each (accumulated) ≈ 500B logical instances,
BUT stored as rules: ~1B users * ~200 series/single rows * ~1KB
≈ 200B rows * 1KB ≈ 200 TB base event data.
Attendee/participation rows (event <-> user): ~2-4x event rows.
Storing every recurring instance instead would be 100B+ rows/year of pure
generated data - the whole reason we store rules, not instances.
Reminder schedule entries: kept as derived, time-bucketed, not one-per-instance-forever.
Storing rules instead of materialized instances is the difference between ~200 TB and a runaway multi-petabyte table of generated rows. That single modeling choice is the storage story.
Free-busy index:
Per user, a compact busy-interval list over a rolling window (say next 60 days).
Avg ~40 busy intervals/user * ~24 bytes ≈ 1 KB/user * 1B ≈ 1 TB hot free-busy,
cacheable, keyed by user. An 8-attendee query = 8 small interval-list reads + a merge.
The takeaways: reads (view renders) dominate by ~30x and each is a rule expansion, so the design budget goes into expanding recurrence cheaply and caching it; reminders are a bursty firing problem needing a time-bucketed scheduler; storage is a modeling problem solved by storing rules not instances; and free-busy is an interval-merge kept fast by a per-user busy-interval index.
High-Level Design (HLD)
The architecture separates the write plane (create/update events, fan out to attendees, emit changes) from the read plane (expand rules into a view, cached, sync-driven) and two specialized subsystems: a reminder scheduler (time-bucketed timers firing at scale) and a free-busy service (interval indexes and merges). Sync ties devices to the write log via a per-user change feed.
┌───────────────┐ create / view / sync / rsvp ┌────────────────────┐
│ Client │──────────────────────────────────▶│ Mail / Push (ext.) │
│ (web / iOS / │◀── delta sync (change tokens) ────│ (invites,reminders)│
│ Android) │ └─────────▲──────────┘
└──────┬─────────┘ │
│ │ deliver
┌──────▼───────────┐ │
│ API Gateway │ auth, rate limit, routing │
└──────┬───────────┘ │
│ │
┌──────┼─────────────────────────────────────────────────┐ │
│ │ WRITE / READ PLANE │ │
│ ┌────▼────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ Event Svc │ │ Expansion │ │ Free-Busy Svc │ │ │
│ │ (CRUD, RRULE │ │ Svc (rule → │ │ (busy intervals, │ │ │
│ │ overrides) │ │ instances in │ │ N-attendee merge│ │ │
│ │ │ │ a range) │ │ across zones) │ │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │ │
│ │ │ │ │ │
│ ┌──────▼────────────────▼──┐ ┌────────▼────────┐ │ │
│ │ Event Store (sharded by │ │ Free-busy index │ │ │
│ │ calendar_id; rules + │ │ (Redis per-user │ │ │
│ │ per-instance overrides) │ │ busy list) │ │ │
│ └──────────┬────────────────┘ └─────────────────┘ │ │
│ │ change log (CDC) │ │
└────────────┼─────────────────────────────────────────────┘ │
│ │
┌────────────▼─────────────┐ ┌──────────────────────────────┐ │
│ Sync Svc (per-user change │ │ Fan-out worker │ │
│ feed, change tokens, │ │ (invite → each attendee's │ │
│ tombstones) │ │ calendar; RSVP propagation) │ │
└───────────────────────────┘ └──────────────────────────────┘ │
│ │
┌────────────▼──────────────────────────────────────────────┐ │
│ REMINDER SCHEDULER │ │
│ ┌───────────────┐ ┌──────────────┐ ┌────────────────┐ │ │
│ │ Time-bucketed │──▶│ Due-bucket │──▶│ Delivery worker│─┼──────┘
│ │ store (minute │ │ poller (per │ │ (dedupe, push/ │ │
│ │ buckets, UTC) │ │ minute) │ │ email/popup) │ │
│ └───────────────┘ └──────────────┘ └────────────────┘ │
└────────────────────────────────────────────────────────────┘
View flow (the dominant read path):
- Client opens “this week” for its enabled calendars. Request hits the Event Service, which for each calendar fetches (a) single events whose interval overlaps the window and (b) the recurrence rules whose active span overlaps the window.
- The Expansion Service expands each rule into concrete instances only within the requested window (a “every weekday” rule over a 7-day view yields 5 instances), then applies any per-instance overrides (a moved or cancelled occurrence) and exceptions.
- All instances are converted from their anchored timezone to the viewer’s current timezone, merged, sorted, and returned. Popular expansions are cached; repeat views of the same week hit the cache.
Create / invite flow (the write path):
- User creates an event (single or a rule) on their calendar. The Event Service validates, stores the canonical event (rule + timezone anchor), and appends to the per-calendar change log.
- If there are attendees, a Fan-out worker creates a participation record on each attendee’s calendar (an event they can RSVP to), and triggers invite emails/pushes for external and internal attendees.
- If the event has reminders, the scheduler is told to enqueue timer entries for the upcoming instances (see the reminder deep dive - we do not enqueue “forever,” we enqueue a horizon).
- The change is published to each affected user’s sync feed so their other devices pull it on next sync.
Reminder flow:
- As instances come due within a rolling horizon, entries land in minute-bucketed timer storage keyed by UTC due-minute.
- A poller reads the current (and slightly overdue) minute buckets every few seconds and hands entries to delivery workers.
- Workers dedupe (at-least-once storage can deliver twice), resolve the channel (push/email/popup), and deliver. Fired entries are marked done; for recurring series the next horizon is topped up.
Sync flow:
- Each device holds a change token (a per-user cursor into the change feed). On sync it sends the token; the Sync Service returns everything changed since - creates, updates, and tombstones for deletes - plus a new token.
- Offline edits are queued locally and replayed on reconnect; conflicts converge by the rules in the sync deep dive.
The key insight: the calendar stores rules and expands them per view (read-optimized around a write-cheap model), reminders are a separate time-bucketed scheduler so a billion timers cost almost nothing at rest, and devices stay consistent through a per-user change feed with tokens and tombstones rather than re-downloading the world.
Component Deep Dive
The hard parts, naive-first then evolved: (1) recurring events - storing a rule not a million rows, and editing single instances; (2) reminders at scale - firing a billion timers on time; (3) free-busy across attendees and timezones; (4) multi-device offline sync and conflict convergence.
1. Recurring events - store the rule, expand the view
Recurrence is the defining modeling problem. Get it wrong and you either blow up storage or make every view slow.
Naive approach: materialize every instance as a row.
"Standup every weekday at 9am, no end" ->
INSERT one events row per weekday, out to... when? Forever is impossible,
so pick a horizon (say 2 years) and insert ~520 rows. For all users.
"Show this week" -> SELECT * FROM events WHERE user=? AND start BETWEEN ... (simple!)
Where it breaks:
- Storage explosion. A single “daily” rule is hundreds of rows; a company all-hands recurring weekly with 5,000 attendees is thousands of rows per attendee. Multiply across 1B users and you are generating hundreds of billions of pure-derived rows a year. The BoE showed this is the difference between ~200 TB and multi-petabyte.
- “Forever” is a lie. You must pick a horizon and then run a job to keep extending it, forever, for every open-ended series. A generation backlog is now a permanent operational burden.
- Editing the series is a mass update. “Change standup to 9:30” rewrites hundreds of rows. “Delete the series” deletes hundreds. Every edit touches N rows.
It is beautifully simple to query and completely unscalable to store. It is fine for a single user’s toy app and wrong at calendar scale.
First evolution: store the recurrence as a rule (RRULE), expand at read time. Store one row: the event plus a recurrence rule in the iCalendar RRULE grammar, anchored to a named timezone.
event {
id, calendar_id, title,
dtstart = 2026-01-05T09:00, # wall-clock start of the FIRST instance
timezone = "Asia/Kolkata", # the anchor - instances are 9am HERE
duration = 30min,
rrule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", # every weekday
until / count = null # open-ended
}
To render “this week,” the Expansion Service takes the rule and the visible window [Mon, Sun) and generates only the instances that fall inside it - for a weekday rule, 5 timestamps. It computes each instance’s wall-clock start in the anchor zone, then converts to an absolute instant through that zone’s DST rules at that date (this is why we store a named zone, not a fixed offset - “9am” survives a DST change). Storage is one row; the view does bounded work.
But rule expansion has three problems the naive rule model ignores: editing one instance, deleting/skipping instances, and the cost of expanding across many calendars per view. Handle each:
- Per-instance overrides (the “edit just this one” case). When a user drags Wednesday’s standup to 10am, we do not rewrite the rule. We write an override row keyed by (series_id, original_instance_start): a small exception record saying “the instance that would have been 2026-01-07T09:00 is actually at 10:00 (or cancelled, or retitled).” Expansion generates instances from the rule, then patches in overrides and drops cancellations. iCalendar calls these RECURRENCE-ID overrides and EXDATE exceptions.
override {
series_id, recurrence_id = 2026-01-07T09:00, # which instance (by original start)
status = "moved" | "cancelled",
new_start, new_title, ... # the patch
}
“This and following” edits. Splitting a series at a date is modeled by ending the old rule (
UNTIL = split_date) and creating a new rule from the split date with the changed fields. The series becomes two rules chained by asplit_frompointer. This keeps past instances intact and future ones changed, without touching every row.Expansion cost and caching. Expanding rules for every view of every calendar is CPU. Two mitigations: (a) bound the window - never expand more than the visible range plus a small look-ahead; (b) cache expanded instances per (calendar, week) with the rule’s version as part of the cache key, so a repeat view is a cache hit and any edit (which bumps the version/etag) invalidates just the affected weeks. For very hot shared calendars, precompute the current and next few weeks.
So we keep the rule as the source of truth and a materialized cache as an accelerator: rules + overrides + exceptions stored compactly, expanded lazily within a bounded window, and cached per week keyed by version. The invariant: a recurring event is a rule plus a small set of instance-level patches; instances are derived, never the stored truth.
2. Reminders at scale - firing a billion timers on time
Every event can have reminders. They must fire within seconds of due, they are bursty on minute boundaries, and there are hundreds of millions a day. This is a scheduling problem, not a CRUD one.
Naive approach: a poller that scans all events for “due soon.”
every minute:
SELECT * FROM reminders WHERE remind_at BETWEEN now AND now+1min
-> deliver each
Where it breaks:
- Full-table scan per minute. With billions of reminder rows, scanning for the tiny slice due in the next minute is enormous wasted I/O every single minute, forever. An index on
remind_athelps but the write amplification and hot index tail (everything clustered near “now”) make it a scaling wall. - Recurring reminders have no single
remind_at. A “10 min before, every weekday” reminder is not one timestamp - it is a stream. You would have to materialize future reminder rows (the same instance-explosion we just avoided for events). - Bursts crush it. At 8:50 every timezone’s standups remind at once. A minute-granularity scan that also has to deliver them all in that minute falls behind, and late reminders are the visible failure.
First evolution: a time-bucketed schedule (timing wheel). Bucket reminders by their due minute in UTC. The store is keyed by minute-bucket; each bucket holds the reminder entries due in that minute.
bucket:{2026-07-15T08:50Z} -> [ {event_id, user_id, channel, series_ptr}, ... ]
A poller reads only the current bucket (and the immediately previous one or two, to catch stragglers) every few seconds, hands entries to delivery workers, and moves on. No scanning - the bucket key is the due time, so finding “what is due now” is a direct read of a few keys, independent of total reminder count. This is a distributed timing wheel: buckets are the wheel slots, the poller is the hand.
To handle scale and bursts:
- Shard the buckets (bucket key + shard suffix) so a heavy minute is spread across many partitions and many pollers/workers, not one hot key. Delivery is horizontally scalable.
- Enqueue a rolling horizon, not forever. For a recurring reminder we do not create a bucket entry for every future instance. We schedule the next instance (and maybe a few) into buckets; when it fires, we compute and enqueue the next one from the rule. The schedule stays bounded regardless of “forever” series - the same rule-not-instances discipline as events.
- At-least-once with dedupe. Buckets and delivery use at-least-once semantics (a worker may crash after delivering, before marking done, and retry). Deliveries carry an idempotency key (event_id + instance + channel + user) so the client/notification layer suppresses duplicates. Better a rare duplicate than a missed reminder.
- Timezone and DST at enqueue time. A reminder’s UTC due-minute is computed from the instance’s wall-clock time through the anchor zone’s rules. If the user changes their event’s timezone or a DST boundary shifts the instant, the next enqueue recomputes it. We store buckets in UTC (a single global timeline) but derive the bucket from local rules.
The principle: reminders are a time-bucketed timing wheel keyed by UTC due-minute, sharded for burst absorption, topped up on a rolling horizon so recurring reminders never materialize forever, delivered at-least-once with idempotent dedupe. Finding what is due is a keyed read, not a scan, so a billion reminders cost nothing at rest.
3. Free-busy across attendees and timezones
The scheduling assistant asks: over this window, when are all these attendees free? It must answer for up to ~20 people across timezones in under a second.
Naive approach: for each attendee, fetch and expand their whole calendar, then compare.
for each attendee:
events = expand ALL their calendars over the window # full detail
merge and look for gaps
Where it breaks:
- Over-fetching private detail. Free-busy needs only busy/free intervals, not titles, guests, or descriptions - fetching full events is wasteful and a privacy leak across org boundaries.
- Repeated rule expansion is expensive. Expanding every attendee’s full recurrence set on every assistant query, for 20 people, blows the 1s budget.
- Timezone confusion. Attendees anchor events to different zones; comparing wall-clock times across people is the classic instant-vs-wall-clock bug. You must compare on the absolute timeline.
The answer: a precomputed per-user busy-interval index, merged on the absolute timeline. Maintain, per user, a compact busy-interval list over a rolling window (say next 60 days): a sorted list of [start_instant, end_instant) half-open intervals in UTC, derived by expanding that user’s events once and stripping everything but the interval and a free/busy/tentative flag.
busy:{user_id} -> [ [t0,t1), [t2,t3), ... ] # UTC instants, sorted, non-overlapping-merged
This index is updated incrementally when the user’s events change (CDC from the event store re-derives the touched window), not recomputed per query. A free-busy query then:
- Reads the busy lists for the N attendees - N small keyed reads, ~1 KB each.
- Merges them on the UTC timeline. Overlap of two half-open intervals is the classic
aStart < bEnd AND bStart < aEnd; a sweep over all intervals produces the union of busy time. - The gaps in the union, intersected with working-hours per attendee (each attendee’s working hours are in their zone - convert their working-hours mask to UTC before intersecting), are the common free slots.
- Convert the resulting slots into the requester’s timezone for display.
Because everything is compared as absolute UTC instants, cross-timezone comparison is correct by construction; timezone only re-enters at the edges (working-hours masks in, display out). The index makes an N-attendee query N small reads plus a linear merge, not N full calendar expansions - comfortably under 1s for 20 people.
Rooms and resources are modeled as pseudo-users with their own busy index, so “find a time when everyone and a room are free” is just one more interval list in the merge. Hard prevention of double-booking a scarce room, if required, is layered on top as an atomic claim at booking time (the same interval-overlap-under-a-lock pattern used for finite inventory), but the availability display is just the merge.
The principle: free-busy is a precomputed per-user UTC busy-interval index merged on the absolute timeline; expand once on write, read cheaply on query, and let timezone in only at the working-hours and display edges.
4. Multi-device offline sync and conflict convergence
The same account lives on a laptop, a phone, and a tablet. They must converge, offline edits must replay, and no edit may silently vanish.
Naive approach: each device re-downloads the full calendar periodically.
every sync: GET /events?updated_since=<last_time> # by wall-clock timestamp
apply whatever comes back, last-write-wins by timestamp
Where it breaks:
- Timestamp-based sync is unreliable. Client clocks are wrong and skewed; “updated_since a timestamp” misses edits that landed with an earlier clock or double-counts. And it cannot express deletes - an event that vanished server-side is simply absent, so a device that had it never learns it is gone (the resurrection bug: a delete that doesn’t propagate lets a stale replica re-create the event).
- Full re-download is wasteful. Pulling the whole calendar to find the two things that changed burns bandwidth and battery for 400M daily users.
- Last-write-wins by client timestamp loses data. Two offline edits with skewed clocks: the one with the wrong later clock wins, silently discarding the real newer edit.
First evolution: a per-user change feed with opaque change tokens. Maintain a monotonic, server-assigned change sequence per user (a log of every create/update/delete to any of their calendars). A device holds an opaque change token = its cursor into that log. Sync is:
POST /sync { change_token }
-> { changes: [ {event, op: upsert|delete}, ... ],
next_change_token }
The server returns everything after the token - a delta, not the world - and a new token. The sequence is server-assigned and monotonic, so it does not depend on client clocks at all. This is exactly how Google’s Calendar API syncToken works.
Deletes are tombstones, not absences. A delete writes a tombstone into the change feed (op=delete with the id) and soft-deletes the row. The device receives the tombstone and removes the event locally. Tombstones are retained long enough to cover the longest plausible offline gap; a device whose token is older than retention gets a full_sync_required and re-baselines. Tombstones kill the resurrection bug: the delete is a positive fact that propagates, not the absence of a row.
Offline edits and conflict convergence. A device queues edits locally while offline and replays them on reconnect. Two devices editing the same event concurrently is the conflict case. Convergence rules:
- Version each event with an etag / version number. An update is conditional: “update event X if its version is V.” On reconnect the device sends its edit with the base version it started from.
- If the server version still matches, the edit applies and the version bumps - fast path, no conflict.
- If it has moved on (someone else edited first), the server rejects the conditional write and returns the current event. The client reconciles: for disjoint fields (one device changed the time, the other the title) auto-merge field-by-field; for the same field changed both ways, apply a deterministic tiebreak (highest version wins, or surface a conflict copy the way contacts sync does) so both devices, replaying the same log, reach the same result. Determinism is the point - convergence must not depend on arrival order.
- Read-your-writes for the editing device is preserved by applying the local edit optimistically and reconciling against the authoritative version on sync.
Timezone on sync. Events sync with their anchored wall-clock time + named timezone, never a device-local absolute. A device in New York and one in Delhi store the identical canonical event; each renders it in the viewer’s current zone at display time. Flying from Delhi to New York changes the render, not the stored event. This is the instant-vs-wall-clock discipline enforced at the sync boundary.
The principle: sync is a per-user monotonic change feed consumed by opaque tokens returning deltas with tombstones for deletes; conflicts converge deterministically via conditional versioned writes and field-level merge; and events sync as wall-clock-plus-zone so every device renders correctly in its own timezone. No clocks trusted, no deletes lost, no edits silently dropped.
API Design & Data Schema
The read/sync path is delta-oriented; writes are versioned and idempotent.
REST endpoints
GET /api/v1/calendars/{cal_id}/events?time_min=&time_max=&tz=
-> { events:[ { id, title, start, end, tz, recurring_event_id?,
status, etag } ], # expanded instances in [time_min,time_max)
next_page_token }
(single events + expanded rule instances + overrides, in requested window)
POST /api/v1/calendars/{cal_id}/events
body: { title, start, end, timezone, rrule?, attendees[], reminders[] }
Idempotency-Key: <client-uuid>
-> 201 { id, etag, ... }
PATCH /api/v1/calendars/{cal_id}/events/{event_id}?scope=single|following|all
If-Match: <etag> # conditional write for convergence
body: { changed fields, recurrence_id? } # recurrence_id targets one instance
-> 200 { id, etag, ... }
-> 412 { current_event } # version moved on -> client reconciles
DELETE /api/v1/calendars/{cal_id}/events/{event_id}?scope=single|following|all
If-Match: <etag>
-> 204 (writes a tombstone into the change feed)
POST /api/v1/calendars/{cal_id}/events/{event_id}/rsvp
body: { response: "accepted|declined|tentative" }
-> 200
POST /api/v1/freebusy
body: { attendees:[user_or_room_id], time_min, time_max, requester_tz }
-> { busy: { attendee_id: [ {start,end} ] }, common_free:[ {start,end} ] }
(intervals in requester_tz for display; merged on UTC internally)
POST /api/v1/sync
body: { change_token } # opaque cursor; empty = full sync
-> { changes:[ {op:"upsert|delete", event?, event_id} ],
next_change_token,
full_sync_required? } # true if token older than tombstone retention
The POST /events and sync paths are idempotent (client idempotency key, monotonic token); PATCH/DELETE are conditional on If-Match etag so concurrent device edits converge deterministically.
Data stores - the right tool per job
1. Event store - relational or NewSQL (PostgreSQL / Spanner-like), sharded by calendar_id. Events are user data needing durability, per-user read-your-writes, and multi-row transactions (event + attendees + change-log append in one commit). A calendar’s events, overrides, and change feed co-locate on one shard so those writes are single-shard. NoSQL would work for the raw KV, but the change-log-plus-event transactional coupling and the need for consistent reads favor SQL/NewSQL here.
Table: events
id BIGINT PRIMARY KEY -- Snowflake, time-sortable
calendar_id BIGINT -- shard key
organizer_id BIGINT
title, location, description TEXT
dtstart_local TIMESTAMP NOT NULL -- WALL-CLOCK start of first instance
timezone VARCHAR NOT NULL -- IANA zone, e.g. "Asia/Kolkata"
duration_secs INT
rrule TEXT NULL -- null = single event
rrule_until TIMESTAMP NULL
split_from BIGINT NULL -- "this and following" chain
status ENUM(confirmed, cancelled)
version BIGINT NOT NULL -- etag / conditional-write guard
updated_seq BIGINT -- position in user's change feed
created_at, updated_at TIMESTAMP
Index: (calendar_id, dtstart_local) -- single-event window scan
Index: (calendar_id, updated_seq) -- delta sync
Shard key: calendar_id
Table: recurrence_overrides -- per-instance edits/cancellations
series_id BIGINT -- FK to events.id (the rule)
recurrence_id TIMESTAMP NOT NULL -- ORIGINAL wall-clock start of instance
status ENUM(moved, cancelled)
new_start_local TIMESTAMP NULL
patch JSONB -- changed fields for this instance
PRIMARY KEY (series_id, recurrence_id)
Table: attendees -- event <-> participant, fan-out target
event_id BIGINT
attendee_id BIGINT -- internal user id or external hash
email VARCHAR
role ENUM(organizer, required, optional)
response ENUM(needs_action, accepted, declined, tentative)
PRIMARY KEY (event_id, attendee_id)
Index: (attendee_id) -- "events I'm invited to"
Table: reminders
event_id BIGINT
attendee_id BIGINT
minutes_before INT
channel ENUM(push, email, popup)
PRIMARY KEY (event_id, attendee_id, minutes_before, channel)
Table: change_feed -- per-user monotonic sync log
user_id BIGINT
seq BIGINT -- monotonic per user
op ENUM(upsert, delete)
event_id BIGINT
ts TIMESTAMP
PRIMARY KEY (user_id, seq) -- deltas + tombstones (op=delete)
2. Free-busy index - Redis / in-memory, keyed by user_id. A per-user sorted, merged list of UTC busy intervals over a rolling 60-day window. Derived from the event store via CDC; a fast filter for the scheduling assistant, not a source of truth.
busy:{user_id} -> [ [start_utc, end_utc, flag], ... ] # sorted, merged
3. Reminder scheduler store - time-bucketed KV (Redis / Bigtable-like), keyed by UTC minute-bucket + shard. Entries for reminders due in that minute. Topped up on a rolling horizon for recurring series; read by minute, never scanned.
rbucket:{2026-07-15T08:50Z}:{shard} -> [ {event_id, user_id, channel, series_ptr}, ... ]
4. Expansion cache - Redis, keyed by (calendar_id, week, rule_version). Materialized instances for a week, invalidated by version bump on edit. Accelerates the dominant view read.
Sharding note. The natural shard key for events is calendar_id: a calendar’s events, overrides, and change feed co-locate, so a view render, an edit, and the change-log append are all single-shard. The cost is that an invite fans out - one event with 500 attendees writes a participation row into 500 attendees’ calendars (different shards) - handled by the async Fan-out worker, not a synchronous cross-shard transaction; each attendee’s copy is an independent, eventually-consistent write. “Events I’m invited to” is served by the attendees(attendee_id) index / a per-user materialized view fed by fan-out, keeping the write path sharded by calendar while attendee reads stay cheap.
Why SQL/NewSQL for events but Redis for free-busy, reminders, and expansions: opposite needs. Events need durability, transactions (event + change-feed append), and read-your-writes - eventual consistency is wrong for a user reading their own edit. Free-busy, reminder buckets, and expanded weeks are derived, massive-scale, latency-critical, and tolerant of small staleness - perfect for in-memory keyed stores fed by CDC. Matching the store to the job is the schema decision.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each:
1. Recurring-event storage explosion (breaks first at the data model). Materializing instances generates hundreds of billions of derived rows.
Fix: store the rule (RRULE), not instances; expand within a bounded window at read time; represent per-instance edits as small override/exception rows and “this and following” as a split_from rule chain. Instances are derived, never stored.
2. View-render CPU (the dominant load, ~46k/sec). Expanding rules for every view of every calendar is expensive. Fix: bound expansion to the visible window plus a small look-ahead; cache expanded instances per (calendar, week, rule_version) so repeat views are cache hits and an edit invalidates only affected weeks; precompute hot shared calendars. Read replicas absorb the read fan-out.
3. Reminder firing at scale and on the minute-boundary burst. Billions of timers, clumped at :00/:50 across every timezone. Fix: a time-bucketed timing wheel keyed by UTC due-minute - finding “what is due” is a keyed read, not a scan; shard buckets so a heavy minute spreads across many pollers/workers; enqueue a rolling horizon so recurring reminders never materialize forever; at-least-once delivery with idempotent dedupe.
4. Invite fan-out to large attendee lists. A 5,000-person all-hands is one write that must appear on 5,000 calendars. Fix: asynchronous Fan-out worker off a queue - the organizer’s write commits immediately; participation rows and invite notifications propagate eventually (seconds). Never a synchronous cross-shard transaction. Batch and rate-limit fan-out for huge lists.
5. Free-busy query cost across many attendees. Expanding 20 full calendars per assistant query blows the 1s budget. Fix: a precomputed per-user UTC busy-interval index; an N-attendee query is N small keyed reads plus a linear interval merge on the absolute timeline; timezone enters only at working-hours masks and display. Rooms are pseudo-users in the same merge.
6. Timezone and DST correctness. “9am daily” drifting across a DST boundary; cross-user comparison on wall-clock time. Fix: store events as wall-clock + named IANA zone, derive the absolute instant through the zone’s DST rules at expansion time; compare and store busy intervals and reminder buckets in UTC; convert to the viewer’s zone only at display. Instant-vs-wall-clock is disciplined at every boundary.
7. Sync correctness - lost deletes and dropped edits. Timestamp sync misses deletes (resurrection) and loses edits under clock skew.
Fix: a per-user monotonic change feed consumed by opaque change tokens returning deltas; tombstones for deletes so a delete is a positive propagating fact; conditional versioned writes (If-Match etag) with deterministic field-level merge so concurrent device edits converge identically regardless of arrival order. No client clocks trusted.
8. Full-sync stampede. A device offline longer than tombstone retention, or a mass token invalidation, triggers expensive full re-syncs.
Fix: retain tombstones for a generous window (covering realistic offline gaps); return full_sync_required only past that; paginate full sync with page tokens; rate-limit and jitter re-baselines to avoid a thundering herd.
9. Hot shared calendar / hot shard. A company-wide or public calendar read by millions concentrates load.
Fix: cache and precompute its expanded weeks aggressively (it changes rarely relative to reads); serve from replicas and CDN-style edge caches; since it is read-mostly, a stale-by-seconds copy is fine. The calendar_id shard for a hot calendar is fronted by cache, not hit directly per read.
10. Reminder duplicate vs miss under at-least-once. Retries can double-fire. Fix: idempotent delivery keyed by (event_id, instance, channel, user); the client/notification layer suppresses duplicates within a window. Bias to at-least-once - a rare duplicate beats a missed reminder.
11. Single points of failure. Fix: Redis (free-busy, reminder buckets, expansion cache) replicated with failover - on failover, free-busy and views fall back to on-the-fly expansion from the event store (slower, still correct), and reminders re-derive buckets from the schedule; the event store is synchronously replicated (source of truth) with read replicas for views; services are stateless behind the LB. The event store, not the caches, is the arbiter, so a cache failover degrades latency, never correctness.
12. Cross-attendee consistency lag. An invite or RSVP takes seconds to appear on every calendar. Fix: accept it - invite propagation is eventually consistent by design; the organizer’s own view is read-your-writes immediately, attendees converge in seconds via fan-out and their own sync feed. Correctness (convergence) is guaranteed; instantaneous cross-user visibility is not required.
Wrap-Up
The trade-offs that define this design:
- Store rules, expand views. A recurring event is one RRULE row plus small override/exception patches, expanded lazily within a bounded window and cached per week - not hundreds of materialized instances. This is the difference between ~200 TB and a runaway multi-petabyte table, and it makes “edit the series” an O(1) rule change instead of an N-row rewrite.
- A separate time-bucketed scheduler for reminders. Keying timers by UTC due-minute turns “find what is due” into a keyed read instead of a scan, sharding absorbs the minute-boundary burst, and a rolling horizon keeps recurring reminders from materializing forever - so a billion timers cost nothing at rest and still fire within seconds.
- Free-busy as a precomputed UTC interval merge. Expand each user’s calendar once into a busy-interval index; answer an N-attendee query with N small reads and a linear merge on the absolute timeline, letting timezone in only at the working-hours and display edges.
- Instant-vs-wall-clock discipline everywhere. Events are stored as wall-clock plus named IANA zone and rendered in the viewer’s zone; busy intervals and reminder buckets live in UTC. “9am daily” stays 9am local across DST, and cross-user comparison is always on the absolute timeline.
- Sync via a monotonic change feed with tokens and tombstones. Deltas not full downloads, tombstones so deletes propagate instead of resurrecting, and conditional versioned writes with deterministic field-level merge so concurrent device edits converge to the same state without trusting a single client clock or dropping an edit.
One-line summary: a Google Calendar system where recurring events are stored as rules and expanded per view (cached per week), reminders fire from a sharded UTC minute-bucket timing wheel topped up on a rolling horizon, free-busy is a precomputed per-user UTC busy-interval merge, and multi-device state converges through a monotonic per-user change feed with opaque tokens, tombstoned deletes, and conditional versioned writes - all disciplined by a strict wall-clock-plus-zone-versus-UTC separation so timezones and DST never corrupt an instant.
Comments