Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

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

The whole design, in writing

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

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.

Youbook a meeting
New in this step: You.

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.

What the new pieces do

Youclient
Anyone scheduling, editing, or being invited to an event — on a phone, laptop, or another calendar app entirely.

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?

Calendar APICRUD + queriesEvent Storeevents + RRULEs
New in this step: Calendar API, Event Store.

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

  1. This "materializes" every occurrence immediately. It works for reading, but editing the series ("move standup to 9:30, starting next week") means updating hundreds of rows, and a 10-year recurring event means tens of thousands of rows for a meeting that's conceptually ONE thing.

  2. 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.

  3. Pushing recurrence logic to N different client apps guarantees inconsistent behavior between them, and the server can't answer "is this person free on day 400" without knowing the rule itself, which it needs for free/busy and reminders anyway.

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.

Why this piece earns its place

Storing the series as one row assumes the series is always edited as one thing. That holds for “move standup to 9:30” and stops holding the first time someone picks this and all following events — the scope every real calendar offers alongside “just this one” and “the whole series.” There is no single-row update that expresses it, because the rule has no notion of a midpoint. What actually happens is a split: you close the original row by writing an UNTIL just before the chosen occurrence, create a second row carrying the new rule from that point on, and then decide what happens to everything hanging off the old one. Per-attendee RSVP state and scheduled reminder state belong to occurrences rather than to the row, and every occurrence past the split point now hangs off a series that no longer generates it, so the split has to say which of that state follows the new rule and which dies with the old one. Decide that policy before you ship the edit dialog, because a user who picks “this and following” and finds that forty people’s answers have quietly reset to no reply will not call it a split, they will call it data loss.

  • 1 rowcaptures a year of daily meetings
  • 260→1rows for a weekday recurrence
  • 1 editchanges the whole series

What the new pieces do

Calendar APIgateway
The front door for every read and write: create an event, edit a series, query a time range, check availability.
Event Storedata
Durable storage for events — including recurring ones, which are stored as ONE row plus a recurrence rule, not one row per occurrence.

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.

Calendar APICRUD + queriesEvent Storeevents + RRULEsRecurrence ExpanderRRULE → instances
New in this step: Recurrence Expander.

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

  1. 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.

  2. Every client (web, iOS, Android, a third-party integration) would need to correctly reimplement RRULE expansion, including all its edge cases — a recipe for divergent bugs. Expansion belongs server-side, once.

  3. This is exactly the "materialize everything" problem from Step 1, just deferred — and recurring events with no end date (COUNT/UNTIL absent) can't be pre-expanded to infinity at all.

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.

Why this piece earns its place

Expanding at read time puts a loop on the hot path of every calendar query, so the knob that matters is the widest window the API will expand in one request. A rule with no UNTIL or COUNT is infinite by construction — the window is the only thing that terminates it. Set it too small and the product cannot render a year view or answer “am I free on day 400,” and clients start papering over the limit by firing twelve month-sized requests where one belonged. Set it too large and a single call can ask for a decade of a daily rule across every series on a calendar, which is a plausible request from a badly written integration and an obvious one from a hostile caller. I would clamp the window server-side, cap how many occurrences any one series may contribute to a response, and paginate past the cap rather than truncating silently. Both caps earn their keep: one protects you from a pathological rule, the other from a calendar carrying thousands of ordinary ones.

What the new pieces do

Recurrence Expanderservice
Expands a recurrence rule into concrete occurrences on demand, for whatever date range the current query actually needs.

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?

YouCalendar APIEvent StoreRecurrence Expander
The system as it stands at this step. · swipe to pan the diagram

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.

Why this piece earns its place

The override is keyed by the occurrence it replaces — series plus original start — which quietly assumes that original start is stable. It is, right up until someone edits the rule. Shift the series thirty minutes earlier, or move it from Mondays to Tuesdays, and every stored override now points at an occurrence the expander no longer generates. The moved Thursday does not travel with the pattern, it becomes an orphan; and the cancelled Friday quietly comes back to life, which is the worse of the two, because nobody goes looking for an event they already deleted. No default here is right for everyone, so the edit path has to make the call explicitly: either re-anchor the overrides onto the corresponding occurrences of the new pattern, or discard them and warn the organizer that this series has modified instances before the edit commits. Cancellations deserve the stricter treatment of the two — carrying a deletion forward is almost always what the person meant.

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?

