System Design · step by stepDesign a Calendar System
Step 1 / 9

Design a Calendar System — the walkthrough in full

A written version of the interactive walkthrough above — the same steps, decisions and trade-offs, laid out for reading, reference and 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.

  • R — e
  • E — x
  • F — r
  • R — e
  • I — n
  • R — e
  • M — u
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 / 58 System Designs done

Explore the topic

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