System design interview
Design a shared calendar
Recurring events, time zones and DST, invitations, free and busy times.
Press Play to follow a request through the design, or step through it.
- Client and edge
- Service
- Data store
- Cache and queue
- Alice opens next week. The app sends the date range and her time zone.
- The gateway routes the request to the Calendar service.
- The service asks the user index for her rows that overlap the range: single events, and series that could have an occurrence in it.
- The index returns a few dozen small rows, all from one shard because they are keyed by her user ID.
- The service loads those events for their titles and other details.
- The store returns the records.
- It expands each rule inside the range only, applies exceptions, and converts every time to Alice's zone.
- Alice sees her week, with each weekly meeting at the right local time.
- Client
- API gateway
- Calendar service: views, edits, rules
- Free/busy service: merges busy blocks
- Event store: events, rules, replies
- User index: by user and time
- Invite queue
- Fan-out worker
- Email provider
On this page
In 2 minutes
I would shard everything by user, because reads outnumber writes 20 to 1 and a person's calendar should come from one place. The Event store holds one record per event or series, with its rule, time zone, exceptions and a response row per guest. A per-user index, keyed by user and time, holds scheduling rows of about 250 bytes, with the span, rule, zone, time overrides and the guest's own reply, and no titles or notes.
A week view asks the index which rows overlap the window, loads those events, expands the rules inside the window only, and converts times to the viewer's zone. Recurring events are one rule plus overrides keyed by the original start time. Editing this and all following splits the series in two.
Meetings are stored as local time plus an IANA zone name, not a fixed offset, so a weekly 9:00 stays 9:00 across daylight saving, and each occurrence becomes UTC only when it is expanded. A write or a reply commits with an outbox row, and a queue and workers fan the change out to the guest index rows and send the invitation emails, with versions so that old updates never win. Free and busy reads only the index, expands series, merges the busy intervals of up to 10 people with a sort and a sweep, and returns the gaps inside working hours.
Step 1 of 6: Clarify the scope
Interviewer asks
Design a shared calendar. People create events, invite others, set up recurring meetings, and ask for a time when several people are all free. It must show the right local time to everyone, including across daylight saving changes.
Questions to ask first
Ask these before you draw anything. Under each one is why its answer changes the design.
- How many people can one event have?
Ten guests is a small fan-out. Ten thousand guests, as for a webinar, would need a different path from the start.
- Can people change one occurrence of a recurring meeting, or 'this and all following'?
Exceptions mean we store a rule plus overrides, not a flat list of dates.
- Does an event belong to a place, such as 'Tuesday 9:00 in New York', or to an instant in time?
A meeting at a local time must stay at that time when clocks change. A flight departure is a fixed instant. The two are stored differently.
- How much can people see of each other's calendars?
Free and busy can show only 'busy' blocks, which limits what the query may read and return.
- Do phones and other apps sync with the calendar?
Sync needs an ordered way to ask what changed since last time, so every change needs a version.
Requirements
Functional
- Create, edit and delete events, including events that repeat daily, weekly, monthly or yearly.
- Invite people, and let each one accept, decline or answer maybe.
- Edit one occurrence, or this and all following occurrences, of a recurring event.
- Show a person's calendar for a day, week or month, in the viewer's own time zone.
- Find times when a group of people are all free, without showing the details of their events.
Non-functional
- Opening a week view: p99 under 200 ms.
- A change to an event reaches every guest's calendar within 5 seconds.
- A weekly 9:00 meeting stays at 9:00 local time for its organizer across daylight saving changes.
- Free and busy for 10 people over 7 days: p99 under 300 ms.
- Handles 12,000 calendar views and 600 event writes a second at peak.
Step 2 of 6: Size it
The volume is modest and reads outnumber writes 20 to 1, so we shard by user and keep each person's time index in one place. The hard part is not scale. It is correctness: recurrence rules, time zones and who may see what.
Assumed for the exercise, not facts about a real system:
- 50 million users, 10 million of them active in a day. Each active user opens a calendar view 10 times a day. Peak traffic is 10 times the average.
- Users create or change 5 million events a day. An average event has 4 guests.
- 1 billion events and series are stored, at about 500 bytes each. Each guest has an index row of about 250 bytes: the UTC span, the rule, the zone, the overrides that change times, and that guest's own reply and busy flag.
- Each guest replies once to each event.
- A daily meeting runs for 5 years.
- Views per second
- 10M users × 10 views = 100M a day ÷ 86,400 s ≈ 1,157
- ≈ 1,160/s on average, about 11,600/s at a 10x peak
- Writes per second
- 5M a day ÷ 86,400 s ≈ 58
- ≈ 58/s on average, about 580/s at a peak. Reads outnumber writes 20 to 1
- Index updates per second
- 580 writes/s × 4 guests, plus 580 × 4 replies
- ≈ 2,300/s from edits and 2,300/s from replies, about 4,600/s at the peak
- Storage
- 1B events × 500 B = 500 GB; 4B index rows × 250 B = 1 TB
- ≈ 1.5 TB, so shard by user
- Rows for one daily meeting over 5 years
- 365 × 5 = 1,825 occurrences
- 1,825 rows as occurrences, but 1 row as a rule
Step 3 of 6: Sketch the path of one request
Step through the diagram at the top of the page to watch a request move through these parts.
A Client calls the API gateway, which sends calendar views and event edits to the Calendar service, and free-time questions to the Free/busy service. The Calendar service owns the Event store: one record per event or per series, holding its title, its recurrence rule, its time zone, its exceptions and a response row for each guest.
Reading a person's week is a question about a range of time, so the User index is keyed by user and start time. It has one small row per person per event or series, with what is needed to place it in time and nothing private: the UTC span, the rule, the zone, the overrides that move or cancel occurrences, and that person's own reply and busy flag. The Calendar service asks the index which events overlap the window, loads those events for their titles, expands recurring rules inside the window only, and converts the times to the viewer's zone.
A write commits to the Event store and publishes a message to the Invite queue. A Fan-out worker reads it, updates the User index row of every guest, and sends the invitation through an Email provider. A reply goes through the same outbox, so the worker keeps that guest's index row current. The Free/busy service reads only the index, so it never touches titles or notes.
Other paths through the system
Create and invite
- Alice creates a weekly 9:00 meeting in her own zone and invites three people.
- The gateway routes the request to the Calendar service.
- The service saves the event, its rule and a response row for each guest, with an outbox row, in one transaction.
- An outbox relay publishes one 'event created' message with the guest list. Alice's app already shows the event.
- A fan-out worker takes the message.
- It writes an index row for each guest, keyed by that guest's user ID, with the span, rule and reply. A version number stops an old update from winning.
- It sends each guest an invitation email, with links to accept or decline.
Find a free time
- Alice asks for 30 minutes next week when she and nine colleagues are free.
- The gateway routes the question to the Free/busy service.
- The service reads each person's index rows for the week, ten reads in parallel, and expands any series and overrides inside the week.
- Each row holds times, a rule, that person's reply and a busy flag. Titles and notes are not in the index.
- The service sorts all the busy blocks, merges overlaps, and finds the gaps that fit inside everyone's working hours.
- Alice sees the best few slots, such as Tuesday 14:00 to 14:30.
Step 4 of 6: Go deep on the hard part
Recurring events and exceptions
Interviewer asks
How do you store a meeting that repeats forever, and let people change just one occurrence?
Do not store every occurrence.
Read moreShow less: Recurring events and exceptions
A daily meeting for 5 years is 1,825 rows, and 'repeat forever' has no end. Store one series record with a start time, a duration and a recurrence rule in the style of the iCalendar standard (RFC 5545), such as 'every week on Tuesday', 'every 2 weeks' or 'monthly on the last Friday', with an optional end date or count.
Expand only what is visible. To draw a week, take each series that overlaps the window, generate its occurrences between the start and end of the window, and drop the rest. A weekly series gives one occurrence in a 7-day window. The expansion code is a shared library, used by both the Calendar service and the Free/busy service, so they always agree.
Index series by their span. A series row in the index holds the first start and the last end, or a far-future date when it never ends, with its rule, zone and time-changing overrides. A window query then finds every single event and every series that might have an occurrence in the window.
Exceptions. When someone moves or cancels one occurrence, store an override keyed by that occurrence's original start time. It is either 'cancelled' or a replacement with a new time or title. Expansion generates the occurrence, then applies any override with the same key. The original start is the key because a moved occurrence may land on another day.
This and all following. Editing from one occurrence onward is a split. End the old series just before that occurrence, and create a new series that starts there with the changed rule or time. Link the two with a shared group ID so that 'edit all' still finds both. Overrides dated after the split move to the new series. Guests get one update about the new part, not a rewrite of the past.
A guest's reply to one occurrence, such as 'decline this one', is an override too, kept in that guest's response row.
Time zones and daylight saving
Interviewer asks
A weekly 9:00 meeting is created in New York. How does it keep its time for people in London across daylight saving changes?
There are two kinds of time.
Read moreShow less: Time zones and daylight saving
An instant is a point on the universal timeline, stored as UTC, such as 'the flight departs at 14:05 UTC'. A wall time is what a clock in a place shows, such as 'every Tuesday at 9:00 in New York'. Meetings are wall times: people expect 9:00 to stay 9:00 when the clocks change.
So for an event tied to a place, store the local date and time and an IANA zone name, such as America/New_York. Do not store a fixed offset such as -05:00, because the offset changes twice a year and governments change the rules. A one-off event that really is an instant is stored as UTC.
A worked example. The meeting is Tuesdays at 9:00 in America/New_York. In winter New York is 5 hours behind UTC, so 9:00 is 14:00 UTC. After the spring change it is 4 hours behind, so the same 9:00 is 13:00 UTC. The rule is stored once, as local time plus zone, and each occurrence is turned into UTC only when it is expanded. A person in London sees 14:00 in winter. The United States changes its clocks on the second Sunday of March and the United Kingdom on the last Sunday, so for a few weeks London sees 13:00, and then 14:00 again once the UK changes too. That shift is correct, and it follows from the rule. Nobody stored a wrong time.
Odd local times. When clocks spring forward, 02:30 does not exist that night. When they fall back, 01:30 happens twice. Pick a rule and write it down: a missing time moves forward by the length of the gap, and a repeated time means the first one.
Keep the zone database current. Use the IANA time zone database through a library, never your own tables, and update it on a schedule. The index holds UTC bounds so it can be searched by range. When a country changes its rules, those bounds are wrong for future events, so a tz update starts a job that recomputes them from the stored local times.
At the display edge, convert to the viewer's zone and group events by the viewer's local day, because 23:30 in one zone can be the next day in another.
Invitations, fan-out and free and busy
Interviewer asks
How do invitations reach everyone, and how do you find a free slot for 10 people without showing their events?
One event, many replies. Store the event once.
Read moreShow less: Invitations, fan-out and free and busy
Each guest has a response row: accepted, declined, maybe or no reply, plus their own overrides. A guest changes only their own row, so replies never clash with the organizer's edits. A reply also writes an outbox row, and a worker copies it into that guest's index row. Each edit by the organizer raises the event's version number, so guests and synced apps can tell what is new. A change of time resets replies to 'no reply'.
Fan-out. Each guest's view needs the event in their own user-keyed index, or their week view would have to search every event on every request. The write saves the event, the guest rows and an outbox row in one transaction. A relay publishes the outbox row to the invite queue, and workers update each guest's index row, at least once. The update is safe to repeat: the row is keyed by user and event, and the version number stops an old update from overwriting a new one. That is about 2,300 index updates a second at peak from edits, and as many again from replies. If one write fails, the worker retries from the queue, and that guest sees the old version for a few seconds.
Free and busy. The Free/busy service takes the people and a window. For each person it reads the index rows in the window, expands any series, applies the overrides, and keeps only start, end and a busy flag. A row the person has declined, or marked as free, does not count. The rule and the reply are in the row, so the Event store is not read, and the answer holds times only.
Then it merges. Ten people with about 30 busy blocks each in a week is 300 intervals. Sort them by start, sweep through, and join any that overlap or touch. The gaps between the merged blocks that fall inside working hours are the times when everyone is free. Rank them, say earliest first or away from lunch, and return the top few. Sorting 300 items takes microseconds. The real cost is the 10 index reads, which run in parallel.
Check permissions first. A person who hides their calendar can still be included under a rule such as 'show busy only'. For someone outside the organisation, return no data at all and say so.
Step 5 of 6: Name the trade-offs
Recurrence storage
One series row with a rule and exceptions over Store every occurrence as a row
A series of any length is one row, and a rule change is one write. The cost is expanding rules on every read, and care with exceptions.
Time storage
Local time plus an IANA zone for meetings over UTC only
Meetings keep their local time across clock changes. The cost is turning each occurrence into UTC when it is expanded, and refreshing stored bounds when the zone database changes.
Guest views
Fan-out on write into a per-user index over Search all events by guest on every read
Reads, the 20-to-1 majority, hit one shard. The cost is a queue, workers, and a few seconds before a guest sees a change.
Free and busy
Computed on request from the index over Precomputed busy bitmaps for each person
The answer is always current and needs no extra pipeline, and it is cheap for groups of 10. The cost grows for very large groups, where precomputed summaries would help.
Step 6 of 6: Say what breaks next
Questions an interviewer asks once the design is on the board. Answer each one out loud first.
- How would you handle a webinar with 10,000 guests?
Do not write 10,000 index rows and send 10,000 emails in one burst. Keep the guests in a separate table and fan out in batches through the queue, or let people join through a link and add the event to their own calendar when they accept. The organizer's view reads a count and a paged list, not a row per guest inside the event record.
- How do phones sync with the calendar?
Give each calendar a change counter, and record every change under the next number. A phone sends the last number it saw and receives everything after it, in order. If that number is too old and the change log has been trimmed, the phone does a full reload.
- How do reminders work for a repeating event?
Compute the next occurrence's start, subtract the lead time, and put one delayed job in a scheduler for that moment. When it fires, send the reminder and schedule the next occurrence. If the event is moved or deleted, a version check on the job drops it.
- What happens when the organizer deletes a series that has exceptions?
Mark the series as deleted, with a timestamp, and let the fan-out remove it from every guest's index. The overrides go with the series, because they only make sense as part of it. Keep the deleted record for a while so that synced phones learn about the deletion.