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.
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?
A daily standup recurs every weekday for a year. How should that live in the events table?
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.
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.
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.
A recurring series needs concrete occurrences for a given date range. What computes them?
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.
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.
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?
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?
Checking "is this person free at 3pm Thursday" against all their events (including recurring ones) — how do you keep it fast?
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.
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.
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?
A recurring "9am every Monday" is stored as a fixed UTC instant. DST flips. What happens?
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.
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.
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?
An organizer invites 40 attendees. Should event creation block until every attendee's calendar is updated?
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.
Silently dropping invitees is a correctness bug dressed up as an optimization — every invitee needs their copy eventually, full stop.
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?
Millions of events, each with a reminder offset. How do you find the ones due "right now" without scanning everything?
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.
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.
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?
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.
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.