Notion system design interviews cover collaborative editing, notes and document management, real-time sync, and calendar/scheduling features. They look for deep understanding of CRDTs, conflict resolution, and offline-first architecture.
Interview focus: Collaborative editing, notes apps, calendar systems, and offline-first mobile design.
Key Topics
google calendarpostgresqlkafkarecurring eventstimezone
You've read the guide — now put your knowledge to the test. Our AI interviewer will challenge you with follow-up questions and give you real-time feedback on your system design.
Google Calendar has over 500 million users. It's installed on more than 5 billion devices. And yet, when you look at it from a pure traffic standpoint, it's not a particularly demanding system — people don't create calendar events at the rate they send messages or scroll feeds.
The reason this question shows up in senior interviews isn't about raw scale. It's because the problem domain is genuinely tricky. Timezones. Recurring events with exceptions. Concurrent edits to shared events. Notification scheduling that needs to fire reliably at a specific moment in the future. Free/busy computation across multiple calendars. These are problems where a casual answer falls apart the moment the interviewer asks a follow-up.
This guide goes deep on all of them.
Clarify the Scope
Don't jump straight to architecture. Spend two minutes figuring out what you're actually building.
Good questions to ask:
Are we designing for personal scheduling, or enterprise (multiple rooms, resources, delegates)?
Do we need recurring events? They add significant complexity.
What notification types — email, push, SMS?
Do we need a free/busy API so users can see others' availability?
Do we need to support external calendar sync (iCal, CalDAV)?
What's the scale — consumer app or enterprise platform?
For this guide: a consumer calendar supporting event creation, attendee invites, RSVP, recurring events, reminders, and free/busy time queries.
Requirements
Functional
Create, update, and delete calendar events (one-time and recurring)
Invite attendees to events; attendees can RSVP (accepted, declined, tentative)
Send reminders — email and push — at a configurable time before the event
View events for a given time range (day, week, month)
Query free/busy time for a user to find available meeting slots
Multiple calendars per user (personal, work, shared)
Non-Functional
Consistency over availability for writes — a created or modified event should be durably stored; we can't lose it
Low read latency — calendar views need to load fast; under 200ms
Reliable notifications — reminders must fire at the right time, not 20 minutes late
Scalability — handle millions of concurrent users, especially during business hours when everyone is checking their 9 AM calendar
Back-of-the-Envelope Estimates
plaintext
Registered users: 500 millionDaily Active Users (DAU): 100 millionEvents created per DAU per day: 1 (conservative; most days users just read)Attendees per event: 5 on averageWrite throughput: 100M DAU × 1 event/day / 86,400s ≈ 1,160 writes/second Peak (business hours, 3× multiplier): ~3,500 writes/secondRead throughput: Each DAU views their calendar ~10 times/day 100M × 10 / 86,400s ≈ 11,600 reads/second Peak: ~35,000 reads/secondRead:write ratio ≈ 10:1 — moderately read-heavyNotification volume: Each event triggers 1 reminder per attendee 1,160 new events/sec × 5 attendees = ~5,800 notifications/second (spread over time — not all fire at once)Storage: Average event record: ~1KB (title, times, description, location) Attendee row: ~100 bytes 1,160 events/sec × 86,400s × 365 days × 1KB ≈ ~36TB/year (raw events) Manageable with standard relational database + archival strategy
Two things stand out. First, this is not a write-heavy system — 3,500 writes per second at peak is well within what a properly indexed PostgreSQL setup can handle before you need to think about sharding. Second, notifications are where the operational complexity hides. Firing 5,800 reminders per second reliably, at precisely the right time, is a distributed scheduling problem in its own right.
Database Schema
Google Calendar is a relational problem at its core. Events have owners, attendees have statuses, calendars belong to users. That's structured data with clear relationships — PostgreSQL is the right primary store.
Core Tables
sql
-- A user accountCREATE TABLE users ( user_id UUID PRIMARY KEY, email TEXT UNIQUE NOT NULL, timezone TEXT NOT NULL DEFAULT 'UTC' -- IANA tz ID e.g. 'America/New_York');-- A user can have multiple calendars (personal, work, a shared team calendar)CREATE TABLE calendars ( calendar_id UUID PRIMARY KEY, owner_id UUID NOT NULL REFERENCES users(user_id), name TEXT NOT NULL, color TEXT, is_primary BOOLEAN DEFAULT FALSE);-- An eventCREATE TABLE events ( event_id UUID PRIMARY KEY, calendar_id UUID NOT NULL REFERENCES calendars(calendar_id), created_by UUID NOT NULL REFERENCES users(user_id), title TEXT NOT NULL, description TEXT, location TEXT, start_time TIMESTAMPTZ NOT NULL, -- stored in UTC end_time TIMESTAMPTZ NOT NULL, timezone TEXT NOT NULL, -- IANA tz ID: 'Europe/London' is_all_day BOOLEAN DEFAULT FALSE, recurrence_rule TEXT, -- RRULE string: 'FREQ=WEEKLY;BYDAY=MO' recurrence_id UUID, -- for exceptions: points to the parent event original_start TIMESTAMPTZ, -- the occurrence this exception replaces version INTEGER DEFAULT 1, -- for optimistic locking created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW());-- Who's invited to what, and their responseCREATE TABLE attendees ( event_id UUID REFERENCES events(event_id) ON DELETE CASCADE, user_id UUID REFERENCES users(user_id), rsvp_status TEXT CHECK (rsvp_status IN ('accepted', 'declined', 'tentative', 'needs_action')), is_organizer BOOLEAN DEFAULT FALSE, PRIMARY KEY (event_id, user_id));-- Reminders to sendCREATE TABLE reminders ( reminder_id UUID PRIMARY KEY, event_id UUID REFERENCES events(event_id) ON DELETE CASCADE, user_id UUID REFERENCES users(user_id), method TEXT CHECK (method IN ('email', 'push', 'sms')), notify_at TIMESTAMPTZ NOT NULL, sent BOOLEAN DEFAULT FALSE, sent_at TIMESTAMPTZ);
Critical Indexes
sql
-- Event range queries: "show me everything between Monday 9AM and Friday 6PM"CREATE INDEX idx_events_calendar_time ON events (calendar_id, start_time, end_time);-- Free/busy queries across a user's calendarsCREATE INDEX idx_events_start ON events (start_time);-- Finding unsent reminders efficiently (the scheduler's query)CREATE INDEX idx_reminders_unsent ON reminders (notify_at, sent) WHERE sent = FALSE;-- Attendee lookups: "which events is user X attending?"CREATE INDEX idx_attendees_user ON attendees (user_id);
The index on reminders(notify_at, sent) WHERE sent = FALSE is a partial index — it only indexes unsent reminders. As reminders get marked sent, they drop out of the index automatically. This keeps the index small and the scheduler's poll query fast even after millions of reminders have been processed.
Timezone Handling — The Part Everyone Gets Wrong
Timezones are where calendar systems quietly break. The naive approach is to store timestamps as local time strings, and it works fine until a user in London schedules a meeting at 9 AM and someone in New York sees it at the wrong time after a DST change.
The right model is "store globally, display locally":
Store all times in UTC (TIMESTAMPTZ in PostgreSQL stores in UTC internally)
Also store the IANA timezone ID (e.g. America/New_York) alongside the event
Convert to local time at display time, using the IANA timezone database
plaintext
User in New York creates event at 9:00 AM EST (UTC-5): → stored as: start_time = 2025-03-10T14:00:00Z, timezone = 'America/New_York'User in London views the event: → UTC time 14:00 → converted to London local time: 14:00 GMT (same day, UTC+0) → displayed as: 2:00 PMUser in New York views it: → UTC time 14:00 → converted to 9:00 AM EST → displayed as: 9:00 AM ✓
Why store the IANA timezone ID separately, and not just the UTC offset?
Because UTC offsets change. New York is UTC-5 in winter and UTC-4 in summer (DST). If you store -05:00 alongside the timestamp, a recurring event scheduled every Monday at 9 AM EST will appear at 9 AM in winter but 10 AM in summer relative to UTC — which is actually correct, because 9 AM local time is the intent.
The IANA ID America/New_York encodes the full DST transition history. Libraries like Joda-Time (Java), dateutil (Python), and Luxon (JavaScript) all use the IANA database to correctly resolve what UTC time corresponds to "9 AM New York time on the second Monday of March."
The DST edge case in recurring events:
A weekly meeting scheduled for 9 AM repeats at 9 AM local time — even through DST transitions. That means its UTC representation shifts by one hour twice a year. This is expected and correct. The RRULE expansion logic must work in the event's local timezone, not in UTC. Expanding in UTC leads to the 10 AM bug.
All-day events are special:
An all-day event like "John's Birthday" doesn't have a time — it spans a calendar date. Store it as a DATE (not TIMESTAMPTZ) so it doesn't get shifted when viewed in different timezones. An all-day event on November 15 should be November 15 everywhere in the world, not slide to November 14 in a UTC-minus timezone.
Timezone handling is consistently where candidates stumble in calendar system design interviews — not because the concept is hard, but because the DST edge case with recurring events is non-obvious until you've thought through it carefully. Getting that explanation crisp before your interview is worth time. Mockingly.ai includes calendar system design prompts where timezone handling is a standard follow-up.
Client sends POST /calendars/{id}/events with title, times, attendee list, optional RRULE
Event Service validates ownership and request
Writes the event in a database transaction:
Insert into events
Insert one row per attendee into attendees (status: needs_action)
Insert reminder rows into reminders — one per attendee, per notification method, with notify_at = start_time - reminder_offset
On transaction commit, publishes an event.created message to Kafka
Kafka consumer handles invite emails asynchronously (doesn't block the response)
Returns the new event to the caller
The reminder rows are created at event creation time, not lazily. This is a deliberate choice — discussed more in the notifications section.
Fetching Events for a Time Range
sql
-- Get all events for a user's calendar between two timestampsSELECT e.*FROM events eJOIN calendars c ON e.calendar_id = c.calendar_idWHERE c.owner_id = $user_id AND e.start_time < $range_end AND e.end_time > $range_start AND e.recurrence_id IS NULL -- exclude exception instances in first passORDER BY e.start_time;
Then, in application code, expand recurring events for the queried range (described below), merge the exception instances back in, and return the unified list.
The start_time < range_end AND end_time > range_start condition is the standard overlap check — it catches events that start before the range but end inside it, start inside it, or span the entire range.
RSVP Flow
Attendee sends POST /events/{id}/rsvp with status: "accepted"
RSVP Service validates that the caller is actually an attendee of the event
Updates attendees.rsvp_status for that (event_id, user_id) pair
Publishes rsvp.changed to Kafka
Kafka consumer notifies the event organizer asynchronously
Recurring Events — The Hardest Part
This is where most candidates either skip the detail or get it wrong. Recurring events are tricky because you need to balance query flexibility with storage efficiency, and handle the "edit this and all following" scenario without corrupting your data.
How Recurring Events Are Stored
Don't materialise every occurrence. A meeting that repeats weekly for a year would be 52 rows — fine for one event, but at scale, creating an event generates thousands of database rows. Instead, store the recurrence rule as a string on the parent event row.
The industry standard for this is RRULE from iCalendar RFC 5545. It's the same format Google Calendar, Apple Calendar, Outlook, and every major calendar system uses. A few examples:
plaintext
FREQ=WEEKLY;BYDAY=MO every MondayFREQ=WEEKLY;BYDAY=MO,WE,FR Mon, Wed, Fri standupFREQ=MONTHLY;BYMONTHDAY=15 15th of every monthFREQ=DAILY;INTERVAL=2;COUNT=10 every other day, 10 timesFREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 every New Year's Day
When the server receives a request for a date range, it expands the RRULE at query time using a library (such as rrule.js, dateutil.rrule in Python, or ical4j in Java) to get the list of occurrence timestamps within that range. This expansion is fast — it's arithmetic, not a database query.
Editing a Recurring Event — Three Cases
This is where almost every calendar system gets complicated, and it's a common deep-dive question.
Case 1: Edit this occurrence only
The user changes only Tuesday's standup. They don't want to affect all the others.
Create a new row in events with recurrence_id pointing to the parent event, and original_start set to the time of the occurrence being replaced. This is called an exception instance. When expanding the parent's RRULE, exclude any occurrences that have an exception instance.
At query time: expand the RRULE, then for each occurrence, check if there's an exception row with a matching original_start. If yes, use the exception row instead.
Case 2: Edit this and all following occurrences
"From next Tuesday onwards, this meeting is an hour earlier."
Split the recurrence. Add UNTIL= to the original RRULE to terminate it just before the split point. Create a new parent event starting from the modified occurrence with its own RRULE.
plaintext
Original: FREQ=WEEKLY;BYDAY=TU → modified to: FREQ=WEEKLY;BYDAY=TU;UNTIL=20251104T085900ZNew: FREQ=WEEKLY;BYDAY=TU (starting 2025-11-11, one hour earlier)
This way, the history is preserved exactly, and future occurrences follow the new rule.
Case 3: Edit all occurrences
Update the parent event row directly. All future expansions reflect the change. Past occurrences already happened, so there's no retroactive issue unless you're storing a history — but for a calendar, past meetings don't need updating.
The three recurring edit cases are one of the most common deep-dive questions in Google Calendar interviews. Most candidates know there are multiple cases; far fewer can describe the RRULE split mechanism for "this and following" without prompting. If that explanation isn't second nature yet, Mockingly.ai is a good place to pressure-test it before the real thing.
The EXDATE Pattern
RFC 5545 also defines EXDATE — a list of specific occurrence timestamps to exclude from the recurrence. This is useful for cancelled individual occurrences without needing to create a whole exception instance row. Store it as an array alongside the RRULE:
plaintext
RRULE: FREQ=WEEKLY;BYDAY=MOEXDATE: 2025-12-25T09:00:00Z (skip Christmas day even though it's a Monday)
Conflict Detection and Free/Busy Queries
"Find me a 1-hour slot next week when Alice, Bob, and Carlos are all free." This is one of the most useful features in Google Calendar, and it requires careful design.
Checking if Two Events Overlap
Two time intervals [a_start, a_end) and [b_start, b_end) overlap if and only if:
plaintext
a_start < b_end AND a_end > b_start
In SQL, to find if a proposed event conflicts with any existing accepted events for a user:
sql
SELECT COUNT(*)FROM events eJOIN attendees a ON e.event_id = a.event_idWHERE a.user_id = $user_id AND a.rsvp_status = 'accepted' AND e.start_time < $proposed_end AND e.end_time > $proposed_start;
Google Calendar doesn't block double-booking — it warns about it. The query is a check, not a gate. The index on (calendar_id, start_time, end_time) makes this fast.
Computing Free Slots: The Merge Intervals Problem
To show when someone is available, you need to find the gaps between their busy periods. This is the classic "merge overlapping intervals" algorithm.
Step 1: Fetch all accepted events for the relevant users in the query window, ordered by start_time.
Step 2: Merge overlapping intervals:
python
def merge_intervals(intervals): if not intervals: return [] merged = [intervals[0]] for start, end in intervals[1:]: if start <= merged[-1][1]: merged[-1] = (merged[-1][0], max(merged[-1][1], end)) else: merged.append((start, end)) return merged
Step 3: Find gaps between merged busy periods. Each gap is a potential free slot.
Step 4: For multi-user availability (e.g. "find a time when the whole team is free"), compute busy periods per user, then find time ranges that don't appear in anyone's busy set. Intersection of free windows across all users.
This logic lives in the application layer, not in SQL. The database provides the raw event data; the service does the interval math.
Caching free/busy data: For frequently queried users (active calendar sharers, popular meeting rooms), cache their merged busy intervals in Redis with a TTL. Invalidate the cache when an event is created, modified, or an RSVP status changes. The Kafka event.changed and rsvp.changed topics drive these invalidations.
Notification Scheduling at Scale
"Send a reminder to all attendees 30 minutes before the event." Easy to state, genuinely hard to do reliably at scale.
At 5,800 new events per second (with 5 attendees each), you're inserting ~5,800 reminder rows every second. And at any given moment, some of those reminders are ready to fire.
The Scheduler Pattern
A Notification Scheduler Worker runs continuously, polling for due reminders:
Mark reminders as sent = TRUE (with UPDATE ... WHERE sent = FALSE to prevent double-sends in case of retries)
Push to an email/push provider (SES, FCM, etc.)
On provider failure, keep sent = FALSE and retry with backoff
The LIMIT 500 keeps each batch bounded. The partial index on (notify_at, sent) WHERE sent = FALSE means this query only touches unsent reminders — it stays fast indefinitely.
Why not use a delayed job queue (like Redis ZSET) instead of a DB poll?
You could. Storing (reminder_id, notify_at) in a sorted set and processing entries as they become due is a valid approach. But it adds another moving part. For this scale (not millions of reminders per second), a database-backed scheduler with a fast partial index is simpler, durable, and observable. If something goes wrong, the state is in one place.
When Events Are Modified or Cancelled
When an event changes (time moves, event deleted), reminders need to be updated too. The Kafka event.changed consumer handles this:
Time change:UPDATE reminders SET notify_at = new_start_time - offset WHERE event_id = $id AND sent = FALSE
Cancellation:DELETE FROM reminders WHERE event_id = $id AND sent = FALSE (or soft-delete)
New attendee added: Insert new reminder rows for the new attendee
Because this happens via Kafka (asynchronously), there's a brief window where a reminder could fire on the old time if the event was changed very close to the original notify time. For a calendar app, this is an acceptable tradeoff. If exact-once delivery becomes critical, a distributed lock on event_id during the update window handles it.
Concurrent Edits: Optimistic Locking
When multiple users share edit access to an event, two of them can load the event at the same time and both submit conflicting changes. Last-write-wins loses data. A transaction-level lock blocks users and scales poorly.
The right solution is optimistic locking with a version counter.
The events table has a version INTEGER column. Every update must include the version the client loaded:
sql
UPDATE eventsSET title = $new_title, start_time = $new_start, version = version + 1, updated_at = NOW()WHERE event_id = $event_id AND version = $expected_version;
If no rows are affected, it means someone else updated the event between when you loaded it and when you submitted. The server returns HTTP 409 Conflict. The client shows a "someone else changed this event — please review and resubmit" message.
For a calendar application, edits are infrequent (compared to, say, a collaborative document). Conflicts are rare. Optimistic locking handles the rare case without the overhead of distributed locks or serial write queues. It's the right trade-off.
Optimistic locking vs distributed locks is an exact trade-off comparison that comes up in Google, Atlassian, and Notion interviews — and the answer depends on conflict frequency. Being able to explain that reasoning clearly, connecting infrequent calendar edits to why optimistic locking is correct here, is exactly the kind of depth Mockingly.ai is designed to help you practise.
Calendar Sharing and Permissions
Users can share their calendar with others. Permissions come in three tiers: view free/busy only, view all event details, or full edit access.
The Event Service checks this table before responding to any request:
free_busy: caller can see time ranges marked busy, but not titles or details
read: caller sees full event content
write: caller can create, edit, and delete events on this calendar
For the free/busy query path, this is important — when you ask "when is Alice free?", the system checks whether Alice has shared her calendar with you and at what level before deciding what data to return.
Caching Strategy
Calendar reads are bursty — lots of users load their calendar at 8–9 AM on weekdays. The same events get fetched repeatedly across devices.
What to cache in Redis:
Recent event lists per calendar — a user's events for the current week, cached for 60 seconds. Short TTL because events change.
Free/busy merged intervals — computed once, cached per user with a 5-minute TTL. Invalidated on any event mutation via Kafka.
User and calendar metadata — rarely changes; cache for 1 hour.
What not to cache: individual event details are rarely hot enough to justify complexity. The index-backed range queries are fast enough for individual lookups.
API Design
plaintext
GET /calendars/{calendarId}/events?start=2025-11-10T00:00:00Z&end=2025-11-17T00:00:00Z → Returns all events (including expanded recurring occurrences) in the windowPOST /calendars/{calendarId}/events → Body: { title, start_time, end_time, timezone, attendees, rrule, reminders } → Returns the created event with server-assigned event_idPATCH /calendars/{calendarId}/events/{eventId} → Body: { ...changes, version: 3 } → Returns 409 if version doesn't match (optimistic lock conflict) → Query param: ?scope=this|this_and_following|all (for recurring edits)DELETE /calendars/{calendarId}/events/{eventId}?scope=this|this_and_following|allPOST /calendars/{calendarId}/events/{eventId}/rsvp → Body: { status: "accepted" | "declined" | "tentative" }GET /users/{userId}/freebusy?start=...&end=...&timezone=America/New_York → Returns merged busy intervals for the user in the requested range
Scaling Discussion
Google Calendar's actual scale is read-dominated and time-localized. Most traffic happens during business hours in major timezones. A few observations:
Database: A single well-tuned PostgreSQL primary with read replicas handles this comfortably at 35,000 reads/second. Sharding by calendar_id or user_id becomes necessary beyond a certain point, but you reach it later than you'd think.
Event expansion for recurring events: is CPU-bound, not I/O-bound. The RRULE expansion for a week-view query is microseconds of arithmetic. This can scale horizontally with the application tier without touching the database.
Notification scheduler: can be sharded by time bucket. Partition reminders across multiple worker instances by notify_at mod N so each worker owns a slice of the notification schedule. This scales linearly.
The thundering herd at 9 AM: millions of users checking their calendar at the same time. CDN caching for static content helps. Redis caching with short TTLs absorbs most read traffic before it hits PostgreSQL. If needed, application-level request coalescing (multiple requests for the same calendar view return the same cached response) caps the fan-out.
Common Interview Follow-ups
"How would you handle an event that spans midnight in the user's timezone?"
This is why you store the IANA timezone alongside the event. A midnight-spanning event stored as UTC timestamps looks correct as long as you always use the event's stored timezone — not the server's timezone or the viewer's timezone — when displaying start/end times. TIMESTAMPTZ handles this transparently in PostgreSQL.
"What happens to reminders for a recurring event when the user modifies 'this and all following'?"
All unsent reminders for occurrences from the split point forward need to be deleted and recreated against the new parent event. The Kafka event consumer handles this: when it receives a recurring_event.split message, it deletes pending reminders for all affected occurrences and creates new ones based on the new schedule. This is a batch operation, not a real-time one, so a brief window of stale reminders is acceptable.
"How do you handle a user in a timezone that observes Daylight Saving Time when they view a recurring event set by a user in a timezone that doesn't?"
This is exactly why each event stores its own IANA timezone ID (the creator's intent), separate from the viewer's timezone. When viewer B loads the event, the server converts the stored UTC time using the event's timezone to determine the canonical local time, then converts that to viewer B's timezone for display. The DST complexity is all in the timezone conversion libraries — the data model just needs to preserve both the UTC value and the creating timezone.
"Could you use a NoSQL database for events?"
You could, for the storage layer. Cassandra or DynamoDB could handle the write throughput and time-range queries if you designed the partition key as (user_id, year_month) and clustered by start_time. But you'd lose the transactional guarantees needed for the multi-table write on event creation (event + attendees + reminders all need to be atomically committed). You'd have to handle that in the application layer. For this particular problem, the relational model earns its keep — the schema naturally represents the problem and the transaction boundary is clear.
✅ Recurring events via RRULE string — not materialised rows for every occurrence
✅ Three modes of recurring edit: this only, this and following (RRULE split), all
✅ Exception instances stored with recurrence_id + original_start
✅ Overlap check: start < proposed_end AND end > proposed_start
✅ Free/busy: merge intervals in application code, cache in Redis, invalidate via Kafka
✅ Notification scheduler: partial index on unsent reminders, poll-and-mark pattern
✅ Reminder update on event mutation handled via Kafka consumer
✅ Optimistic locking with version counter — 409 on conflict, client handles
✅ Calendar sharing with permission tiers
✅ API uses ?scope= for recurring edit operations
Conclusion
Google Calendar looks like a CRUD app and behaves like a distributed systems puzzle. The interesting problems are all in the details: DST transitions breaking recurring events if you expand in the wrong timezone. The three-way split in recurring event edits. Notification scheduling that needs to be durable, exactly-once, and correct even when events move.
The design pillars:
PostgreSQL with TIMESTAMPTZ + IANA timezone ID — UTC for storage, named timezone for intent
RRULE strings from RFC 5545 — don't materialise every occurrence; expand at query time
Exception instances — recurrence_id + original_start for per-occurrence overrides
RRULE split for "this and following" — terminate the original, create a new parent
Overlap check in SQL, merge intervals in application code — each layer does what it's good at
Partial index on unsent reminders — keeps the scheduler query fast at any scale
Optimistic locking with version counter — right trade-off for infrequent concurrent edits
Frequently Asked Questions
Why store the IANA timezone ID instead of a UTC offset for calendar events?
UTC offsets change with Daylight Saving Time — storing an offset captures a moment in time, not the user's intent. The IANA timezone ID America/New_York encodes the full DST transition history, allowing the system to correctly resolve local time for any date in the future.
The problem with storing UTC offsets:
New York in winter is UTC-5; in summer it's UTC-4
If you store -05:00 with a recurring event, every occurrence computed in summer will appear one hour later in UTC than intended
A "9 AM every Monday" meeting would appear at 10 AM UTC in summer — which is actually correct local time, but only if you use the offset as the anchor rather than the local time
How IANA IDs fix this:
Store timezone = 'America/New_York' alongside the UTC timestamp
When expanding recurring events, use the IANA library to determine what UTC time corresponds to "9 AM New York time" on each specific occurrence date
DST transitions are handled automatically — the library knows when they occur
Rule of thumb: store UTC for the database, store IANA timezone ID for intent, convert to local time only at display.
How do recurring events work in a calendar system — what is RRULE?
RRULE (Recurrence Rule) is a standard from RFC 5545 (iCalendar) that defines a recurring event pattern as a compact string. The system stores one event row per recurring series, not one row per occurrence.
Example RRULE strings:
plaintext
FREQ=WEEKLY;BYDAY=MO,WE,FR → every Monday, Wednesday, FridayFREQ=MONTHLY;BYDAY=1MO → first Monday of every monthFREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 → December 25 every yearFREQ=WEEKLY;BYDAY=TU;COUNT=10 → 10 occurrences starting from the event startFREQ=DAILY;UNTIL=20251231T235900Z → daily until December 31, 2025
Why store RRULE strings instead of materialising every occurrence:
A weekly meeting for 2 years = 104 occurrences. Storing 104 rows for one meeting wastes storage and complicates updates
RRULE expansion is cheap arithmetic — a week-view query expands the pattern in microseconds in application code
Editing "all occurrences" is a single row update; with materialised rows it's 104 updates
At query time, the application expands the RRULE into concrete timestamps for the requested time window, merges in any exception instances, and returns the unified list.
How do you edit a recurring event — the three cases explained
Editing a recurring event has three distinct cases, each requiring a different database operation. Getting this right is the most common deep-dive question in calendar system design interviews.
Case 1: Edit this occurrence only
Create a new event row with recurrence_id pointing to the parent and original_start set to the replaced occurrence's timestamp
This is called an exception instance
When expanding the parent's RRULE, skip any occurrence that has a matching exception instance
The exception row replaces that one occurrence; all others remain from the parent
Case 2: Edit this and all following occurrences
Terminate the original RRULE: add UNTIL=<timestamp_before_split_point> to the original event's RRULE
Create a new parent event starting from the modified occurrence with its own RRULE
Historical occurrences (before the split) remain from the original event
Future occurrences follow the new event's rule
Case 3: Edit all occurrences
Update the parent event row directly
All RRULE expansions from this point forward reflect the change
Exception instances are preserved unless explicitly removed
The split mechanism for Case 2 is what most candidates miss — understanding that "this and following" requires terminating the original RRULE and creating a new parent is the key insight.
How does calendar conflict detection work?
Two time intervals overlap if and only if one starts before the other ends. This mathematical condition translates directly to a SQL WHERE clause.
SELECT COUNT(*)FROM events eJOIN attendees a ON e.event_id = a.event_idWHERE a.user_id = $user_id AND a.rsvp_status = 'accepted' AND e.start_time < $proposed_end AND e.end_time > $proposed_start;
Important design decision: Google Calendar warns about double-booking but does not block it. The query is a check, not a gate. Users are informed of the conflict and can decide whether to proceed.
Why the overlap condition works for all cases:
B completely inside A: a_start < b_end ✓ and a_end > b_start ✓
A completely inside B: both conditions still hold ✓
Partial overlap (A starts before B ends, B starts before A ends): both conditions hold ✓
Adjacent (A ends exactly when B starts): a_end > b_start fails ✓ — adjacent events don't conflict
How does free/busy computation work at scale?
Free/busy computation requires finding the gaps between a user's busy periods — the classic merge intervals algorithm applied to calendar data.
The four-step process:
Fetch all accepted events for the relevant user(s) in the query window, ordered by start_time
Merge overlapping intervals into consolidated busy periods:
python
def merge_intervals(intervals): if not intervals: return [] merged = [intervals[0]] for start, end in intervals[1:]: if start <= merged[-1][1]: # overlaps with last merged interval merged[-1] = (merged[-1][0], max(merged[-1][1], end)) else: merged.append((start, end)) return merged
Find gaps between merged busy periods — each gap is a free slot
For multi-user availability: compute busy periods per user separately, then find time ranges that fall in no one's busy set (intersection of free windows)
Caching strategy:
Free/busy queries for popular users (active calendar sharers, meeting rooms) run frequently. Cache merged busy intervals per user in Redis with a 5-minute TTL. Invalidate the cache via Kafka when any event is created, modified, or when an RSVP status changes.
How does notification scheduling work for calendar reminders at scale?
Notification scheduling uses a partial-indexed database poll pattern — not a message queue — for durability and simplicity at calendar scale.
How reminder rows are created:
When an event is created, reminder rows are inserted in the same transaction: one per attendee per notification method
notify_at = start_time - reminder_offset (e.g., 30 minutes before the event)
sent = FALSE on creation
How the Notification Scheduler Worker delivers them:
sql
SELECT reminder_id, event_id, user_id, methodFROM remindersWHERE sent = FALSE AND notify_at <= NOW() + INTERVAL '1 minute'LIMIT 500;
Then marks each batch sent = TRUE before sending — the WHERE sent = FALSE in the update prevents double-sends on retry.
Why a partial index makes this fast at any scale:
sql
CREATE INDEX idx_reminders_unsent ON reminders (notify_at, sent) WHERE sent = FALSE;
As reminders are marked sent, they drop out of the partial index automatically. The index only ever contains unsent reminders — it stays small and fast even after millions of sent reminders accumulate in the table.
When events are modified: a Kafka consumer handles reminder updates asynchronously — updating notify_at for time changes, deleting unsent reminders for cancellations.
What is optimistic locking and why is it the right choice for calendar events?
Optimistic locking allows concurrent reads but detects conflicting writes at commit time — by requiring each update to include the version number the client last saw. If someone else updated between your read and your write, the version won't match, and the update fails with a 409 Conflict.
How it works for calendar events:
sql
UPDATE eventsSET title = $new_title, start_time = $new_start, version = version + 1, updated_at = NOW()WHERE event_id = $event_id AND version = $expected_version;-- 0 rows affected = someone else updated first → return 409 to client
Why optimistic locking is correct for calendars (not distributed locks):
Optimistic Locking
Distributed Lock
Best for
Low-conflict scenarios
High-conflict scenarios
Blocks other readers?
No
Depends on implementation
Overhead
One version check per write
Lock acquisition + network round-trip
Calendar conflict frequency
Rare (infrequent concurrent edits)
—
Verdict
✅ Right choice
Overkill for calendars
Calendar events are rarely edited by multiple people simultaneously. Optimistic locking handles the rare conflict without blocking all writers behind a lock. For a highly concurrent system (collaborative document editing), pessimistic locking or OT would be appropriate instead.
How should all-day events be stored differently from regular events?
All-day events should be stored as DATE type, not TIMESTAMPTZ — because they represent a calendar date, not a specific moment in time.
Why the distinction matters:
A birthday on November 15 means "the entirety of November 15 in the user's local calendar" — not a specific UTC instant
If stored as TIMESTAMPTZ, the UTC representation depends on timezone: November 15 00:00 in New York is November 15 05:00 UTC, which could display as November 14 in a UTC-minus timezone
A user in Hawaii (UTC-10) would see their birthday land on November 14 — wrong
The correct approach:
Store all-day events with a DATE column: event_date = '2025-11-15'
Set a flag is_all_day = TRUE on the event row
Display the date as-is regardless of the viewer's timezone — it's a date, not a time
Don't convert all-day event dates through timezone math
The rule: if a user would describe the event as "on a date" (birthday, holiday, out-of-office), use DATE. If they'd describe it as "at a time" (meeting, call, appointment), use TIMESTAMPTZ.
Which companies ask the Google Calendar system design question in interviews?
Google, Meta, Microsoft, Amazon, Atlassian, Calendly, and Notion ask variants of this question for senior software engineer roles.
Why it is a consistently probing interview question:
The problem domain is genuinely tricky — timezone handling, recurring event edits, and notification scheduling are problems where casual answers break under follow-up
Tests domain modelling depth — the RRULE approach vs materialised rows, the three recurring edit cases, and the IANA timezone ID decision all reveal whether a candidate has thought through the full problem
Medium difficulty with real depth — accessible enough to start quickly, hard enough to distinguish senior engineers from mid-level ones
What interviewers specifically listen for:
UTC + IANA timezone ID — not UTC offsets alone, and explaining why DST transitions matter
RRULE string, not materialised rows — and the query-time expansion approach
Three recurring edit cases named and explained — especially the RRULE split for "this and following"
Partial index on WHERE sent = FALSE — the specific mechanism that keeps the notification scheduler fast at scale
Optimistic locking with version counter — and connecting it to low conflict frequency in calendar apps
If any of those five feel uncertain, that's exactly where Mockingly.ai helps — live system design simulations where an AI interviewer asks these questions, follows up when an answer is thin, and gives you feedback on the reasoning, not just the component list.
System design interviews reward candidates who can connect the dots between a platform's edge cases and the architectural decisions those edge cases force. If you want to practice explaining these trade-offs under real interview pressure — not just read about them — Mockingly.ai has backend system design simulations built for engineers preparing for senior roles at companies like Google, Meta, Amazon, and Atlassian.