How it is built
Architecture overview
A calendar system stores events that happen at a time, may repeat forever, involve several people and rooms, and must show up identically on a phone, a laptop and a third-party app. Its core is a small event store plus three pieces of time logic that every user-visible feature depends on: recurrence expansion (turning "every second Tuesday" into concrete instants), time-zone arithmetic (wall-clock times that move relative to UTC across daylight saving), and interval queries (what overlaps this week, who is free). Around that core sit a scheduling service that proposes meeting times, an invitation channel that talks to other calendars by email and CalDAV, a sync API that ships only changes to clients, and a reminder service that fires notifications on time. The idea that makes it work is to store the rule and the local time, expand lazily inside the window being looked at, and never write a million future instances.
Parts:
- Event store — events with recurrence rules, exceptions and attendees, versioned for safe edits — iCalendar format (no page yet), Optimistic concurrency control, Write-ahead log
- Time engine — recurrence expansion, time zones, DST, month-end anchoring — Recurrence rule expansion, Calendar date arithmetic (no page yet)
- Availability index — which intervals are busy, overlap queries, free/busy answers — Interval tree, Interval arithmetic (no page yet)
- Scheduler — finds meeting times and rooms for many people under constraints — Constraint propagation, Two watched literals, Journaled state revert (no page yet)
- Invitations and external calendars — invites, replies and updates sent to other systems — Email delivery over SMTP (no page yet), SMTP protocol (no page yet), DKIM, SPF, DMARC, Bounce processing (no page yet), DNS protocol, TLS 1.3
- Sync API — clients pull changes since a token, CalDAV and a native API — Incremental sync cursors, Cursor pagination (no page yet), Message sequence numbers
- Reminders and notifications — fire an alert at the right time on the right device — Hierarchical timing wheel, Slab allocator, Mobile push delivery, HTTP/2, JWT, Token bucket, hdr-histogram (no page yet)
- Integrations — webhooks to other apps, retries, dead letters — Webhook delivery, HMAC request signing (no page yet), Exponential backoff with jitter, Dead-letter queue, Idempotency keys, Circuit breaker, Durable job queue
- Access and operations — sharing, delegation, backups, failover, deploys, testing — Authorization models, Mutual TLS, Point-in-time recovery, Primary-backup replication, Process supervision, Graceful draining, Deterministic simulation testing, Coverage-guided fuzzing
phone / web / CalDAV client ──HTTPS──► API (authz, sync tokens) ──► event store (SQL, versioned rows)
▲ push (APNs/FCM) │ │ change log
│ │ window query ├──► free/busy index (intervals per calendar)
reminder service ◄── timing wheel ◄───────┤ expand RRULE in window ├──► webhooks / integrations (retry, DLQ)
▲ ▼ └──► invitation mailer ──SMTP──► other calendars
└────────────── scheduler (find a time, book a room) ◄── availability index ▲ replies (iMIP/iTIP)
The write side (create, edit, respond) is small and transactional; the read side (month views, free/busy) is windowed, cacheable and tolerant of a few seconds of delay. Both query by time window, never by "all events".
How the parts fit together
Event store
An event row holds the master: start (wall-clock time plus a zone name, or a date for all-day events), duration,
title, location, the recurrence rule if any, a list of excluded dates, attendees with their reply status, and a
version. A one-off exception ("this Tuesday moved to Wednesday") is a second row that points at the master and the
instance it replaces. This is the shape of the iCalendar format (no page yet): RFC 5545 defines
the event component (VEVENT) with RRULE, EXDATE, RDATE and RECURRENCE-ID, and every other calendar speaks
it, so the store keeps the iCalendar fields rather than inventing its own.
Two people editing the same event is the main concurrency case, and it is rare enough for
Optimistic concurrency control: each update names the version it saw (CalDAV exposes
this as the ETag with If-Match), and a mismatch returns the current copy to merge. Whole-series edits ("change
the time of all future instances") split the master in two at the chosen instance instead of rewriting history. The
database's Write-ahead log gives crash safety and also supplies the ordered change feed that every
downstream piece reads.
Time engine
Recurrence rule expansion turns a rule into instants lazily: given a window, it walks the rule forward from the start in local wall-clock time and yields only instances inside the window. Storing the rule and not the instances keeps "every day, forever" at one row, and makes "change all future events" an edit of one field. The hard part is not the iteration but the calendar arithmetic underneath it (Calendar date arithmetic (no page yet)):
- Local time first. "Every Monday 09:00 in Berlin" is expanded as local times and only then converted to UTC, so the meeting stays at 09:00 across the DST change while its UTC time moves by an hour.
- Gaps and overlaps. A local time that does not exist (clocks jump forward) or occurs twice (clocks go back) needs a defined rule; RFC 5545 specifies one for each, and the library must follow it.
- Month-end anchoring. "Monthly on the 31st" skips short months in RFC 5545; "last day of the month" is a
different rule (
BYMONTHDAY=-1). Products often pick the second meaning silently, which breaks interoperability. - Time-zone data. Zone rules come from the IANA tz database and change by government decree; the server updates tzdata independently of the application and stores a zone name, never a fixed offset, for future events.
Availability index
Month views and free/busy both ask "which intervals overlap this window". Scanning every event of every calendar is linear in history. An Interval tree answers an overlap query in O(log n + k) for k results; for a mostly-static index a sorted array of start times with a running maximum of end times is smaller and faster. The index is built from expanded instances for a rolling horizon (order of the next one to two years) and from the rule for anything beyond it. Free/busy answers expose only start and end plus a busy flag, not titles, which is how a colleague can see you are busy without seeing why. Intervals are half-open, so an event ending at 10:00 and one starting at 10:00 do not conflict; where fractional or floating times appear, Interval arithmetic (no page yet) rounds outward to stay conservative.
Scheduler
"Find 45 minutes this week for these six people and one room, avoiding lunch, preferring mornings" is a constraint problem, solved by Constraint propagation: each attendee's free slots are a domain, working hours and room capacity prune it, and a search picks the best surviving slot. Propagation first discards impossible slots cheaply; only the remaining search needs backtracking. For booking many meetings at once, the solver can reduce to SAT, where Two watched literals keeps unit propagation cheap. A tentative plan must be undoable when one placement fails, which a Journaled state revert (no page yet) provides without copying the whole schedule. Nothing is written until the user confirms; the booking is a version-checked write, so two people booking one room race on the room's row and exactly one wins.
Invitations and external calendars
When an organizer adds an attendee, the system must tell that attendee's calendar, which may be a different product.
RFC 5546 (iTIP) defines the message types (request, reply, cancel) and RFC 6638 defines how CalDAV servers exchange
them internally; between organizations the usual transport is an email with an iCalendar part (iMIP). Delivery
therefore inherits everything about email: Email delivery over SMTP (no page yet) with
queues and retries over the SMTP protocol (no page yet), recipient domains found via the
DNS protocol, links protected by TLS 1.3, and sender reputation held up by
DKIM, SPF and DMARC. Invitations that bounce are handled by
Bounce processing (no page yet): a hard bounce marks the attendee as unreachable instead of
retrying forever. Replies coming back (accept, decline) are matched to the event by its UID and applied as an
attendee status change, not as a new event. Only accept a reply whose sender matches the attendee on the event, or anyone can
mark anybody as accepted.
Sync API
Clients keep a local copy of the calendars they show. Instead of re-reading everything, each calendar has a sync token; the client sends the last token and receives the changed and deleted event ids plus a new token (Incremental sync cursors). The same idea appears in CalDAV (RFC 6578 defines collection synchronization for WebDAV) and in Google Calendar's sync-token API, which tells a client with an expired token to discard its copy and do a full sync (HTTP 410 Gone; Google's sync guide). Large first syncs page with Cursor pagination (no page yet) so a concurrent edit cannot shift the page boundary, and the token is derived from the change log's position, which is monotonic. Push notifications carry only "something changed in calendar X"; the client then pulls. Per-device streams use Message sequence numbers to detect a missed notification.
Reminders and notifications
A reminder is "alert 10 minutes before an instance". The service keeps the next due alert per event, not all future alerts, and stores the due time in a Hierarchical timing wheel: O(1) insert and cancel for millions of timers, with timer nodes carved from a Slab allocator. When a timer fires, the notification goes to Mobile push delivery (APNs and FCM over HTTP/2, the provider authenticating with a JWT), with a short TTL, because a reminder that arrives after the meeting is noise. A Token bucket per user caps floods (an import of 10,000 events must not send 10,000 pushes). After each firing the engine expands the rule to find the next occurrence and re-arms. Timer state is rebuilt from the store on restart, so the wheel is a cache, not a database. Lateness (the gap between a reminder's due time and the moment it is handed to APNs or FCM) is recorded in an hdr-histogram (no page yet), so the tail percentiles of that gap are visible and a slow wheel or a backed-up push worker shows up before users notice late alerts.
Integrations
Third-party apps want to know when events change (video-call links added to meetings, CRM updates). The system sends them through Webhook delivery: payloads signed with HMAC request signing (no page yet), failures retried on a Durable job queue with Exponential backoff with jitter, and messages that never succeed moved to a Dead-letter queue. Idempotency keys let a caller retry "create event" without creating two, and a Circuit breaker stops hammering an endpoint that is down so one slow customer cannot fill the queue.
Access and operations
Calendars are shared in layers: free/busy only, read details, edit, manage sharing, plus delegation ("my assistant accepts invites for me") and resource calendars. Authorization models fits: roles per calendar, relationships for individual shares, and a rule that the least-permissive answer wins when a user is in conflicting groups. Service-to-service calls (API, scheduler, mailer, push) use Mutual TLS. The store uses Primary-backup replication and Point-in-time recovery, the answer to "I deleted my whole calendar yesterday". Processes run under Process supervision, and a deploy drains the push workers with Graceful draining so no reminder is half-sent. The recurrence engine and the iCalendar parser are tested with Deterministic simulation testing (a simulated clock jumping across DST) and Coverage-guided fuzzing, because parsers read bytes from strangers' invitations.
Data and control flow
Creating a weekly meeting and inviting five people:
- The client sends title, start in local time with zone,
FREQ=WEEKLY, attendees and an idempotency key. - The API checks the caller may write to this calendar, stores the master row (version 1) in one transaction and appends to the change log. Order of a few milliseconds; the dominant cost is the commit sync to disk.
- The log feeds three consumers: the free/busy index (expands instances for the horizon and inserts intervals), the invitation mailer (one iTIP request per external attendee, internal attendees get the event directly) and the reminder service (arms the first alert).
- Each attendee's reply is a status change on the attendee list; the organizer's client receives it via its sync token.
Opening the week view on a phone:
- The phone has a local copy and a sync token. It sends the token; the server returns events changed since then.
- The phone expands recurring masters for the visible week locally (the same rule library, so results match) and draws the grid. Time goes to the one network round trip, not to computation.
- If the token has expired the server answers "gone" and the phone refetches the calendar in pages.
Finding a time for a team:
- The scheduler asks the availability index for each attendee's busy intervals in the candidate range.
- It builds slot domains from working hours minus busy time, intersects them, prunes with room and preference constraints, and ranks the survivors.
- The user picks one; the booking is an ordinary version-checked write, so a slot taken in the meantime fails cleanly and the scheduler is re-run.
Scaling
- Free/busy fan-out. Looking up thirty people multiplies reads. Keep busy intervals in a compact per-calendar index, cache them with short expiry, batch lookups into one request.
- Recurrence cost. Expansion is cheap per window but unbounded rules, many exceptions and large windows add up.
Cap the horizon, cache expanded windows, and reject malformed rules (
COUNTof millions) at write time. - Reminder volume. Alerts cluster at the top and half of each hour. Spread work across wheel shards and keep push connections open (HTTP/2 multiplexing); a thundering herd at :00 is the peak that sizes the push tier.
- Sync traffic. Mobile clients poll; replace polling with push-then-pull, and make "nothing changed" a cheap answer (compare the token to the log head).
- Storage. Event rows are small (order of a kilobyte). Shard by calendar owner or workspace; only free/busy crosses calendars.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Meeting shifts by an hour after DST | recurring event appears at 08:00 or 10:00 | instances stored in UTC, or offsets stored instead of zone names | store local time plus zone; expand in local time |
| Missing instance | a monthly event skips a month | "31st" rule on a 30-day month, or a gap-time instance dropped | follow RFC 5545 semantics, test month ends and DST gaps |
| Duplicate event on client | the same meeting twice after sync | server UID not preserved on import, retry without idempotency key | stable UID, idempotency keys, upsert by UID |
| Forged accept | an attendee shows "accepted" they never sent | iTIP reply trusted without checking sender | match reply sender to attendee; require DKIM/SPF alignment |
| Lost update | one person's edit overwritten | writes without version check | optimistic concurrency with ETag / If-Match |
| Reminder storm | many alerts at once, or alerts for deleted events | no rate limit, timers not cancelled on delete | token bucket, cancel on change, rebuild wheel from store |
| Tzdata drift | event times wrong after a government changes DST | old tzdata on some servers | ship tzdata updates independently, version it |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| Radicale | small CalDAV/CardDAV server, file storage | Kozea/Radicale | GPL-3.0 | simplest complete example of the server side |
| sabre/dav | PHP WebDAV, CalDAV, CardDAV framework | sabre-io/dav | BSD-3-Clause | base of Baïkal and used by Nextcloud |
| Baïkal | lightweight CalDAV/CardDAV server on sabre/dav | sabre-io/Baikal | GPL-3.0 | self-hosted calendar |
| Nextcloud Calendar | web calendar app | nextcloud/calendar | AGPL-3.0 | client of the Nextcloud CalDAV server |
| ical4j | Java iCalendar parser, builder and recurrence library | ical4j/ical4j | BSD-3-Clause | the common JVM recurrence engine |
| python-caldav | Python CalDAV client library | python-caldav/caldav | Apache-2.0 | shows the client side of RFC 4791 |
| rrule (JS) | recurrence rules in JavaScript | jkbrzt/rrule | see repo | RFC 5545 rules; last push 2024, treat as stable rather than active |
| Cal.com | scheduling and booking pages | calcom/cal.com | MIT | availability and booking on top of external calendars |
| Stalwart | mail server that also speaks CalDAV and JMAP | stalwartlabs/stalwart | AGPL-3.0 (see repo) | integrates invitations and calendar in one server |
| Apple Calendar and Contacts Server | historic reference CalDAV server | apple/ccs-calendarserver | Apache-2.0 | archived; the reference for RFC 6638 behaviour |
The alternative to CalDAV is a proprietary JSON API with sync tokens (Google Calendar) or the JSON data model JSCalendar (RFC 8984) for new designs. A closed API is easier to evolve and faster on mobile; CalDAV buys interoperability with every desktop and phone calendar. Most real systems expose both.
Sources
- RFC 5545, Internet Calendaring and Scheduling Core Object Specification (iCalendar). rfc
- RFC 5546, iCalendar Transport-Independent Interoperability Protocol (iTIP). rfc
- RFC 4791, Calendaring Extensions to WebDAV (CalDAV). rfc
- RFC 6638, Scheduling Extensions to CalDAV. rfc
- RFC 6578, Collection Synchronization for Web Distributed Authoring and Versioning (WebDAV). rfc
- RFC 8984, JSCalendar: A JSON Representation of Calendar Data. rfc
- Google, Synchronize resources efficiently (Calendar API sync tokens). guide