Calendar APICRUD + queriesRecurrence ExpanderRRULE → instancesFree/Busy Checkconflict detection
New in this step: Free/Busy Check.

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

  1. A full scan of someone's entire calendar history for every availability check doesn't scale — most of those events are irrelevant to a specific proposed time slot.

  2. Ignoring recurring events would let you double-book someone's standing weekly meeting — exactly the conflict this feature exists to prevent. Recurring series must be expanded for the relevant window and checked too.

  3. 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.

Why this piece earns its place

How fast this check runs matters less than what it is allowed to say. The obvious implementation asks the expander for the attendee’s occurrences and hands the overlapping ones straight back, and now the organizer can read titles, locations and other attendees off a calendar nobody gave them access to — a booking box turned into an enumeration tool, one proposed slot at a time. Free/busy has to answer in opaque intervals: busy from here to here, nothing else. Apply each calendar’s visibility rules before merging, then merge adjacent and overlapping intervals, because even a detail-free list still leaks how many separate meetings someone has and exactly where the seams fall. Across an organizational boundary it should collapse further still, to free or busy for the proposed slot alone. The cost is worth naming out loud: the organizer can no longer see why a slot is bad, so the product has to respond by offering alternative times rather than by explaining the conflict.

What the new pieces do

Free/Busy Checkservice
Answers "is this person free at this time?" by checking expanded occurrences against a proposed slot before letting a booking through.

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?

YouCalendar APIEvent StoreRecurrence ExpanderFree/Busy Check
The system as it stands at this step. · swipe to pan the diagram

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

  1. UTC IS unaffected by DST, which is exactly the problem: a fixed UTC instant stays fixed, so the LOCAL wall-clock time it displays as shifts by an hour the moment the region's DST offset changes.

  2. 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.

  3. Nothing "auto-corrects" a fixed timestamp — the correction has to be designed in, by storing the right kind of data in the first place (see the fix below).

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.

Why this piece earns its place

Wall-clock time plus a zone id is the right fact to store, and it inherits two assumptions from the timezone database that both fail in practice. The first is that a local time exists exactly once. On the spring-forward day a 2:30am occurrence does not exist at all, and on the fall-back day a 1:30am occurrence happens twice, so the expander needs a written policy — shift forward out of the gap, take the first of the two ambiguous instants — rather than inheriting whatever the date library happens to do. The second is that the mapping itself is fixed. Governments change DST rules, sometimes with weeks of notice, and a timezone-database release can rewrite what a future 9am resolves to. The stored row stays correct, which is the entire point of storing it this way, but nothing derived from it does: precomputed reminder trigger times, cached expansions, and any resolved instant already handed to a device. A timezone-data bump is a recompute scoped to the zones that actually changed, not a dependency upgrade you merge on a Friday.

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?

YouCalendar APIEvent StoreRecurrence ExpanderInvite Bus
New in this step: Invite Bus. · swipe to pan the diagram

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

  1. Blocking on 40 downstream writes (some of which may be slow, on other services, or temporarily unavailable) makes the organizer's single action as slow and as fragile as the flakiest attendee update. That's a bad trade for "immediately" consistency nobody strictly needs.

  2. Silently dropping invitees is a correctness bug dressed up as an optimization — every invitee needs their copy eventually, full stop.

  3. 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.

Why this piece earns its place

