Low-Level Design: Calendar App — Event Scheduling, Recurring Events, and Conflict Detection

Core Entities

User: user_id, email, timezone, working_hours (JSON: {mon: “9:00-17:00”, …}). Calendar: calendar_id, user_id, name, color, is_primary, visibility (PRIVATE, PUBLIC, SHARED). Event: event_id, calendar_id, creator_id, title, description, start_time (UTC), end_time (UTC), location, status (CONFIRMED, TENTATIVE, CANCELLED), visibility, recurrence_rule (iCal RRULE string, nullable), recurrence_id (for instances of recurring events), original_start (for modified instances). Attendee: attendee_id, event_id, user_id, email, status (NEEDS_ACTION, ACCEPTED, DECLINED, TENTATIVE), is_organizer. CalendarShare: share_id, calendar_id, shared_with_user_id, permission (VIEW, EDIT).

Recurring Events

Recurring events are stored as a single master event with an RRULE (RFC 5545). Example: RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10 (weekly on Mon/Wed/Fri for 10 occurrences). Expansion: generate individual occurrences on-the-fly for display. Do NOT store each occurrence as a separate database row — 10 years of daily events = 3,650 rows per recurring event. Expansion algorithm: given an RRULE and a query date range [start, end], expand only the occurrences falling in that range. Libraries: rrule.js (JavaScript), python-dateutil (Python). Exception handling: a user edits “this occurrence only” — create an exception row (EventException) linked to the master event with the original_start and the modified fields. When expanding recurrence: skip original_start values that have exceptions, and return the exception row instead. “Edit all following” creates a new master event from that date forward and truncates the original RRULE.

Conflict Detection

Detect overlapping events for a user in a calendar. Two events conflict if: event1.start event2.start. Database query for conflicts:

SELECT e.* FROM events e
JOIN attendees a ON e.event_id = a.event_id
WHERE a.user_id = :user_id
  AND e.status != 'CANCELLED'
  AND e.start_time  :new_event_start

Index: (user_id, start_time, end_time) on the attendees-events join. For recurring events: expand the recurrence for the query range first, then check for conflicts. Soft conflict: show a warning but allow double-booking (meetings from different calendars, personal vs. work events). Hard conflict: block the booking (conference room booking — a room cannot be double-booked).

Free/Busy and Scheduling

The free/busy API: given a list of user_ids and a time range, return the busy slots for each user. Used by “Find a meeting time” features. Implementation: for each user, fetch all events in the range (including expanded recurring occurrences). Merge overlapping intervals. Return free slots as the inverse of busy intervals within working hours. Algorithm for merging overlapping intervals: sort by start time, iterate through — if current.start = requested duration with no busy intervals. For Google Calendar’s “Meet with” feature: this query runs server-side over all attendees’ calendars.

Timezone Handling

