Amazon Interview Question

Design Google Calendar — Amazon Interview

medium18 minBackend System Design

How Amazon Tests This

Amazon system design interviews centre on e-commerce infrastructure, distributed computing, delivery logistics, and high-availability services. They look for candidates who can design systems following their leadership principles, especially "think big" and "bias for action".

Interview focus: E-commerce platforms, delivery logistics, distributed systems, task scheduling, and monitoring.

Key Topics
google calendarpostgresqlkafkarecurring eventstimezone

How to Design Google Calendar

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 million
Daily Active Users (DAU): 100 million
Events created per DAU per day: 1 (conservative; most days users just read)
Attendees per event: 5 on average
 
Write throughput:
  100M DAU × 1 event/day / 86,400s ≈ 1,160 writes/second
  Peak (business hours, 3× multiplier): ~3,500 writes/second
 
Read throughput:
  Each DAU views their calendar ~10 times/day
  100M × 10 / 86,400s ≈ 11,600 reads/second
  Peak: ~35,000 reads/second
 
Read:write ratio ≈ 10:1 — moderately read-heavy
 
Notification 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 account
CREATE 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 event
CREATE 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 response
CREATE 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 send
CREATE 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 calendars
CREATE 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 PM
 
User 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.


High-Level Architecture

plaintext
                         ┌──────────────────────┐
                         │    API Gateway /       │
                         │    Load Balancer        │
                         └──────────┬─────────────┘

         ┌──────────────────────────┼──────────────────────┐
         │                          │                      │
┌────────▼──────────┐   ┌───────────▼────────┐  ┌─────────▼────────┐
│   Event Service   │   │   RSVP Service      │  │  Free/Busy       │
│  CRUD, recurrence │   │  invite, respond    │  │  Service         │
│  conflict check   │   │  notify organizer   │  │  availability    │
└────────┬──────────┘   └───────────┬────────┘  └─────────┬────────┘
         │                          │                      │
         └──────────────────────────┼──────────────────────┘

              ┌─────────────────────┼─────────────────────┐
              │                     │                     │
   ┌──────────▼──────────┐   ┌──────▼──────────┐  ┌──────▼──────────┐
   │    PostgreSQL        │   │     Redis        │  │     Kafka       │
   │  (primary store)     │   │  (read cache,    │  │  (event bus:    │
   │  events, attendees,  │   │   free/busy      │  │   mutations,    │
   │  reminders           │   │   cache)         │  │   notifications)│
   └─────────────────────┘   └─────────────────┘  └──────┬──────────┘

                                               ┌──────────▼──────────┐
                                               │  Notification        │
                                               │  Scheduler Worker    │
                                               │  (polls reminders,   │
                                               │   sends email/push)  │
                                               └─────────────────────┘

Core Flows