The moment every attendee holds their own copy, those copies can disagree with the organizer’s. A bus delivers at least once and promises nothing about order, so two quick edits — move it to 2pm, then cancel it — can land at one attendee reversed, leaving a live 2pm meeting on a calendar whose organizer cancelled it, and a redelivery can resurrect a state that attendee already moved past. One mechanism fixes both: put a monotonically increasing version on the event at the source, stamp it on every message, and have each attendee-side write reject anything at or below the version it has already applied. That makes the handler idempotent and order-tolerant at once, for the price of one column. The second consequence is a UI one. The organizer’s fast confirmation means the event is durable, not that anyone received it, so the interface must not claim invitations were delivered — it shows each RSVP filling in as the fan-out lands, which is also exactly what a stuck retry looks like.

  • 1organizer action
  • Nindependent attendee updates
  • asyncnever blocks the organizer

What the new pieces do

Invite Busevents
An async event bus that fans an invite or an edit out to every attendee's own copy of the event, without the organizer's request waiting on all of them.

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?

YouCalendar APIEvent StoreRecurrence ExpanderInvite BusReminder Scheduler
New in this step: Reminder Scheduler. · swipe to pan the diagram

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

  1. 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.

  2. A full scan every 60 seconds over millions of events, most of which aren't due anytime soon, wastes enormous work for a tiny useful fraction — this doesn't scale past a small system.

  3. This moves the same scaling problem client-side and multiplies it by every connected device, plus it fails the second the app isn't open — reminders need to fire server-side and push out, not depend on the client polling.

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.

Why this piece earns its place

Indexing only the next occurrence of a series means firing a reminder and computing the one after it are two operations that have to succeed together. If the fire succeeds and the recompute does not, that series drops out of the index and nothing notices: no error, no stuck job, no growing queue to alert on — the reminders simply stop for that one meeting, permanently, and the report arrives weeks later from a person who missed a standup. Silence is the hardest failure to monitor for, so claim the bucket entry, enqueue the delivery, and write the next trigger time as one atomic unit. Treat the pipeline as at-least-once with a dedupe key on the occurrence and reminder at the delivery end, because a duplicate push is an irritation while a reminder that never fires is the feature failing at its only job. Then run a sweeper looking for active series that have a future occurrence and no row in the index, since that query is the only thing capable of seeing this at all.

  • every minutea new bucket becomes "due"
  • O(due)work per tick, not O(total events)
  • asyncdelivery decoupled from the scan

What the new pieces do

Reminder Schedulerservice
Continuously scans for events approaching their reminder offset and enqueues push/email jobs — decoupled from the event system itself.

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?

YouCalendar APIRecurrence ExpanderFree/Busy CheckReminder SchedulerSync Service
New in this step: Sync Service. · swipe to pan the diagram

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).

Why this piece earns its place

A delta of changed rows can only describe things that still exist. A delete and a revoked share have no row to send, so without tombstones the phone that was off for a week keeps showing a cancelled meeting and a calendar its owner un-shared — and keeps showing them indefinitely, because every future sync is also a delta. So a delete writes a tombstone into the change log, and losing access has to behave like a delete for that device rather than like nothing at all. Tombstone retention is what really sets how long a token can stay valid: a token is good for exactly as long as you can still describe everything that vanished while it was away. That makes token lifetime a storage decision with a recovery cost attached to it. And anything that invalidates tokens in bulk — truncating the log, a migration that rewrites ids — asks every device on the service to fall back to a full download in the same minute, so that path needs staggering and rate-limiting long before you need it.

What the new pieces do

Sync Serviceservice
Lets every device pull only what changed since its last sync token, instead of re-downloading the whole calendar.

You did it

You just designed a calendar system.

