Designing a Calendar and Scheduling System: Availability Windows, Timezone Handling, and Recurring Events at Scale
A deep-dive into the backend architecture of a calendar and scheduling system. Covers data modeling for events and attendees, UTC-first timezone handling, RRULE-based recurrence, availability computation across multiple calendars, double-booking prevention, CalDAV sync, and scaling to millions of calendars.
Most engineers who build a calendar system start with what looks like a simple schema: events with start and end times, a users table, and a join table for attendees. Two months in, they hit the first recurring event bug. Four months in, a user in Fiji causes a timezone edge case that crashes the availability query. Six months in, the SELECT that checks free slots takes 12 seconds because no one thought about how recurrence expansion interacts with database indexing.
This is a harder domain than it looks. The design surface covers data modeling, timezone semantics, recurrence math, conflict detection, external sync, and read-scaling. Each layer has non-obvious failure modes.
Data Model
The core entities are calendars, events, attendees, and availability rules.
interface Calendar {
id: string;
ownerId: string;
name: string;
timezone: string; // IANA name: "America/New_York"
isPublic: boolean;
externalSyncUrl?: string; // CalDAV or iCal feed URL
lastSyncedAt?: Date;
}
interface Event {
id: string;
calendarId: string;
title: string;
description?: string;
startUtc: Date; // always UTC in the database
endUtc: Date; // always UTC in the database
startTimezone: string; // timezone at creation time: "America/Chicago"
endTimezone: string; // may differ for cross-timezone travel events
isAllDay: boolean;
recurrenceRule?: string; // RFC 5545 RRULE string, null if non-recurring
recurrenceExceptions: Date[]; // UTC timestamps of cancelled occurrences
status: "confirmed" | "tentative" | "cancelled";
organizerId: string;
conferenceUrl?: string;
version: number; // for optimistic locking
updatedAt: Date;
}
interface Attendee {
eventId: string;
userId: string;
email: string;
status: "accepted" | "declined" | "tentative" | "needsAction";
isOrganizer: boolean;
isOptional: boolean;
}
interface AvailabilityRule {
id: string;
calendarId: string;
dayOfWeek: number; // 0 = Sunday, 6 = Saturday
startMinute: number; // minutes since midnight in the calendar's timezone
endMinute: number;
isWorking: boolean; // false means blocked/out-of-office
}
The critical choice here is storing startUtc and endUtc as proper UTC timestamps while also preserving the original timezone in startTimezone. The UTC values drive all query logic. The timezone values drive display. The reason you need both: if a user creates an event at “9 AM Pacific” and then you convert and throw away the timezone, you can no longer correctly display the event in the user’s own local time after DST changes.
All-day events are a special case. They have a date (not a datetime), and that date is meaningful in the creator’s timezone, not UTC. Store all-day events with isAllDay: true and interpret startUtc as midnight UTC on the named date, but be explicit in your display layer that the date is calendar-local.
Timezone Handling
The rule is simple: store UTC, display local. Applying it consistently is harder.
What goes wrong most often is that engineers store event times in the user’s local timezone or as a naive datetime string, then discover that querying for “all events today” requires knowing every user’s timezone, and that DST transitions create phantom off-by-one-hour bugs twice a year.
UTC storage means every query involving time comparison is timezone-naive at the database level. “Give me all events overlapping with 2026-04-17 14:00–15:00 UTC” is a straightforward range query. The conversion to local time happens in the application layer or the client.
import { toZonedTime, fromZonedTime, format } from "date-fns-tz";
// Convert a UTC Date to display in a specific timezone
function formatEventTime(utcDate: Date, timezone: string): string {
const zoned = toZonedTime(utcDate, timezone);
return format(zoned, "yyyy-MM-dd HH:mm zzz", { timeZone: timezone });
}
// Convert a user-supplied local time to UTC for storage
function localToUtc(
localDateString: string, // "2026-04-17T09:00:00"
timezone: string // "America/New_York"
): Date {
return fromZonedTime(new Date(localDateString), timezone);
}
For availability windows defined in AvailabilityRule, the minutes-since-midnight values are interpreted in the calendar’s timezone. When you expand those rules into UTC ranges for a specific date, you must account for DST transitions. A “9 AM” rule on a day when clocks spring forward will be 14:00 UTC, not 13:00 UTC. A naive offset calculation will be wrong.
import { startOfDay, addMinutes } from "date-fns";
import { toZonedTime, fromZonedTime } from "date-fns-tz";
function expandAvailabilityRuleToUtc(
rule: AvailabilityRule,
calendarTimezone: string,
date: Date // UTC midnight of the target date
): { startUtc: Date; endUtc: Date } | null {
const zonedDate = toZonedTime(date, calendarTimezone);
// Check day of week in the calendar's timezone, not UTC
if (zonedDate.getDay() !== rule.dayOfWeek) return null;
const localStart = addMinutes(startOfDay(zonedDate), rule.startMinute);
const localEnd = addMinutes(startOfDay(zonedDate), rule.endMinute);
return {
startUtc: fromZonedTime(localStart, calendarTimezone),
endUtc: fromZonedTime(localEnd, calendarTimezone),
};
}
This function correctly handles DST because date-fns-tz applies the correct offset for the specific date, not a static offset for the timezone.
Recurring Event Representation
Recurrence is where most calendar implementations make a fundamental architecture mistake. The choice is between two approaches:
Virtual expansion: Store one event row with an RRULE string, expand occurrences at query time.
Materialized instances: Pre-generate individual rows for each occurrence.
Virtual expansion keeps the database compact (one row per recurring series) but makes query ranges expensive: to find all events overlapping a 7-day window, you must load every active RRULE, parse it, and expand it forward. With millions of recurring events this does not scale.
Materialized instances make range queries cheap (standard index scan) but create write amplification: modifying a recurring event with 200 future occurrences means 200 writes, and “edit this and all following” requires a delete-and-reinsert for every affected row.
The production answer is a hybrid: store the RRULE on the parent event, pre-generate occurrences up to a rolling horizon (typically 90 days), and expand on demand when a query reaches beyond the horizon.
interface RecurringEventInstance {
id: string;
parentEventId: string; // references Event.id
occurrenceUtc: Date; // UTC start of this occurrence
endUtc: Date;
isException: boolean; // true if this occurrence has been modified
overrideTitle?: string;
overrideDescription?: string;
status: "confirmed" | "cancelled";
}
RRULE parsing is not something you implement from scratch. Use rrule (npm) or ical.js. The parsing is straightforward; the edge cases are not. RRULE supports BYSETPOS, WKST, COUNT, UNTIL, and combinations that create genuinely surprising expansion results. Let a library handle it.
import { RRule, RRuleSet } from "rrule";
function expandRrule(
rruleString: string,
dtStart: Date, // UTC
exceptions: Date[], // UTC timestamps of cancelled occurrences
windowStart: Date,
windowEnd: Date
): Date[] {
const ruleSet = new RRuleSet();
const rule = RRule.fromString(rruleString);
// rrule library expects dtstart to be part of the options
const ruleWithStart = new RRule({
...rule.origOptions,
dtstart: dtStart,
});
ruleSet.rrule(ruleWithStart);
for (const exDate of exceptions) {
ruleSet.exdate(exDate);
}
return ruleSet.between(windowStart, windowEnd, true);
}
When a user edits “this and all following” occurrences, the correct model is to set UNTIL on the parent RRULE to the day before the edit point, then create a new Event with a new RRULE starting from the edit point. This preserves history without complex branching logic on a single row.
Availability Computation
Finding free slots across multiple calendars is the core query of any scheduling product. The inputs are: a set of calendar IDs to check, a time range to search within, a desired slot duration, and the meeting timezone.
The algorithm: collect all busy intervals from all calendars in the range, merge overlapping intervals, then subtract merged busy time from the requested availability window.
interface TimeInterval {
start: Date;
end: Date;
}
function mergeBusyIntervals(intervals: TimeInterval[]): TimeInterval[] {
if (intervals.length === 0) return [];
const sorted = [...intervals].sort(
(a, b) => a.start.getTime() - b.start.getTime()
);
const merged: TimeInterval[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const last = merged[merged.length - 1];
if (current.start <= last.end) {
// Overlapping or adjacent: extend the end if needed
if (current.end > last.end) {
last.end = current.end;
}
} else {
merged.push(current);
}
}
return merged;
}
function computeFreeSlots(
availabilityWindow: TimeInterval,
busyIntervals: TimeInterval[],
slotDurationMs: number,
bufferMs: number = 0
): TimeInterval[] {
const merged = mergeBusyIntervals(busyIntervals);
const freeSlots: TimeInterval[] = [];
let cursor = availabilityWindow.start;
for (const busy of merged) {
// Try to fit slots in the gap before this busy interval
while (cursor.getTime() + slotDurationMs <= busy.start.getTime()) {
const slotEnd = new Date(cursor.getTime() + slotDurationMs);
freeSlots.push({ start: new Date(cursor), end: slotEnd });
cursor = new Date(slotEnd.getTime() + bufferMs);
}
// Skip past the busy interval
if (busy.end > cursor) {
cursor = busy.end;
}
}
// Slots after the last busy interval
while (cursor.getTime() + slotDurationMs <= availabilityWindow.end.getTime()) {
const slotEnd = new Date(cursor.getTime() + slotDurationMs);
freeSlots.push({ start: new Date(cursor), end: slotEnd });
cursor = new Date(slotEnd.getTime() + bufferMs);
}
return freeSlots;
}
The busy intervals come from two sources: events already on the calendar (fetched from recurring_event_instances and non-recurring events for the range), and external calendar sync data. For multi-calendar scheduling (Calendly-style), you union the busy intervals from all participant calendars before merging. A meeting is schedulable only in windows where every participant is free.
Double-Booking Prevention
Availability computation shows you free slots, but it does not prevent two concurrent booking requests from picking the same slot. The standard approach for exclusive booking is optimistic locking on the event or slot row.
async function createBooking(
db: Pool,
params: {
calendarId: string;
startUtc: Date;
endUtc: Date;
organizerId: string;
attendeeEmails: string[];
}
): Promise<Event> {
const client = await db.connect();
try {
await client.query("BEGIN");
// Check for overlapping confirmed events (exclusive booking)
const { rows: conflicts } = await client.query(
`SELECT id FROM events
WHERE calendar_id = $1
AND status = 'confirmed'
AND start_utc < $3
AND end_utc > $2
FOR UPDATE`,
[params.calendarId, params.startUtc, params.endUtc]
);
if (conflicts.length > 0) {
await client.query("ROLLBACK");
throw new Error("Time slot is no longer available");
}
const { rows } = await client.query<Event>(
`INSERT INTO events (calendar_id, start_utc, end_utc, organizer_id, status, version)
VALUES ($1, $2, $3, $4, 'confirmed', 1)
RETURNING *`,
[params.calendarId, params.startUtc, params.endUtc, params.organizerId]
);
await client.query("COMMIT");
return rows[0];
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
The FOR UPDATE lock on the conflict check is the critical piece. Without it, two concurrent transactions can both read zero conflicts and both proceed to insert, producing a double-booking. The lock forces them to serialize. On high-contention resources (popular meeting rooms, in-demand consultant slots), this is the correct choice even at the cost of reduced throughput.
For systems that allow overbooking up to a limit (event ticketing where 10 people can book the same slot), replace the conflict query with a count-based check and use row-level locking only on the capacity counter.
External Calendar Sync via CalDAV and iCal
Scheduling systems that need to respect a user’s existing Google Calendar or Outlook calendar have two sync options: CalDAV (bidirectional, authenticated, push-capable) and iCal feed (read-only, polled, simpler).
iCal feed sync is the lowest-friction approach. Google, Outlook, and Apple all expose public or private iCal feed URLs. You poll them on a schedule, parse the VCALENDAR/VEVENT structure, and store the busy intervals as opaque blocks (you do not need full event details for availability computation).
import ical from "node-ical";
interface ExternalBusyBlock {
id: string;
calendarId: string;
sourceUid: string; // UID from the external calendar
startUtc: Date;
endUtc: Date;
syncedAt: Date;
}
async function syncIcalFeed(
calendarId: string,
feedUrl: string,
db: Pool
): Promise<void> {
const events = await ical.async.fromURL(feedUrl);
const blocks: ExternalBusyBlock[] = [];
for (const [uid, component] of Object.entries(events)) {
if (component.type !== "VEVENT") continue;
if (!component.start || !component.end) continue;
blocks.push({
id: crypto.randomUUID(),
calendarId,
sourceUid: uid,
startUtc: new Date(component.start.toISOString()),
endUtc: new Date(component.end.toISOString()),
syncedAt: new Date(),
});
}
// Upsert: replace existing blocks for this calendar with fresh data
await db.query(
`DELETE FROM external_busy_blocks WHERE calendar_id = $1`,
[calendarId]
);
if (blocks.length > 0) {
// Bulk insert (simplified; use pg-copy or unnest for large volumes)
for (const block of blocks) {
await db.query(
`INSERT INTO external_busy_blocks
(id, calendar_id, source_uid, start_utc, end_utc, synced_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (calendar_id, source_uid)
DO UPDATE SET start_utc = $4, end_utc = $5, synced_at = $6`,
[block.id, block.calendarId, block.sourceUid,
block.startUtc, block.endUtc, block.syncedAt]
);
}
}
}
CalDAV sync is more complex but enables bidirectional write (create events in external calendar, receive real-time change notifications via push). Most implementations use the tsdav library for CalDAV negotiation. The key operations are: initial sync with REPORT (fetch all events), delta sync with calendar-multiget using ETags to detect changes, and webhook-style push via CalDAV scheduling or Google Calendar’s push notifications.
For most scheduling products, iCal polling with a 5-15 minute interval is sufficient. CalDAV bidirectional sync is justified only when your product needs to write back to the user’s primary calendar.
Webhook-Based Change Notification
When a booking is created, modified, or cancelled, downstream systems need to react: send confirmation emails, update videoconference links, trigger workflows. Queuing these as webhooks rather than handling them synchronously keeps the booking transaction fast and reliable.
The pattern is identical to any webhook delivery system: write the event to a queue (or an outbox table) inside the booking transaction, then deliver asynchronously with retry and exponential backoff. The booking write and the webhook enqueue share the same database transaction. If the booking fails, the webhook is never enqueued. If the delivery fails, it retries without re-running the booking logic.
Tradeoffs
| Decision | Option A | Option B | Prefer |
|---|---|---|---|
| Recurrence storage | Virtual (RRULE only) | Materialized instances | Hybrid: RRULE + materialized 90-day horizon |
| Conflict prevention | Pessimistic lock | Optimistic lock + retry | Pessimistic for exclusive resources, optimistic for capacity-limited |
| Timezone storage | Local time + offset | UTC + original timezone | UTC + original timezone |
| External sync | iCal polling | CalDAV bidirectional | iCal for read; CalDAV only if write-back is required |
| Availability rules | DB rows per day-of-week | RRULE in calendar | DB rows: simpler queries, easier overrides |
| All-day events | Date string | UTC midnight | UTC midnight with isAllDay flag; never apply DST logic |
Scaling Considerations
A single-tenant calendar system with 10,000 users fits comfortably in a single PostgreSQL instance. At millions of calendars, the bottlenecks are the availability query (scanning busy intervals across many calendars for a time range) and the recurring event expansion (materializing the next 90 days for millions of series).
Availability queries benefit from a partial index on (calendar_id, start_utc, end_utc) filtered to status = 'confirmed'. For multi-calendar queries (checking 50 participants), fan out to parallel queries and merge results in application code rather than doing a massive cross-calendar JOIN.
Recurring event materialization should run as a background job, not on the write path. A worker scans for recurring events whose materialized horizon is within 7 days of expiring, expands the RRULE for the next 90 days, and inserts new instances. For 1 million active recurring series, this job needs to be sharded by calendarId range and run continuously, not on a daily cron.
External sync at scale means one iCal polling job per connected calendar. At 500,000 connected external calendars polling every 10 minutes, you need roughly 833 concurrent fetch operations at any moment. Use a distributed task queue with rate limiting per external provider to avoid hitting Google’s or Microsoft’s rate limits.
Read traffic on availability pages is cacheable. The free-slot computation for a given calendar and day only changes when an event is created, modified, or an external sync runs. Cache the result with a short TTL (60 seconds is usually acceptable for scheduling UIs) or invalidate on write using the calendar’s updatedAt as a cache key component.
Closing Thought
Calendar systems accumulate correctness debt faster than most domains because the bugs are often invisible: a user sees a wrong time, books a conflicting slot, or misses an event that never appeared. The decisions made early on timezone storage and recurrence representation are expensive to undo. Getting them right in the data model, before the first line of UI code, is worth the upfront rigor.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.