All events are stored in UTC. Display is converted to the user’s timezone at read time. Never store in local time (DST transitions cause bugs). Recurring event expansion: expand in the user’s local timezone — “every Monday at 9am” means 9am local time, which shifts in UTC when DST transitions. A recurring event defined as 9am EST (UTC-5) will have occurrences at UTC 14:00 in winter and UTC 13:00 in summer. The rrule library handles this correctly when initialized with the user’s timezone. All-day events: store as a date (not datetime) with no timezone. Display the same date in all timezones. Floating events: “no timezone” — always display at the stored time regardless of the viewer’s timezone (used for travel itineraries, sleep schedules).

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why store recurring events as a single rule rather than expanding all occurrences upfront?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Storage efficiency: a daily recurring event for 5 years = 1,825 occurrences. With 1M recurring events across all users: 1.825 billion rows just for recurrence instances. Storing as one RRULE = 1M rows. An infinite recurrence (no end date, e.g., “every Monday forever”) cannot be stored as pre-expanded rows at all. Flexibility: modifying the recurrence rule (changing frequency, adding exceptions) affects all future occurrences — with pre-expansion, you’d need to delete and re-insert all future instances. With a single RRULE, one UPDATE handles it. Query efficiency: calendar views query a date range (e.g., the current month). With RRULE storage: expand the RRULE for only the visible range. With pre-expanded rows: query by date range index (fast, but huge table). The expansion is O(occurrences in range) — for a monthly view, typically 4-8 occurrences per recurring event. The rrule library handles RRULE expansion with correct DST handling.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle “edit this and all following” for a recurring event?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “”Edit this and all following” from occurrence at date D: (1) Terminate the original recurring event: set UNTIL=D-1day in the original RRULE (so it stops generating occurrences before D). Or set COUNT to cover only occurrences before D. (2) Create a new master event: copy the original event’s fields, apply the modifications, set start_date = D, set a new RRULE for the modified series (same frequency, no end date or the original end date). (3) Migrate exceptions: any exceptions for dates >= D from the original series should be re-linked to the new master event. This split approach preserves history (the original series up to D-1 is intact) while allowing the future series to diverge. Edge case: editing all occurrences (not “this and following”) = just update the master event’s fields in place. The RRULE expansion reflects the change for all future occurrences automatically.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement an efficient “find available meeting slot” feature?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Given N attendees and a desired meeting duration, find the next available slot during business hours. Algorithm: (1) Fetch busy intervals for all attendees for the requested date range (next 5 business days is typical). Expand recurring events within the range. (2) Merge all intervals into a unified busy list: combine all attendees’ busy times and merge overlapping intervals. Sort by start time, then merge where intervals overlap. O(m log m) where m = total intervals. (3) Find free slots: iterate through the merged busy list. Gaps between consecutive busy intervals that are >= duration and within working hours are free slots. (4) Return the top N free slot suggestions. Optimization: use a priority queue merging N sorted attendee interval lists (like k-way merge) — avoids loading all intervals into memory. Timezone handling: convert all busy intervals to UTC for comparison, convert candidate slots back to each user’s local timezone for display. Working hours in UTC: expand working hours for each day separately (DST may shift the UTC offset between days in the range).”
}
},
{
“@type”: “Question”,
“name”: “How do you design the notification system for calendar reminders?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Calendar events have reminders: “notify me 15 minutes before the event.” Storage: EventReminder table: reminder_id, event_id, attendee_id, minutes_before, method (EMAIL, PUSH, SMS), is_sent, scheduled_at (computed: event.start_time – minutes_before). For recurring events: don’t pre-create reminders for all future occurrences. Instead, a daily cron job runs each morning: expand all recurring events for the next 24-48 hours, create reminder rows for each occurrence that doesn’t already have one. Sending: a reminder worker queries: SELECT * FROM reminders WHERE scheduled_at NOW() – 5 minutes (the 5-minute window handles delayed processing). Sends the notification, marks is_sent = TRUE. At-most-once delivery: the is_sent flag and a unique constraint on (event_id, attendee_id, scheduled_at) prevents duplicate sends. For real-time push: maintain a WebSocket connection per active user; push the reminder directly without polling. For email/SMS: route through a notification service (SendGrid, Twilio).”
}
},
{
“@type”: “Question”,
“name”: “What database schema supports efficient calendar queries for a month view?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Primary query: “all events for user U in date range [start, end].” Schema: events table with start_time and end_time (both UTC). For events the user has access to (their own calendars + shared calendars): JOIN with calendar_shares and calendars. Index: (calendar_id, start_time) covers most queries. But events spanning multiple days (multi-day events) can start before the range but overlap with it — the query must use: start_time = :range_start. Index on start_time alone covers start_time = range_start in-memory. For very long-span events (annual recurring events): consider an additional index on end_time for the reverse filter. Partitioning: partition events by year (PostgreSQL range partitioning on start_time). Old partitions are rarely queried (only for history views); active partitions (current + next month) stay hot in the buffer pool. Recurring events: store only the master event, expand instances at query time in application code (not SQL).”
}
}
]
}

Asked at: Atlassian Interview Guide

Asked at: LinkedIn Interview Guide

Asked at: Apple Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top