Problem RestatementProblem
Design a calendar service like Google Calendar. Users create events, invite others, and see their schedule in day, week and month views. Events can repeat ("every Monday at 10 AM"), attendees can accept or decline, and users get reminders before events start. People live in different time zones, and they use the calendar on several devices that must stay in sync. A common variant asks for a meeting scheduler that finds free times and prevents double-booking of rooms.
RequirementsRequirements
1.1 Functional
- Create, edit and delete events (one-time and recurring).
- Invite attendees. They receive invites and can respond (accept, decline, maybe).
- View a date range (a week, a month) quickly.
- Detect conflicts and suggest free slots for a group.
- Reminders (e.g., 10 minutes before).
- Share calendars (view or edit access).
1.2 Non-Functional
- Read-heavy: people open the calendar far more often than they change it.
- Correct times across time zones and daylight saving changes.
- Sync across devices within a few seconds.
- Reliable reminders: they should fire on time and only once.
1.3 Scale Estimates
- 500 million users, 100M daily active.
- Each user creates ~2 events/day → 200M events/day ≈ 2,300 writes/sec.
- Views: 10 opens per user per day → 1B range queries/day ≈ 12K/sec.
- Storage: ~1 KB per event → 200 GB/day before replication.
1.4 API Design
/v1/eventswith { title, start, end, timezone, rrule?, attendees[], reminders[] }/v1/calendars/{id}/events?from=2026-09-21&to=2026-09-28/v1/events/{id}?scope=this|following|all(edit one occurrence of a recurring event, or all of them)/v1/events/{id}/respondwith { status: accepted }/v1/freebusywith { users[], from, to } → busy blocks for each userHigh-Level ArchitectureArchitecture
2.1 Overview
- Event Service: create, edit, delete and view events.
- Event DB: stores events and attendees (sharded by calendar ID).
- Invite Service: when an event has attendees, it adds the event to each attendee's calendar and sends emails or notifications.
- Free/Busy Service: answers "when are these people busy?" from a compact busy-time index.
- Reminder Scheduler: fires reminders at the right time (a delayed job queue).
- Sync Service: pushes changes to the user's other devices.
2.2 Architecture Diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
C["Web / Mobile clients"] --> API["API Gateway"]
API --> ES["Event Service"]
ES --> DB[("Event DB - sharded by calendar")]
ES --> K[("Change events - Kafka")]
K --> INV["Invite Service"]
K --> FB["Free/Busy indexer"]
K --> REM["Reminder Scheduler"]
K --> SYNC["Sync / Push Service"]
INV --> DB
FB --> FBS[("Busy-time index")]
REM --> N["Notifications"]
SYNC --> CData ModelData model
CREATE TABLE events (
event_id UUID PRIMARY KEY,
calendar_id UUID, -- owner calendar
title TEXT,
start_utc TIMESTAMP, -- first occurrence start, stored in UTC
end_utc TIMESTAMP,
timezone TEXT, -- e.g. "America/New_York", needed for recurrence
rrule TEXT, -- e.g. "FREQ=WEEKLY;BYDAY=MO", NULL if one-time
recur_until TIMESTAMP, -- last possible occurrence (or far future)
version INT
);
CREATE TABLE event_exceptions ( -- one changed or cancelled occurrence
event_id UUID, original_start_utc TIMESTAMP, new_start_utc TIMESTAMP,
new_end_utc TIMESTAMP, cancelled BOOLEAN
);
CREATE TABLE attendees (
event_id UUID, user_id UUID, response TEXT, -- accepted, declined, tentative
PRIMARY KEY (event_id, user_id)
);
CREATE INDEX ON events (calendar_id, start_utc);Recurring Events (the key idea)
We do not create one row per occurrence. "Every Monday forever" would be infinite. Instead:
- Store the rule (an RRULE, the standard iCalendar format) plus the time zone.
- When someone views a week, load events whose range overlaps the week (
start_utc <= week_end AND recur_until >= week_start), then expand each rule into concrete times for that week only. - Apply exceptions: "this Monday moved to Tuesday" or "cancelled on Dec 25".
- Editing "this and following" splits the series: end the old rule at that date and create a new rule starting from it.
Key FlowsFlows
5.1 Creating a meeting with attendees
- Save the event in the organizer's calendar.
- Publish a change event. The Invite Service links the event into each attendee's calendar (a row pointing to the same
event_id) and sends invites. - The Free/Busy indexer updates each attendee's busy blocks.
- The Reminder Scheduler schedules reminders for the next occurrence.
5.2 Finding a free slot
- Get busy blocks for all attendees in the range from the Free/Busy index. It stores only start/end pairs, with no titles, which also protects privacy.
- Merge all busy intervals (sort by start and combine overlaps), then return the gaps that are long enough and fall inside working hours.
Deep Dive A — Booking a room without double bookingDeep dive
Two people book the same conference room for 2 PM at the same moment. Whatever the UI does, the database has to make one of them lose.
Check for overlaps, then insert
Query the bookings for that room, see nothing overlapping 2–3 PM, insert the booking.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
sequenceDiagram
participant A as Priya
participant B as Sam
participant DB as Bookings
A->>DB: any booking for room 4, 2-3 PM?
DB-->>A: none
B->>DB: any booking for room 4, 2-3 PM?
DB-->>B: none
A->>DB: INSERT room 4, 2-3 PM
B->>DB: INSERT room 4, 2-3 PM
Note over DB: both meetings walk into the same roomThe check and the insert are two statements, and the room is free in the window between them. This passes every test written with one user.
Lock the room while booking
Take a row lock on the room (SELECT ... FOR UPDATE on a per-room row), run the overlap check and the insert inside that transaction, commit. The second booker waits, re-reads, and correctly fails.
This is correct, and it is a reasonable answer. The cost is that every booking for a room is serialised behind one lock — fine for conference rooms, painful if "room" becomes "doctor" or "court" with thousands of bookings a minute — and it depends on every code path remembering to take the lock. The next engineer who writes a bulk import will not.
Let the database reject the overlap
Put the rule in the schema, as an exclusion constraint on the time range:
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
room_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
);Now any insert that overlaps an existing booking for that room fails, whatever code path it came from. There is no check-then-act to race, no lock to forget, and the guarantee holds for the bulk importer and the migration script too.
Catch the constraint violation and turn it into "that room was just taken" — the error is the feature.
6.1 Reminders, sync and very large meetings
- Reminders: store the next reminder time per event and user in a delayed queue (a Redis sorted set keyed by time, or the job scheduler). A worker fires due reminders and, for a recurring event, schedules the next occurrence. Use an idempotency key of
(event, occurrence, user)so a retry never sends twice. - Multi-device sync: every change bumps a per-calendar sync token. Devices ask "what changed since 5812?" and get only the differences; a push tells them to ask now rather than on a timer.
- All-company events: do not copy a 50,000-attendee event into 50,000 calendars. Store it once, have attendees reference it, and fan the invitations out asynchronously in batches.
Trade-offs & AlternativesTrade-offs
| Decision | Choice | Why | Alternative |
|---|---|---|---|
| Recurring events | Store rule + expand on read | Small storage, easy series edits | Store every occurrence: simple queries, huge and hard to edit |
| Time storage | UTC + original time zone | Correct across DST | Local time only: breaks for travelers and DST |
| Free/busy | Separate busy-time index | Fast group queries, private | Scan full events: slower, leaks details |
| Device sync | Sync tokens + push | Only sends changes | Full reload: simple but wasteful |
Common Follow-up QuestionsFollow-ups
- "How do you show a month view fast?" Cache expanded occurrences per calendar per month, and invalidate when an event in that calendar changes.
- "External invites (other providers)?" Send standard iCalendar (.ics) emails and accept replies by email.
- "Privacy?" Shared calendars can be "free/busy only", "see details" or "edit". Check this on every read.
Wrap-UpWrap-up
Store events in UTC with their time zone, keep recurring events as rules with exceptions, and expand them only for the range being viewed. Use change events to drive invites, a free/busy index, reminders and device sync. Prevent room double-booking with a transactional overlap check, and keep sync efficient with sync tokens.