System Design

Design a Calendar System

Step 1 / 9

Learn system design by building a shared calendar like Google Calendar or Outlook step by step.

The numbers to beat1 rowcaptures a year of daily meetings260→1rows for a weekday recurrence1 editchanges the whole series

Deep cut · 13:29

Next Tuesday, 9am — and the row that was never written

The interactive walkthrough above lays out rule-based recurrence, read-time expansion, exceptions to a series, free/busy conflict detection, the wall-clock-versus-UTC trap, async invite fan-out and time-bucketed reminders. This film is told from inside one week view on a Monday morning, and carries one number along the top of every frame: how many rows are stored for a decade of daily standups. It opens at 2,600 and ends at 1.

  • See the decision everything rests on: a repeating meeting is one row holding a rule, not a row per day. That single choice is what makes a series with no end date storable at all, what turns moving a ten-year standup into one edit instead of 2,600 writes — and what forces every read to compute the week you are looking at.
  • See where the obvious build dies on silence, not cost: in March the clocks go forward. The instant you stored is unchanged, the wall clock moved underneath it, and a standup that has been at nine for six years is now at ten — with nobody having edited anything and nothing in any log. The fix is to stop storing a moment and store a wall-clock time plus an IANA time zone id, re-resolved on every read.
  • See how the awkward cases stay cheap: a moved Thursday is one exception applied on top of the expansion rather than a forked series, free/busy expands only the hours around the proposed slot, invites are confirmed for the organiser and fanned out through an event bus afterwards, and reminders are dropped into per-minute buckets so each tick reads only what is due.
  • Take it into the interview: derive an RRULE-backed series, a server-side expander, an exception table, wall-clock plus time-zone storage, a windowed free/busy check, async invite fan-out and a bucketed trigger index each from the number that demanded it — with what every one of them costs said out loud, including a calendar where nothing can be looked up directly any more.

In the interview room

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

Functional requirements

What it must do — agree on these before drawing a single box.

  • Events & recurrence: create, edit and query events, including “every weekday” series stored as one rule.
  • Exceptions: move or cancel a single occurrence without forking the whole series.
  • Free/busy: check whether every invitee is actually free before confirming a meeting.
  • Invite & RSVP: fan an invite out to attendees and track each one’s response.
  • Reminders & sync: fire “starting soon” alerts at scale and sync changes to every device.

Non-functional requirements

The qualities that shape the whole design — each one names the mechanism that buys it.

Storage and edits scale with the series, not occurrences
Store one row per series holding a recurrence rule — a decade of daily meetings is one row and one edit, not thousands.
Occurrences computed only for the queried window
A server-side Recurrence Expander parses the RRULE and generates exactly the occurrences overlapping the requested date range — done once, correctly, not per client.
Conflict detection regardless of history size
Free/busy expands only the narrow window around the proposed slot and does an interval-overlap check — proportional to the window, not the whole calendar.
Recurring “9am” survives every DST flip
Store wall-clock local time + an IANA timezone id and re-resolve to UTC at read time using the current DST rule, so 9am means 9am forever.
Inviting many attendees never blocks the organizer
Confirm the organizer’s action immediately, then fan the invite out asynchronously via an event bus to each attendee’s own event copy.
Find due reminders without scanning everything
A time-bucketed index of reminder-trigger times means the scheduler queries only the current bucket each tick, then enqueues delivery asynchronously.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

One row + a recurrence ruleover materializing every occurrence up front

Inserting hundreds of rows for a year of standups makes editing the series touch all of them, and a no-end-date recurrence can’t be materialized to infinity at all. Storing the rule and computing on demand keeps writes and storage proportional to the series.

A server-side Recurrence Expanderover each client expanding the RRULE itself

Every client reimplementing RRULE expansion — with all its edge cases — is a recipe for divergent bugs, and the server needs the rule anyway for free/busy and reminders. Expand once, server-side, correctly.

Wall-clock time + an IANA timezone idover a fixed UTC instant

A fixed UTC instant stays fixed while the region’s wall-clock-to-UTC mapping shifts on a DST boundary, so “9am every Monday” silently becomes 8am or 10am with no edit to explain it. Re-resolving from a timezone rule at read time keeps 9am meaning 9am.

Async invite fan-outover blocking creation until all attendees update