YouCalendar APIEvent StoreRecurrence ExpanderFree/Busy CheckInvite BusReminder SchedulerSync Service
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • 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.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why not just materialize every occurrence of every recurring event up front and skip the expander entirely?

    Two reasons. First, storage and edit cost: a decade-long daily recurrence is thousands of rows for what is conceptually one meeting, and editing the series means touching all of them. Second, correctness: many recurring events have NO end date at all (no UNTIL or COUNT in the rule), so "materialize everything" is literally impossible — you'd have to materialize infinity. Expanding on demand for the window a query actually needs sidesteps both problems and costs the same whether the series has existed for a day or a decade.

  2. Could you avoid the DST bug by just always storing everything in UTC and telling users to deal with it?

    You could, but it would be wrong for what users actually mean. When someone sets up a recurring "9am standup," they mean 9am in their local reality — the same wall-clock time every occurrence, DST notwithstanding. A fixed UTC instant captures a DIFFERENT fact: "this exact moment, and then that exact moment plus 7 days, forever" — which drifts relative to local wall-clock time across every DST transition. The bug isn't using UTC (UTC storage is fine for the RESOLVED time); it's using a FIXED UTC offset for a RECURRING local-time concept instead of re-resolving from a timezone rule at read time.

  3. What actually happens when two people try to book the last available slot in a room at the exact same instant?

    This is a classic double-booking race, and free/busy checking alone doesn't prevent it — both requests can pass the availability check before either one commits. The fix is a conditional write at the storage layer: the booking write includes a check (e.g. "no existing event overlaps this room+time" as part of the same transaction, or an optimistic version check on the room's calendar) so the SECOND write to actually commit fails and that user is told the slot just got taken, rather than both writes silently succeeding and creating an overlap. It's the same pattern as inventory-decrement races in an e-commerce checkout.

  4. Why fan out invites asynchronously instead of just accepting that event creation is slower with more attendees?

    It's not really about raw speed — it's about failure isolation. If attendee #23's notification service happens to be degraded, a synchronous design means the ORGANIZER'S event creation fails or hangs because of a problem entirely unrelated to them. An async bus means attendee #23's delivery can retry, queue, or even fail independently, while the organizer (and the other 39 attendees) are completely unaffected. The event still gets created correctly regardless of any single downstream hiccup — that isolation is the actual point, not just latency.

  5. How would you extend the free/busy check to also handle room/resource booking, not just people?

    Treat a room exactly like an attendee with its own calendar: the room has its own event store entries and the same expand-and-interval-overlap check applies — "is Conference Room B free at 3pm Thursday" is structurally identical to "is Priya free at 3pm Thursday." The one addition is usually a capacity/ownership rule (e.g. only certain people can book certain rooms, or a room can't double-book even for an org-wide "the CEO wins conflicts" policy that might apply to people), but the core availability mechanism — the same interval-overlap check against the same kind of expanded occurrence data — is reused as-is.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A year-long daily recurring event should be stored as…

    Storing one rule keeps edits and storage proportional to the series, not to how many occurrences it will ever have.

  2. A single instance of a recurring series that gets moved is handled by…

    Exceptions are targeted overrides on top of the general pattern, keeping one-off changes cheap and isolated.

  3. Free/busy conflict checks stay fast at scale because…

    The check is proportional to the window being verified, using the same expand-then-interval-overlap logic as any other query.

  4. A recurring "9am every Monday" event silently shifts an hour after a DST change if it's stored as…

    A fixed UTC instant doesn't track DST; only resolving wall-clock time via the current timezone rule at read time keeps "9am" meaning 9am.

  5. Inviting 40 attendees to an event should…

    Async fan-out isolates the organizer's action from any single slow or failing downstream attendee update.

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.

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.

The qualities that shape everything

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 rule over 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 Expander over 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 id over 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-out over 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 index over 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.

The answer, out loud

What a strong answer to “Design a Calendar System” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–3 min

    What actually makes this hard

    Let me scope it before I draw anything. A calendar is events with a start and an end. What makes it interesting is four things that look like footnotes on the requirements list. Meetings repeat, sometimes with no end at all. One instance of a repeating meeting needs to move without disturbing the rest of it. Before you book anything you have to know whether everyone is actually free. And nine in the morning is not a moment in time, it is a moment in a place. I’ll build those in that order.

  2. 3–8 min

    How a repeating meeting is stored

    The first real decision is how a repeating meeting lives in the table, and I want to push back on the obvious answer. If a weekday standup runs for a year and I insert a row per weekday, I have written about two hundred and sixty rows for something the user thinks of as one meeting, and every edit to it now touches all of them. Worse, plenty of series have no end date, so there is no finite number of rows to insert at all. So one row per series holding the pattern as a rule, and nothing concrete exists until somebody asks.

    Built in step 1: One row per event
  3. 8–13 min

    Turning the rule back into dates

    That moves the work to read time, which is where I want it. When a client asks what is on my calendar this week, a recurrence expander reads the rule and generates exactly the occurrences that overlap that week. A ten-year series costs about what a one-off event costs for that query, because the window bounds the work, not the history. I would keep the expander on the server rather than shipping rules to clients: recurrence rules have genuinely nasty edges, and I would rather have one implementation than one per platform — and the server has to expand anyway, for availability and reminders.

    Built in step 2: RRULE and the expansion window
  4. 13–18 min

    The one instance that moved

    Now the Thursday that moved to two o’clock and the Friday that got cancelled. I do not want to fork the series for those. The rule stays the clean general pattern, and I keep sparse overrides beside it, one per affected occurrence, either replacing that instance’s details or marking it cancelled. The expander generates from the rule first and applies any matching override afterwards, so a one-off change stays a one-off write.

    Built in step 3: "Just this one instance moved"
  5. 18–24 min

    Is everyone free

    Availability is where people expect something clever and do not need it. The question is whether this person is free at three on Thursday, and the honest answer only involves their events in that window. So I expand each attendee’s series for the proposed slot and nothing wider, then do an interval-overlap test. That scales with the window I am checking, not with how long they have had the calendar. I would say plainly that this does not make the booking safe by itself: two people can both pass it for the last free room, and the write has to be conditional for the second one to lose.

    Built in step 4: Conflict detection before you book
  6. 24–30 min

    Nine in the morning, in a place

    Timezones are where I would slow down, because this is the bug that actually ships. If I store a recurring nine in the morning as a fixed instant in UTC, it is right until that region’s clocks shift, and then it is eight or ten in the morning for everybody, with nothing in the history to explain it. Nobody touched the event. The fix is to store what the person meant: the wall-clock time and the zone they meant it in, as a named region rather than an offset, then resolve to UTC at expansion time using whichever rule is in force that date. A one-off meeting genuinely is an instant, and UTC is fine for it. A repeating local time is a different kind of fact.

    Built in step 5: 9am is not a fixed instant
  7. 30–35 min

    Inviting people without waiting on them

    Invites. If forty people are on a meeting, I do not want the organizer’s create request waiting on forty downstream writes, because their action then becomes as slow and as fragile as the least healthy one. So the organizer is confirmed as soon as the event is durable, and the invite fans out over a bus to each attendee’s own copy, with their own RSVP state. One slow attendee delays one attendee — that is failure isolation more than speed.

    Built in step 6: Fan-out without blocking the organizer
  8. 35–40 min

    Firing reminders at scale

    Reminders are a scaling problem wearing a feature’s clothes. Everything is due on the hour, and I cannot scan millions of events every minute to find the handful that are. So I index events by trigger time in buckets, and each tick the scheduler asks only for the bucket that just came due — the work is proportional to what is due, not to what exists. For a repeating series I index only the next occurrence and compute the following one once it fires. And the scheduler only enqueues; the push or email is a separate job, so delivery retries without holding up the scan.

    Built in step 7: Everyone's 9am reminder fires at 8:50
  9. 40–45 min

    Sync, and what I’d do next

    Last piece, three devices staying in step. Each one holds a token, really just a position in a change log, and asks what changed since then; it gets a delta and a fresh token. A device that was off for a week asks one question instead of downloading everything. To close on the trade-off: almost every decision here stores less and computes at read time — the rule instead of the occurrences, the window instead of the calendar — and I pay for that in read-time work and in how much correctness now lives inside one expander. That is the right trade while the expander is one well-tested thing, and it is the first place I would look when reads get slow. With more time I would take on the calendars we do not own — an imported feed is read-only, arrives late and has no sync token, so it needs its own freshness story.

    Built in step 8: Don't re-download the whole calendar

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
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
NextDesign a Notification System Closest system design to this one

Explore the topic

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

More System Designs