Chapter 8: Design Google Calendar
Building a globally distributed calendar system with event scheduling, conflict detection, recurring events, multi-timezone support, and real-time notifications
Table of Contents
- Introduction
- Functional and Non-Functional Requirements
- Capacity Estimation
- Data Model
- High-Level Architecture
- Event Scheduling and Conflict Detection
- Recurring Events
- Timezone Handling
- Notification System
- Free/Busy Lookup
- Calendar Sharing and Permissions
- Sync Protocol
- Read/Write Paths
- Failure Scenarios
- Cost Estimation
- Interview Questions and Answers
1. Introduction
Google Calendar is one of the most widely used productivity applications in the world, serving over 1.5 billion users across web, mobile, and wearable platforms. Behind its deceptively simple interface lies a complex distributed system that must handle event scheduling across timezones, detect conflicts, support recurring events with complex recurrence rules, deliver timely notifications, synchronize state across multiple devices, and manage calendar sharing with fine-grained permissions.
The fundamental challenge of a calendar system is managing state correctly across multiple participants, timezones, and devices. A meeting scheduled in New York must display correctly for participants in London, Tokyo, and Sydney. Recurring events must handle edge cases like daylight saving time transitions, leap years, and modified instances. Calendar sharing must support different permission levels while maintaining low-latency read access.
Real-World Scale
| Metric | Value | Notes |
|---|---|---|
| Monthly active users | 1.5 billion | Across all platforms |
| Events created per day | ~500 million | Peak during business hours |
| Average events per user | ~15/month | Varies by region and profession |
| Concurrent connections | ~100 million | Peak during business hours |
| Notifications per day | ~2 billion | Reminders, invitations, updates |
| Timezones supported | ~100 | IANA timezone database |
| Recurrence rules (RRULE) | ~30% of events | Daily, weekly, monthly, custom |
2. Functional and Non-Functional Requirements
Functional Requirements
- Create/Edit/Delete events: Users can create events with title, description, time, location, attendees, and reminders.
- Recurring events: Support RRULE-based recurrence patterns (daily, weekly, monthly, yearly, custom).
- Conflict detection: Detect and prevent double-booking when creating or modifying events.
- Free/Busy lookup: Check availability of users before scheduling meetings.
- Calendar sharing: Share calendars with varying permission levels (free/busy only, limited details, full access).
- Notifications: Push notifications, email reminders, and in-app alerts for upcoming events.
- Multi-device sync: Real-time synchronization across web, mobile, and desktop clients.
- Multi-timezone support: Events display correctly in the user's local timezone.
- Attachments and location: Support file attachments and location data (including map integration).
Non-Functional Requirements
| Requirement | Target | Justification |
|---|---|---|
| Latency (p99) | <200ms for reads | Calendar loads must feel instant |
| Latency (p99) | <500ms for writes | Event creation should feel responsive |
| Availability | 99.99% | Calendar is critical for business operations |
| Durability | 100% | Never lose calendar data |
| Notification delivery | <30 seconds | Reminders must arrive on time |
| Sync latency | <5 seconds | Changes visible across devices quickly |
| Conflict detection | Strong consistency | Must prevent double-booking |
3. Capacity Estimation
public class CalendarCapacityEstimator
{
public static void Estimate()
{
// Traffic
long monthlyActiveUsers = 1_500_000_000L;
long eventsPerDay = 500_000_000L;
double readsPerEventPerDay = 5.0; // User views event multiple times
long readsPerDay = eventsPerDay * (long)readsPerEventPerDay + monthlyActiveUsers * 2;
long writesPerDay = eventsPerDay * 3; // Create + edits + RSVPs
long notificationsPerDay = 2_000_000_000L;
Console.WriteLine($"Reads/day: {readsPerDay:N0}");
Console.WriteLine($"Writes/day: {writesPerDay:N0}");
Console.WriteLine($"Notifications/day: {notificationsPerDay:N0}");
// Storage
long avgEventSizeBytes = 2048; // Title, description, attendees, metadata
long avgEventsPerUser = 500; // Lifetime events per user
double totalEventsStorageGB = (monthlyActiveUsers * avgEventsPerUser * avgEventSizeBytes) / (1024.0 * 1024 * 1024);
Console.WriteLine($"Total event storage: {totalEventsStorageGB:N0} GB");
// Recurrence expansion storage
long avgRecurrenceInstances = 52; // Average recurring instances per rule
double recurrenceStorageGB = (eventsPerDay * 0.3 * avgRecurrenceInstances * 100) / (1024.0 * 1024 * 1024);
Console.WriteLine($"Recurrence instance storage: {recurrenceStorageGB:N0} GB");
}
}
| Metric | Value |
|---|---|
| Reads per second | ~80K (average), ~250K (peak) |
| Writes per second | ~17K (average), ~50K (peak) |
| Notifications per second | ~23K (average), ~70K (peak) |
| Total event storage | ~1.4 PB |
| Recurrence instance storage | ~15 TB |
| Attachment storage | ~500 TB |
| Notification queue capacity | ~2B messages/day |
4. Data Model
public class Calendar
{
public string CalendarId { get; set; }
public string OwnerUserId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string Color { get; set; }
public CalendarVisibility Visibility { get; set; } // Default, Public, Private
public CalendarRole DefaultRole { get; set; } // Reader, Writer, Owner
public List<CalendarACL> ACLs { get; set; } // Access control list
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public long ETag { get; set; } // Optimistic concurrency control
}
public class CalendarEvent
{
public string EventId { get; set; }
public string CalendarId { get; set; }
public string Summary { get; set; } // Title
public string Description { get; set; }
public string Location { get; set; }
public GeoLocation GeoLocation { get; set; }
public EventStatus Status { get; set; } // Confirmed, Tentative, Cancelled
public bool IsAllDay { get; set; }
public DateTimeOffset StartTime { get; set; }
public DateTimeOffset EndTime { get; set; }
public string StartTimezone { get; set; } // IANA timezone (e.g., "America/New_York")
public string EndTimezone { get; set; }
public List<EventAttendee> Attendees { get; set; }
public List<EventReminder> Reminders { get; set; }
public RecurrenceRule RRULE { get; set; } // Null for non-recurring events
public string RecurringEventId { get; set; } // Links instances to parent
public string OriginalStartTime { get; set; } // For modified recurring instances
public List<string> AttachmentIds { get; set; }
public string OrganizerId { get; set; }
public CalendarVisibility Visibility { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public long ETag { get; set; }
public Dictionary<string, string> ExtendedProperties { get; set; }
}
public class EventAttendee
{
public string UserId { get; set; }
public string Email { get; set; }
public string DisplayName { get; set; }
public RSVPStatus ResponseStatus { get; set; } // NeedsAction, Declined, Tentative, Accepted
public bool IsOptional { get; set; }
public bool IsOrganizer { get; set; }
}
public class EventReminder
{
public ReminderMethod Method { get; set; } // Pop, Email, Sms
public int MinutesBeforeEvent { get; set; }
}
public class RecurrenceRule
{
public string RRULE { get; set; } // RFC 5545 RRULE string
public List<string> EXDATE { get; set; } // Exclusion dates
public List<string> RDATE { get; set; } // Additional dates
public DateTimeOffset? UNTIL { get; set; } // End date for recurrence
public int? COUNT { get; set; } // Max number of occurrences
}
public class FreeBusyInfo
{
public string UserId { get; set; }
public List<TimeSlot> BusySlots { get; set; }
public List<TimeSlot> TentativeSlots { get; set; }
}
Database Schema (Cassandra/Bigtable)
| Table | Partition Key | Sort Key | Use Case |
|---|---|---|---|
| events_by_calendar | calendar_id | start_time, event_id | Load calendar view |
| events_by_user | user_id | date, event_id | User's aggregated view |
| free_busy_by_user | user_id | date_range | Free/busy lookup |
| recurrence_instances | recurring_event_id | instance_date | Recurring event instances |
| notification_queue | notification_date | send_time, user_id | Scheduled notifications |
| calendar_acls | calendar_id | grantee_id | Permission checks |
5. High-Level Architecture
Google Calendar System Architecture
Service Responsibilities
| Service | Responsibility | Scaling Strategy |
|---|---|---|
| Event Service | CRUD for events, conflict detection | Sharded by calendar_id, horizontally scaled |
| Notification Service | Reminders, invitations, updates | Async via message queue, partitioned by user |
| Calendar Sync Service | Multi-device synchronization | Long-polling + SSE, stateless |
| Free/Busy Service | Availability queries for scheduling | Precomputed daily aggregates, cached |
| Recurrence Engine | Expand RRULE into instances | Batch job + on-demand expansion |
| Timezone Service | DST transitions, timezone conversions | Static data, heavily cached |
6. Event Scheduling and Conflict Detection
Conflict Detection Algorithm
Detecting scheduling conflicts requires checking whether a new event overlaps with any existing event on the same calendar (or across shared calendars). This must be atomic to prevent race conditions.
public class ConflictDetector
{
private readonly IEventStore _eventStore;
private readonly IDistributedLock _lockManager;
public async Task<ConflictResult> CheckConflictsAsync(
string calendarId, DateTimeOffset start, DateTimeOffset end,
string excludeEventId = null)
{
// Use a distributed lock on the calendar to prevent concurrent modifications
var lockKey = $"calendar-lock:{calendarId}";
using var lockHandle = await _lockManager.AcquireAsync(lockKey, TimeSpan.FromSeconds(5));
// Query all events in the time range for this calendar
var existingEvents = await _eventStore.GetEventsInRangeAsync(
calendarId, start.AddDays(-1), end.AddDays(1));
var conflicts = new List<CalendarEvent>();
foreach (var existing in existingEvents)
{
// Skip the event being modified
if (existing.EventId == excludeEventId) continue;
// Skip cancelled events
if (existing.Status == EventStatus.Cancelled) continue;
// Check for time overlap
if (TimesOverlap(start, end, existing.StartTime, existing.EndTime))
{
conflicts.Add(existing);
}
}
return new ConflictResult
{
HasConflicts = conflicts.Count > 0,
ConflictingEvents = conflicts,
Suggestion = conflicts.Count > 0
? FindNextAvailableSlot(calendarId, start, end - start, existingEvents)
: null
};
}
private bool TimesOverlap(DateTimeOffset start1, DateTimeOffset end1,
DateTimeOffset start2, DateTimeOffset end2)
{
return start1 < end2 && start2 < end1;
}
private DateTimeOffset? FindNextAvailableSlot(string calendarId,
DateTimeOffset desiredStart, TimeSpan duration, List<CalendarEvent> existing)
{
var sorted = existing.OrderBy(e => e.StartTime).ToList();
var candidate = desiredStart;
foreach (var evt in sorted)
{
if (candidate + duration <= evt.StartTime)
return candidate; // Found a slot before this event
if (candidate < evt.EndTime)
candidate = evt.EndTime; // Move past this event
}
return candidate; // Available after all events
}
}
Optimistic Concurrency with ETags
public class EventService
{
private readonly IEventStore _eventStore;
private readonly ConflictDetector _conflictDetector;
private readonly IEventBus _eventBus;
public async Task<CalendarEvent> CreateEventAsync(CreateEventRequest request)
{
// Validate inputs
ValidateEventRequest(request);
// Check for conflicts
var conflictResult = await _conflictDetector.CheckConflictsAsync(
request.CalendarId, request.StartTime, request.EndTime);
if (conflictResult.HasConflicts)
{
throw new SchedulingConflictException(
"Event conflicts with existing events",
conflictResult.ConflictingEvents,
conflictResult.Suggestion);
}
// Create the event
var evt = new CalendarEvent
{
EventId = Guid.NewGuid().ToString("N"),
CalendarId = request.CalendarId,
Summary = request.Summary,
Description = request.Description,
Location = request.Location,
StartTime = request.StartTime,
EndTime = request.EndTime,
StartTimezone = request.StartTimezone,
EndTimezone = request.EndTimezone,
Attendees = request.Attendees,
Reminders = request.Reminders,
RRULE = request.RecurrenceRule,
Status = EventStatus.Confirmed,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ETag = 1
};
await _eventStore.SaveAsync(evt);
// Publish event created notification
await _eventBus.PublishAsync(new EventCreatedNotification
{
Event = evt,
CalendarId = request.CalendarId,
NotifyAttendees = request.NotifyAttendees
});
return evt;
}
public async Task<CalendarEvent> UpdateEventAsync(
string eventId, UpdateEventRequest request, long expectedETag)
{
var existing = await _eventStore.GetAsync(eventId);
if (existing == null) throw new EventNotFoundException(eventId);
// Optimistic concurrency check
if (existing.ETag != expectedETag)
throw new ConcurrentModificationException(
"Event was modified by another user. Please refresh and try again.");
// Check conflicts for new time range (if time changed)
if (request.StartTime != existing.StartTime || request.EndTime != existing.EndTime)
{
var conflictResult = await _conflictDetector.CheckConflictsAsync(
existing.CalendarId, request.StartTime ?? existing.StartTime,
request.EndTime ?? existing.EndTime, excludeEventId: eventId);
if (conflictResult.HasConflicts)
throw new SchedulingConflictException("Updated time conflicts with existing events",
conflictResult.ConflictingEvents);
}
// Apply updates
ApplyUpdates(existing, request);
existing.UpdatedAt = DateTimeOffset.UtcNow;
existing.ETag++;
await _eventStore.SaveAsync(existing);
await _eventBus.PublishAsync(new EventUpdatedNotification
{
Event = existing,
Changes = GetChangedFields(existing, request)
});
return existing;
}
}
7. Recurring Events
RRULE Expansion
Recurring events are defined using RFC 5545 RRULE syntax. The system must expand recurrence rules into individual instances while handling edge cases like DST transitions, exclusion dates, and modified instances.
public class RecurrenceExpander
{
public List<CalendarEvent> ExpandRecurrence(
CalendarEvent recurringEvent, DateTimeOffset rangeStart, DateTimeOffset rangeEnd)
{
var instances = new List<CalendarEvent>();
var rrule = RecurrenceRule.Parse(recurringEvent.RRULE);
// Generate all occurrences within the range
var dates = GenerateOccurrences(rrule, recurringEvent.StartTime,
rangeStart, rangeEnd);
// Remove excluded dates (EXDATE)
if (recurringEvent.RRULE.EXDATE != null)
{
dates = dates.Where(d => !recurringEvent.RRULE.EXDATE.Contains(
d.ToString("yyyyMMdd"))).ToList();
}
foreach (var occurrenceDate in dates)
{
// Handle timezone and DST transitions
var adjustedStart = AdjustForDST(occurrenceDate, recurringEvent);
var duration = recurringEvent.EndTime - recurringEvent.StartTime;
var adjustedEnd = adjustedStart + duration;
// Check for modified instances (single occurrence changed)
var modifiedInstance = FindModifiedInstance(
recurringEvent.EventId, adjustedStart);
if (modifiedInstance != null)
{
instances.Add(modifiedInstance); // Use the modified version
}
else
{
instances.Add(new CalendarEvent
{
EventId = $"{recurringEvent.EventId}_{occurrenceDate:yyyyMMdd}",
RecurringEventId = recurringEvent.EventId,
Summary = recurringEvent.Summary,
Description = recurringEvent.Description,
StartTime = adjustedStart,
EndTime = adjustedEnd,
StartTimezone = recurringEvent.StartTimezone,
EndTimezone = recurringEvent.EndTimezone,
Status = recurringEvent.Status,
Attendees = recurringEvent.Attendees,
Reminders = recurringEvent.Reminders
});
}
}
return instances;
}
private DateTimeOffset AdjustForDST(DateTimeOffset date, CalendarEvent template)
{
// When a DST transition occurs during a recurring event,
// the event should maintain its local time (wall clock time),
// not its UTC offset.
var tz = TimeZoneInfo.FindSystemTimeZoneById(template.StartTimezone);
// Get the local time from the template
var templateLocal = template.StartTime.LocalDateTime;
// Apply the same local time to the new date
var newLocal = date.LocalDateTime.Date + templateLocal.TimeOfDay;
return new DateTimeOffset(newLocal, tz.GetUtcOffset(newLocal));
}
}
Recurrence Edge Cases
| Edge Case | Example | Handling |
|---|---|---|
| DST spring-forward | 2:30 AM doesn't exist | Shift to 3:00 AM (or next valid time) |
| DST fall-back | 1:30 AM occurs twice | Use the first occurrence (before DST change) |
| Leap year | Feb 29 yearly recurrence | Skip non-leap years |
| Month-end | 31st of every month | Clamp to last day of shorter months |
| Modified instance | Change one occurrence of weekly meeting | Store as separate event with EXDATE on original |
| Deleted instance | Cancel one occurrence | Add EXDATE to RRULE, mark instance as cancelled |
8. Timezone Handling
Multi-Timezone Architecture
Timezone Conversion Flow
public class TimezoneService
{
private readonly ConcurrentDictionary<string, TimeZoneInfo> _tzCache = new();
public DateTimeOffset ConvertToUTC(DateTimeOffset localTime, string timezoneId)
{
var tz = GetTimezone(timezoneId);
var utcOffset = tz.GetUtcOffset(localTime LocalDateTime);
return localTime.ToOffset(utcOffset);
}
public DateTimeOffset ConvertFromUTC(DateTimeOffset utcTime, string targetTimezoneId)
{
var tz = GetTimezone(targetTimezoneId);
return TimeZoneInfo.ConvertTime(utcTime, tz);
}
public string FormatForUser(DateTimeOffset utcTime, string userId)
{
var userTimezone = GetUserTimezone(userId);
var localTime = ConvertFromUTC(utcTime, userTimezone);
return localTime.ToString("ddd, MMM d, yyyy h:mm tt zzz");
}
private TimeZoneInfo GetTimezone(string timezoneId)
{
return _tzCache.GetOrAdd(timezoneId, id =>
{
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
catch (TimeZoneNotFoundException)
{
// Fallback to UTC if timezone not found
return TimeZoneInfo.Utc;
}
});
}
// Detect DST transitions for a given timezone
public List<DSTTransition> GetDSTTransitions(string timezoneId, int year)
{
var tz = GetTimezone(timezoneId);
var transitions = new List<DSTTransition>();
var jan1 = new DateTime(year, 1, 1, 0, 0, 0);
var dec31 = new DateTime(year, 12, 31, 23, 59, 59);
for (var date = jan1; date <= dec31; date = date.AddDays(1))
{
var todayOffset = tz.GetUtcOffset(date);
var tomorrowOffset = tz.GetUtcOffset(date.AddDays(1));
if (todayOffset != tomorrowOffset)
{
transitions.Add(new DSTTransition
{
Date = date.Date,
OffsetBefore = todayOffset,
OffsetAfter = tomorrowOffset,
IsSpringForward = todayOffset < tomorrowOffset
});
}
}
return transitions;
}
}
9. Notification System
Notification Pipeline
public class NotificationService
{
private readonly INotificationQueue _queue;
private readonly IScheduledNotificationStore _scheduledStore;
private readonly IReminderScheduler _reminderScheduler;
public async Task ScheduleEventRemindersAsync(CalendarEvent evt)
{
foreach (var reminder in evt.Reminders)
{
var notifyAt = evt.StartTime.AddMinutes(-reminder.MinutesBeforeEvent);
if (notifyAt <= DateTimeOffset.UtcNow)
continue; // Event already started, skip
await _scheduledStore.SaveAsync(new ScheduledNotification
{
NotificationId = Guid.NewGuid().ToString("N"),
EventId = evt.EventId,
CalendarId = evt.CalendarId,
UserId = GetNotificationRecipient(evt),
Type = reminder.Method,
ScheduledTime = notifyAt,
Payload = new NotificationPayload
{
Title = evt.Summary,
Body = $"Starting in {reminder.MinutesBeforeEvent} minutes",
Location = evt.Location,
EventStartTime = evt.StartTime,
DeepLink = $"calendar://event/{evt.EventId}"
}
});
}
}
public async Task SendInvitationsAsync(CalendarEvent evt, List<EventAttendee> newAttendees)
{
foreach (var attendee in newAttendees.Where(a => !a.IsOrganizer))
{
await _queue.EnqueueAsync(new NotificationMessage
{
RecipientId = attendee.UserId,
Type = NotificationType.Invitation,
Priority = Priority.High,
Payload = new InvitationPayload
{
EventSummary = evt.Summary,
OrganizerName = evt.OrganizerId,
StartTime = evt.StartTime,
EndTime = evt.EndTime,
Location = evt.Location,
ActionUrl = $"calendar://invite/{evt.EventId}",
Actions = new[] { "Accept", "Tentative", "Decline" }
}
});
}
}
}
public class NotificationDispatcher
{
private readonly IPushNotificationService _push;
private readonly IEmailService _email;
private readonly ISmsService _sms;
public async Task DispatchAsync(NotificationMessage message)
{
var user = await _userService.GetAsync(message.RecipientId);
var preferences = await GetNotificationPreferencesAsync(message.RecipientId);
var tasks = new List<Task>();
if (preferences.PushEnabled && message.Priority >= preferences.PushThreshold)
{
tasks.Add(_push.SendAsync(user.PushTokens, message));
}
if (preferences.EmailEnabled && message.Priority >= preferences.EmailThreshold)
{
tasks.Add(_email.SendAsync(user.Email, message));
}
if (preferences.SmsEnabled && message.Priority >= Priority.Critical)
{
tasks.Add(_sms.SendAsync(user.PhoneNumber, message));
}
await Task.WhenAll(tasks);
}
}
10. Free/Busy Lookup
Precomputed Free/Busy Index
Free/busy queries must be extremely fast since they're called for every participant when scheduling a meeting. We precompute daily free/busy data and store it in an optimized format.
public class FreeBusyService
{
private readonly IFreeBusyStore _store;
private readonly IEventStore _eventStore;
public async Task<FreeBusyResponse> GetFreeBusyAsync(
List<string> userIds, DateTimeOffset start, DateTimeOffset end)
{
var response = new FreeBusyResponse();
// Fetch precomputed free/busy data in parallel
var tasks = userIds.Select(async userId =>
{
var busySlots = await _store.GetBusySlotsAsync(userId, start, end);
return new UserFreeBusy
{
UserId = userId,
BusySlots = busySlots
};
});
response.Users = (await Task.WhenAll(tasks)).ToList();
return response;
}
// Called by event service whenever an event is created/updated/deleted
public async Task UpdateFreeBusyAsync(CalendarEvent evt, FreeBusyChange change)
{
var days = GetAffectedDays(evt.StartTime, evt.EndTime);
foreach (var day in days)
{
await _store.UpdateDailyFreeBusyAsync(
evt.Attendees.Select(a => a.UserId),
day,
new TimeSlot
{
Start = evt.StartTime,
End = evt.EndTime,
EventId = evt.EventId,
Status = change == FreeBusyChange.Busy
? SlotStatus.Busy
: SlotStatus.Free
});
}
}
}
// Precomputed daily free/busy stored in a compact bitmap format
public class DailyFreeBusyBitmap
{
// Represent a 24-hour day as 96 quarter-hour slots
private const int SlotsPerDay = 96;
private byte[] _busyBitmap = new byte[SlotsPerDay / 8]; // ~12 bytes per day
public void MarkBusy(int startSlot, int endSlot)
{
for (int i = startSlot; i < endSlot; i++)
{
_busyBitmap[i / 8] |= (byte)(1 << (i % 8));
}
}
public bool IsBusy(int slot)
{
return (_busyBitmap[slot / 8] & (1 << (slot % 8))) != 0;
}
public List<TimeSlot> GetBusySlots()
{
var slots = new List<TimeSlot>();
bool inBusy = false;
int start = 0;
for (int i = 0; i < SlotsPerDay; i++)
{
if (IsBusy(i) && !inBusy)
{
start = i;
inBusy = true;
}
else if (!IsBusy(i) && inBusy)
{
slots.Add(new TimeSlot
{
Start = SlotToTime(start),
End = SlotToTime(i)
});
inBusy = false;
}
}
if (inBusy) slots.Add(new TimeSlot { Start = SlotToTime(start), End = SlotToTime(SlotsPerDay) });
return slots;
}
}
12. Multi-Device Sync Protocol
Sync Token Architecture
Incremental Sync Flow
public class SyncService
{
private readonly ISyncTokenStore _tokenStore;
private readonly IEventStore _eventStore;
private readonly IChangeLog _changeLog;
public async Task<SyncResponse> SyncAsync(string userId, long? lastSyncToken)
{
if (lastSyncToken == null || lastSyncToken == 0)
{
// Full sync
return await FullSyncAsync(userId);
}
// Incremental sync
var changes = await _changeLog.GetChangesAsync(userId, lastSyncToken.Value);
var newToken = await _tokenStore.GetLatestTokenAsync(userId);
var response = new SyncResponse
{
SyncToken = newToken,
Events = new List<SyncEvent>()
};
foreach (var change in changes)
{
switch (change.ChangeType)
{
case ChangeType.Created:
case ChangeType.Updated:
var evt = await _eventStore.GetAsync(change.EventId);
if (evt != null)
{
response.Events.Add(new SyncEvent
{
Event = evt,
ChangeType = change.ChangeType
});
}
break;
case ChangeType.Deleted:
response.Events.Add(new SyncEvent
{
EventId = change.EventId,
ChangeType = ChangeType.Deleted
});
break;
}
}
return response;
}
private async Task<SyncResponse> FullSyncAsync(string userId)
{
var events = await _eventStore.GetAllEventsAsync(userId);
var token = await _tokenStore.GetLatestTokenAsync(userId);
return new SyncResponse
{
SyncToken = token,
Events = events.Select(e => new SyncEvent
{
Event = e,
ChangeType = ChangeType.Created
}).ToList()
};
}
}
13. Read/Write Paths
Write Path: Creating an Event
Event Creation Flow
Read Path: Loading Calendar View
public class CalendarViewService
{
private readonly IEventStore _eventStore;
private readonly ICacheService _cache;
private readonly IFreeBusyService _freeBusy;
private readonly IRecurrenceExpander _recurrence;
public async Task<CalendarView> GetViewAsync(string userId, DateTimeOffset start,
DateTimeOffset end, List<string> calendarIds = null)
{
// Get user's calendars if not specified
calendarIds ??= await GetUserCalendarIdsAsync(userId);
var cacheKey = $"cal-view:{userId}:{start:yyyyMMdd}:{end:yyyyMMdd}";
var cached = await _cache.GetAsync<CalendarView>(cacheKey);
if (cached != null) return cached;
// Fetch events from all calendars in parallel
var allEvents = new List<CalendarEvent>();
var tasks = calendarIds.Select(async calId =>
{
var events = await _eventStore.GetEventsInRangeAsync(calId, start, end);
return events;
});
var results = await Task.WhenAll(tasks);
foreach (var calEvents in results)
allEvents.AddRange(calEvents);
// Expand recurring events
var expandedEvents = new List<CalendarEvent>();
foreach (var evt in allEvents)
{
if (evt.RRULE != null)
{
expandedEvents.AddRange(_recurrence.ExpandRecurrence(evt, start, end));
}
else
{
expandedEvents.Add(evt);
}
}
// Sort by start time
expandedEvents = expandedEvents.OrderBy(e => e.StartTime).ToList();
var view = new CalendarView
{
UserId = userId,
Start = start,
End = end,
Events = expandedEvents,
DayGroups = GroupByDay(expandedEvents, start, end)
};
await _cache.SetAsync(cacheKey, view, TimeSpan.FromMinutes(5));
return view;
}
}
14. Failure Scenarios
| Failure | Impact | Detection | Mitigation | Recovery |
|---|---|---|---|---|
| Event DB down | Cannot create/view events | Health check timeout | Read from replica; queue writes | DB failover to replica |
| Notification service down | Reminders not delivered | Queue depth growing | Retry with exponential backoff | Service restart + catch-up |
| Cache failure | Higher DB load | Cache miss rate spike | Direct DB reads, rate limit | Redis cluster recovery |
| Timezone data stale | Wrong event times | Test with known DST dates | Update IANA TZ database | Automatic update pipeline |
| Sync token corrupted | Client desync | Client reports stale data | Force full resync | Client re-syncs from scratch |
| Recurrence expansion timeout | Complex recurring events slow | Request latency > 5s | Cache expanded instances | Pre-expand common patterns |
15. Cost Estimation
| Component | Spec | Monthly Cost |
|---|---|---|
| Event Service | c5.xlarge × 20 | ~$5K |
| Notification Service | m5.xlarge × 10 | ~$2.5K |
| Sync Service | c5.xlarge × 15 | ~$4K |
| Cassandra cluster | i3.2xlarge × 30 | ~$18K |
| Redis cluster | r5.xlarge × 12 | ~$6K |
| S3 (attachments) | ~500TB | ~$12K |
| SQS (notification queue) | ~2B msgs/day | ~$1.5K |
| Email (SES) | ~500M emails/day | ~$50K |
| Push notifications (FCM/APNs) | ~1B/day | ~$2K |
| Total | ~$101K/month |
16. Interview Questions and Answers
Q1: How do you handle double-booking across shared calendars?
Use a distributed lock on the calendar ID during event creation, combined with a database-level unique constraint on (calendar_id, start_time, end_time). The lock prevents concurrent modifications, and the unique constraint provides a safety net. For cross-calendar conflict detection (checking all attendees' calendars), use a two-phase approach: first check conflicts optimistically, then acquire locks only for calendars with detected conflicts before finalizing.
Q2: How would you handle recurring events with DST transitions?
Store recurring events with their local timezone (wall clock time), not UTC. When expanding instances, apply the local time to each occurrence date and let the timezone database resolve the UTC offset. For spring-forward (2:30 AM doesn't exist), shift to the next valid time. For fall-back (1:30 AM occurs twice), use the first occurrence. The IANA timezone database contains all historical DST rules.
Q3: How do you handle calendar sharing permissions at scale?
Use a hierarchical permission model: Owner → Writer → Reader → Free/Busy. Store ACLs in a separate table indexed by (calendar_id, grantee_id) for O(1) permission checks. Cache ACL results in Redis with a 5-minute TTL. For organizations, support group-based permissions (e.g., "all employees in engineering" can view the team calendar). Permission checks happen at the API gateway level before requests reach the event service.
Q4: How do you sync calendars across multiple devices?
Use a sync token protocol similar to Google's. Each user has a monotonically increasing sync token. Clients store their last sync token and request only changes since that token. The server maintains a change log that records every event modification. When a client syncs, it receives only the delta (created, updated, deleted events). For real-time updates, use long-polling or server-sent events (SSE) to push changes to connected clients.
Q5: How do you handle free/busy lookups for users with millions of events?
Precompute daily free/busy bitmaps for each user. Each day is represented as 96 quarter-hour slots (12 bytes per day). Free/busy queries become simple bitmap lookups — O(1) per day. When events are created or modified, update the affected daily bitmaps asynchronously. For users with dense calendars, this precomputation avoids scanning all events for every free/busy query.
Q6: How would you handle a scenario where the notification service is down?
Notifications are stored in a durable queue (SQS/SNS) before being dispatched. If the dispatch service is down, notifications remain in the queue and are retried when the service recovers. For time-critical notifications (meeting reminders), implement a priority queue that processes reminders with the most urgent send time first. Add a "notification lag" metric and alert if the lag exceeds 60 seconds.
Q7: How do you handle offline edits on mobile and sync when back online?
Use an offline-first architecture with a local SQLite database on the mobile device. All edits are applied locally first (immediate UI response). When connectivity is restored, the client sends changes to the server with the last known sync token. The server detects conflicts using optimistic concurrency (ETags). If a conflict is detected, the client is notified and the user chooses which version to keep. This ensures no data loss while maintaining consistency.