Blocking on 40 downstream writes makes the organizer’s action as slow and fragile as the flakiest attendee update. An async bus delivers each copy independently, so one slow attendee can’t block or fail the organizer.

A time-bucketed trigger indexover scanning every event each minute

A full scan over millions of events every 60 seconds wastes enormous work for the tiny fraction actually due. Indexing by trigger time makes the per-tick query proportional to how many reminders are due, not to total event count.

What this teaches

Learn system design by building a shared calendar like Google Calendar or Outlook step by step. An interactive guide covering why storing every recurring occurrence explodes storage, RRULE-based recurrence expanded at read time, exceptions to a recurring series, free/busy conflict detection, the timezone/DST trap that silently shifts meetings, invite and RSVP fan-out, reminder delivery at scale, and incremental multi-device sync.

Key takeaways

  • Recurring events are one row plus a rule, expanded into concrete occurrences only for the window a query actually needs — never materialized in full.
  • Exceptions are sparse overrides applied after expansion, so one moved instance doesn't fork the whole series.
  • Free/busy conflict checks scale with the proposed window, not with someone's entire event history, via the same expand-then-interval-overlap logic.
  • Recurring local-time events must store wall-clock time + a timezone id and resolve to UTC at read time — a fixed UTC instant silently breaks across DST.
  • Invite/RSVP fan-out is asynchronous per-attendee, so 40 invitees can't slow down or fail the organizer's action.
  • Reminders are found via a time-bucketed trigger index, not a full scan, and delivery is decoupled from the scan so it can retry independently.
  • Multi-device sync uses incremental tokens (a position in a change log), not a full recalendar refetch, per device, per change.

Concepts covered

  • What is a calendar system?
  • One row per event
  • RRULE and the expansion window
  • "Just this one instance moved"
  • Conflict detection before you book
  • 9am is not a fixed instant
  • Fan-out without blocking the organizer
  • Everyone's 9am reminder fires at 8:50
  • Don't re-download the whole calendar

Design a Calendar System — read the full walkthrough as text

the same steps, decisions & trade-offs, for reading, reference & search

The big idea

What is a calendar system?

A calendar looks like a simple CRUD app — events with a start and end time — until you add the features every real calendar needs: this meeting happens every Monday forever, this attendee needs to know if you're free, that reminder has to fire for a million events at once, and everyone's clock is in a different timezone that occasionally jumps by an hour.

The interesting engineering is almost entirely in the features that look like footnotes: recurrence (store one rule, not a thousand rows), conflict detection (query "am I free" efficiently), timezones (a wall-clock time is not the same thing as an instant), and fan-out at scale (reminders, invites). We'll build each one, and break each naive version first.

How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the diagram grow, hover the boxes, and at the end kill the reminder scheduler and flip a DST boundary to see what breaks and what doesn't. Hit Begin.

Step 1 · The baseline

One row per event

Start simple: an events table with id, title, start_time, end_time, owner. You create an event, it's a row. A daily standup recurs every weekday for the next year. How do you represent that in this table?

Design decision: A daily standup recurs every weekday for a year. How should that live in the events table?

The call: Store one row with a recurrence rule (e.g. "every weekday"), and generate specific occurrences only when a query actually needs them. — One row captures the entire series as data: a start, an end pattern, and a rule. Nothing is generated until a query asks "what's on my calendar this week" — at which point you expand just that window. Editing the series is a single-row update.

Store one row per series, holding a recurrence rule, not one row per occurrence. Concrete occurrences are computed on demand — this alone turns a year of daily meetings from hundreds of rows and hundreds of potential edits into one row and one edit.

Materialize vs. compute: The same trade-off shows up everywhere in system design: pre-compute and store every result (fast reads, expensive writes/edits, storage blowup) vs. store the rule and compute on demand (cheap writes, a bit more read-time work). Recurring events are a textbook case for the second option.

Step 2 · Expand on read

RRULE and the expansion window

A client asks "what's on my calendar this week?" The store holds recurrence rules, not occurrences. Something has to turn "every weekday at 9am, starting Jan 1" into "Mon Jan 6, Tue Jan 7, Wed Jan 8…" for exactly the requested window.

Design decision: A recurring series needs concrete occurrences for a given date range. What computes them?