Creating an Event

  1. Client sends POST /calendars/{id}/events with title, times, attendee list, optional RRULE
  2. Event Service validates ownership and request
  3. 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
  4. On transaction commit, publishes an event.created message to Kafka
  5. Kafka consumer handles invite emails asynchronously (doesn't block the response)
  6. 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 timestamps
SELECT e.*
FROM events e
JOIN calendars c ON e.calendar_id = c.calendar_id
WHERE 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 pass
ORDER 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

  1. Attendee sends POST /events/{id}/rsvp with status: "accepted"
  2. RSVP Service validates that the caller is actually an attendee of the event
  3. Updates attendees.rsvp_status for that (event_id, user_id) pair
  4. Publishes rsvp.changed to Kafka
  5. 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 Monday
FREQ=WEEKLY;BYDAY=MO,WE,FR         Mon, Wed, Fri standup
FREQ=MONTHLY;BYMONTHDAY=15         15th of every month
FREQ=DAILY;INTERVAL=2;COUNT=10     every other day, 10 times
FREQ=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.

sql
-- Exception instance row
INSERT INTO events (event_id, calendar_id, ..., recurrence_id, original_start)
VALUES (new_uuid, ..., parent_event_id, '2025-11-11T09:00:00Z');

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=20251104T085900Z
New:      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=MO
EXDATE: 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 e
JOIN attendees a ON e.event_id = a.event_id
WHERE 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:

sql
SELECT reminder_id, event_id, user_id, method
FROM reminders
WHERE sent = FALSE
  AND notify_at <= NOW() + INTERVAL '1 minute'  -- slight lookahead
LIMIT 500;

For each batch:

  1. Mark reminders as sent = TRUE (with UPDATE ... WHERE sent = FALSE to prevent double-sends in case of retries)
  2. Push to an email/push provider (SES, FCM, etc.)
  3. 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 events
SET 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.

sql
CREATE TABLE calendar_shares (
    calendar_id  UUID REFERENCES calendars(calendar_id),
    shared_with  UUID REFERENCES users(user_id),
    permission   TEXT CHECK (permission IN ('free_busy', 'read', 'write')),
    PRIMARY KEY (calendar_id, shared_with)
);

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 window
 
POST /calendars/{calendarId}/events
  → Body: { title, start_time, end_time, timezone, attendees, rrule, reminders }
  → Returns the created event with server-assigned event_id
 
PATCH /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|all
 
POST /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.


Quick Interview Checklist

  • ✅ Clarified scope — recurring events, reminders, free/busy, sharing
  • ✅ Back-of-envelope confirms this is read-heavy but moderate write load — not a high-throughput challenge
  • ✅ PostgreSQL as primary store — justified by relational nature of the data and need for transactions
  • ✅ Timezone design — UTC storage + IANA timezone ID; explained DST implications
  • ✅ All-day events stored as DATE, not TIMESTAMPTZ
  • ✅ 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:

  1. PostgreSQL with TIMESTAMPTZ + IANA timezone ID — UTC for storage, named timezone for intent
  2. RRULE strings from RFC 5545 — don't materialise every occurrence; expand at query time
  3. Exception instancesrecurrence_id + original_start for per-occurrence overrides
  4. RRULE split for "this and following" — terminate the original, create a new parent
  5. Overlap check in SQL, merge intervals in application code — each layer does what it's good at
  6. Partial index on unsent reminders — keeps the scheduler query fast at any scale
  7. 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:

  1. New York in winter is UTC-5; in summer it's UTC-4
  2. If you store -05:00 with a recurring event, every occurrence computed in summer will appear one hour later in UTC than intended
  3. 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:

  1. Store timezone = 'America/New_York' alongside the UTC timestamp
  2. 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
  3. 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, Friday
FREQ=MONTHLY;BYDAY=1MO             → first Monday of every month
FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 → December 25 every year
FREQ=WEEKLY;BYDAY=TU;COUNT=10      → 10 occurrences starting from the event start
FREQ=DAILY;UNTIL=20251231T235900Z  → daily until December 31, 2025

Why store RRULE strings instead of materialising every occurrence:

  1. A weekly meeting for 2 years = 104 occurrences. Storing 104 rows for one meeting wastes storage and complicates updates
  2. RRULE expansion is cheap arithmetic — a week-view query expands the pattern in microseconds in application code
  3. 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

  1. Create a new event row with recurrence_id pointing to the parent and original_start set to the replaced occurrence's timestamp
  2. This is called an exception instance
  3. When expanding the parent's RRULE, skip any occurrence that has a matching exception instance
  4. The exception row replaces that one occurrence; all others remain from the parent

Case 2: Edit this and all following occurrences

  1. Terminate the original RRULE: add UNTIL=<timestamp_before_split_point> to the original event's RRULE
  2. Create a new parent event starting from the modified occurrence with its own RRULE
  3. Historical occurrences (before the split) remain from the original event
  4. Future occurrences follow the new event's rule

Case 3: Edit all occurrences

  1. Update the parent event row directly
  2. All RRULE expansions from this point forward reflect the change
  3. 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.

The overlap condition:

plaintext
Interval A: [a_start, a_end)
Interval B: [b_start, b_end)
 
They overlap if: a_start < b_end AND a_end > b_start

SQL to find conflicts for a proposed event:

sql
SELECT COUNT(*)
FROM events e
JOIN attendees a ON e.event_id = a.event_id
WHERE 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:

  1. B completely inside A: a_start < b_end ✓ and a_end > b_start
  2. A completely inside B: both conditions still hold ✓
  3. Partial overlap (A starts before B ends, B starts before A ends): both conditions hold ✓
  4. 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:

  1. Fetch all accepted events for the relevant user(s) in the query window, ordered by start_time
  2. 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
  1. Find gaps between merged busy periods — each gap is a free slot
  2. 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:

  1. When an event is created, reminder rows are inserted in the same transaction: one per attendee per notification method
  2. notify_at = start_time - reminder_offset (e.g., 30 minutes before the event)
  3. sent = FALSE on creation

How the Notification Scheduler Worker delivers them:

sql
SELECT reminder_id, event_id, user_id, method
FROM reminders
WHERE 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 events
SET 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 LockingDistributed Lock
Best forLow-conflict scenariosHigh-conflict scenarios
Blocks other readers?NoDepends on implementation
OverheadOne version check per writeLock acquisition + network round-trip
Calendar conflict frequencyRare (infrequent concurrent edits)
Verdict✅ Right choiceOverkill 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:

  1. A birthday on November 15 means "the entirety of November 15 in the user's local calendar" — not a specific UTC instant
  2. 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
  3. A user in Hawaii (UTC-10) would see their birthday land on November 14 — wrong

The correct approach:

  1. Store all-day events with a DATE column: event_date = '2025-11-15'
  2. Set a flag is_all_day = TRUE on the event row
  3. Display the date as-is regardless of the viewer's timezone — it's a date, not a time
  4. 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:

  1. The problem domain is genuinely tricky — timezone handling, recurring event edits, and notification scheduling are problems where casual answers break under follow-up
  2. 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
  3. 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:

  1. UTC + IANA timezone ID — not UTC offsets alone, and explaining why DST transitions matter
  2. RRULE string, not materialised rows — and the query-time expansion approach
  3. Three recurring edit cases named and explained — especially the RRULE split for "this and following"
  4. Partial index on WHERE sent = FALSE — the specific mechanism that keeps the notification scheduler fast at scale
  5. 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.

Companies That Ask This

Ready to Practice?

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.

Free tier includes unlimited practice with AI feedback • No credit card required

Related System Design Guides