The call: A Recurrence Expander that parses the rule (an RRULE, following the iCalendar spec) and generates only the occurrences that fall inside the requested window. — The expander takes the rule (frequency, interval, days, until/count) and the query's date range, and produces exactly the occurrences that overlap — nothing before or after. A ten-year recurring meeting costs the same to expand for "this week" as a one-time event does.

Add a Recurrence Expander that reads the stored RRULE (the iCalendar recurrence-rule standard: frequency, interval, by-day, until/count) and expands it into concrete occurrences only for the window a query actually asks for. This is server-side, done once, correctly — not reimplemented per client.

RRULE: FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20261231 is enough to describe "every Monday, Wednesday and Friday until the end of next year" in one line. It's a standard (RFC 5545) precisely so calendar systems interoperate without each inventing their own recurrence grammar.

Step 3 · Exceptions

"Just this one instance moved"

The team standup is every weekday at 9am — except next Thursday it's moved to 2pm because of a conflict, and the Friday after is cancelled entirely. The recurrence rule describes the pattern, not the exceptions. How does one instance diverge from its series without breaking the rest of it?

Store exceptions alongside the rule: an override table keyed by (series id, original occurrence date) that either replaces that instance's time/details or marks it cancelled. When the expander generates occurrences for a window, it applies any matching override after expansion — the rule stays the clean, general pattern, and exceptions are sparse, targeted overrides on top.

Pattern + overrides, not pattern mutation: This is the same shape as version-controlled config: a base rule plus a small diff, rather than forking the whole rule for one changed day. It keeps "move this one Thursday" a cheap, isolated write instead of splitting the series into two separate rules.

Step 4 · Are you free?

Conflict detection before you book

Before confirming a meeting, the organizer wants to know: is every invitee actually free at this time? That means checking a proposed slot against each attendee's entire calendar, including their recurring events — potentially for every attendee, on every booking attempt. How do you make that fast?

Design decision: Checking "is this person free at 3pm Thursday" against all their events (including recurring ones) — how do you keep it fast?

The call: Expand only the narrow window around the proposed time (via the Recurrence Expander) and interval-overlap-check it against the attendee's events in that window. — You don't need someone's whole calendar to answer "are you free at 3pm Thursday" — you need their events that overlap THAT window. Expanding just that range and doing a simple interval-overlap comparison keeps the check proportional to the query, not to calendar history.

Add a Free/Busy check that asks the expander for each attendee's occurrences only in the proposed window, then does a simple interval-overlap comparison against the requested slot. The check scales with the size of the time window being verified, not with how many events someone has ever created.

Interval overlap: Two time ranges [a_start, a_end) and [b_start, b_end) overlap exactly when a_start < b_end AND b_start < a_end. Free/busy, room booking, and reminder windows all reduce to this one primitive — the trick is never comparing against more events than the window actually contains.

Step 5 · The timezone trap

9am is not a fixed instant

A recurring "9am every Monday" is created by someone in New York. If you store that as a fixed UTC timestamp (say, 14:00 UTC, which is 9am EST), what happens to the meeting when Daylight Saving Time flips and 9am EST becomes 9am EDT — a different UTC offset?

Design decision: A recurring "9am every Monday" is stored as a fixed UTC instant. DST flips. What happens?

The call: The meeting silently becomes 8am or 10am local time for that region — nobody edited it, but "9am" stopped meaning 9am. — If you bake in a fixed UTC offset, the meeting keeps that fixed instant while the region's wall-clock-to-UTC mapping shifts around it. Every attendee sees the meeting move by an hour on the DST boundary, with no edit in the history to explain why.

Store recurring events as local wall-clock time + an IANA timezone id (e.g. 09:00, America/New_York), not a fixed UTC instant. Resolve to a specific UTC time at read/expansion time, using whatever DST rule is currently in effect for that timezone on that date. "9am every Monday" then means 9am local time, forever, correctly straddling every DST transition.

Wall-clock time vs. an instant: A one-time event ("call with the vendor, Tuesday 3pm") is genuinely a fixed instant and UTC storage is fine. A recurring local-time event is a different kind of fact — "this is what 9am means in this place," re-evaluated every occurrence. Conflating the two is one of the most common real-world calendar bugs.

Step 6 · Invites & RSVP

Fan-out without blocking the organizer

The organizer invites 40 people to a meeting. Should their "create event" request wait until all 40 attendees' calendars have been updated before returning success?

Design decision: An organizer invites 40 attendees. Should event creation block until every attendee's calendar is updated?

The call: Confirm the event immediately for the organizer, and fan the invite out to attendees asynchronously via an event bus. — The organizer gets a fast, reliable "event created" response. An async Invite Bus then delivers the invite to each attendee's own calendar (their own event copy + RSVP status) independently, so one slow or failing attendee update can't block or fail the organizer's action.

Confirm the organizer's action immediately, then fan the invite out via an async Invite Bus — each attendee gets their own event copy and RSVP status, updated independently. RSVP changes flow back the same way: async, per-attendee, never blocking the person who triggered the change.

Decouple the write from its fan-out: The same pattern as a notification system or a social-media post: the action that matters to the actor (organizer creates event) completes fast, while everyone downstream (attendees) is updated through a queue that can retry, backpressure, and fail independently without affecting the source of truth.

Step 7 · Reminders at scale

Everyone's 9am reminder fires at 8:50

Millions of events have a "10 minutes before" reminder. At any given minute, potentially thousands of them are due at once — every popular meeting time (9am, 10am, 2pm) creates a spike. How do you find and fire exactly the reminders that are due, on time, without scanning every event in the system every minute?

Design decision: Millions of events, each with a reminder offset. How do you find the ones due "right now" without scanning everything?

The call: Index events by their computed reminder-trigger time (bucketed, e.g. by minute), and have the scheduler query only the current bucket each tick. — If every event's reminder time is indexed (and recurring events' NEXT occurrence is what's indexed, refreshed after it fires), the scheduler's query each minute is "give me the bucket for right now" — proportional to how many reminders are actually due, not to total event count.

Maintain a time-bucketed index of reminder-trigger times (for recurring events, just the next occurrence — recomputed after it fires). The Reminder Scheduler's per-tick query is "what's due in this bucket," a small, bounded lookup regardless of total calendar size, and it enqueues push/email jobs asynchronously so the actual delivery can retry independently of the scan.

Index the thing you query by: "Find all reminders due now" is a time-range query, so the system should be indexed by trigger time, not scanned by scanning everything and filtering. This is the same principle as free/busy's interval index — put the index on the axis your query actually walks.

Step 8 · Multi-device sync

Don't re-download the whole calendar

A user has the calendar open on their phone, laptop, and smartwatch. One device makes a change. How do the other two find out, without each one re-fetching the entire calendar every time anything changes anywhere?

Give every device an incremental sync token — an opaque cursor representing "everything I've already seen." A device asks the Sync Service for "what changed since token X" and gets back only the delta (new, updated, deleted events), plus a fresh token for next time. A device that's been offline for a week still just asks for one delta, not a full re-download, unless the token has aged out entirely (in which case it falls back to a full resync).

Sync tokens vs. full refetch: This is the same incremental-sync pattern used by Google Calendar's and Dropbox's APIs: the server doesn't track "what does each of your N devices know" — it tracks a single append-only change log, and a token is just a position in that log. Cheap for the server, cheap for every device, and naturally handles devices that reconnect after being offline for any length of time.

You did it

You just designed a calendar system.

  • Recurring events are one row plus a rule, expanded into concrete occurrences only for the window a query actually needs — never materialized in full.
  • Exceptions are sparse overrides applied after expansion, so one moved instance doesn't fork the whole series.
  • Free/busy conflict checks scale with the proposed window, not with someone's entire event history, via the same expand-then-interval-overlap logic.
  • Recurring local-time events must store wall-clock time + a timezone id and resolve to UTC at read time — a fixed UTC instant silently breaks across DST.
  • Invite/RSVP fan-out is asynchronous per-attendee, so 40 invitees can't slow down or fail the organizer's action.
  • Reminders are found via a time-bucketed trigger index, not a full scan, and delivery is decoupled from the scan so it can retry independently.
  • Multi-device sync uses incremental tokens (a position in a change log), not a full recalendar refetch, per device, per change.
built so "every Monday at 9am" survives a DST flip and a double-booking race — make the calls, kill the reminder job, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs