🌐 Language: English Version | 中文版
TL;DR
If you’re building Outlook Calendar integration with EspoCRM, recurring meetings will break your standard delta sync logic. This article presents a production-grade solution using:
- Windowed Expansion: Only sync instances within
[Today - 7d, Today + 90d] - Series Rebuild: When seriesMaster changes, fetch all instances via the Instances API
- Bootstrap State Machine: Handle cold-start with explicit initialization scan
- Critical Warning: Never use
iCalUIdas a unique key for recurring instances
The Problem: Why Delta Sync Fails for Recurring Meetings
Most developers assume Microsoft Graph’s Delta Sync API will notify them of every calendar change. This works fine for single events. For recurring meetings, it breaks down.
Microsoft’s Data Model
In Microsoft Graph API, a recurring meeting isn’t a single record. It’s split into three types:
| Type | Description | Stored in Outlook |
|---|---|---|
| seriesMaster | The template defining recurrence rules (e.g., “every Thursday at 2 PM”) | Physical record |
| occurrence | A specific instance (e.g., “Jan 8, 2026 at 2 PM”) | Virtual, computed from Master |
| exception | A modified instance (e.g., “Just this one, move to 3 PM”) | Physical record |
The Delta Sync Trap
When you modify a seriesMaster (e.g., change “weekly Monday” to “weekly Tuesday”), the Delta API returns only the seriesMaster change. It does NOT return the 20+ occurrence changes for future instances.
Your CRM continues showing meetings on Mondays. Users complain. Debugging reveals no sync errors—the delta data simply never contained those changes.
Common Failure Patterns
| Symptom | Root Cause |
|---|---|
| Missing instances | Only processed seriesMaster, never expanded occurrences |
| Duplicate instances | Used iCalUId as dedupe key (same for entire series) |
| Orphaned instances | Deleted series in Outlook, only Master removed from CRM |
| Time drift | Expanded in UTC without proper timezone handling |
The Solution: Windowed Expansion + Series Rebuild
Core Principle: The Window
Never sync infinite future. Define a “window of interest” such as [Today - 7d, Today + 90d].
This makes any recurring series—even “no end date” ones—produce a constant, bounded number of instances.
gantt
title Window Sliding Over Time
dateFormat YYYY-MM-DD
axisFormat %m/%d
section Window (90d)
Initial window :a1, 2026-01-01, 90d
Slid window :a2, 2026-02-01, 90d
section Series
Weekly instances :crit, 2026-01-01, 180d
Architecture Overview
flowchart TD
Start["Start Sync"] --> GetDelta["Graph API: Delta Sync"]
GetDelta --> Loop{"Process Changes"}
Loop -- "Type=seriesMaster" --> MarkResync["Mark for Rebuild"]
Loop -- "Type=occurrence" --> SyncItem["Sync Instance (No iCalUId Fallback)"]
Loop -- "Type=singleInstance" --> SyncNormal["Standard Sync"]
MarkResync --> TriggerResync["Trigger Series Rebuild"]
subgraph SeriesResync ["Series Rebuild"]
Fetch["Graph API: List Instances (Windowed)"]
Upsert["Bulk Upsert Instances"]
Cleanup["Clean Removed Instances"]
end
Implementation Details
1. The Bootstrap State Machine
Delta Sync is “change-driven,” not “presence-driven.” Meetings created years ago with no recent changes won’t appear in delta results. This causes silent missing data on first sync.
Solution: Track bootstrap state per user calendar:
1 |
|
2. Series Rebuild Strategy
When seriesMaster changes, trigger instance rebuild:
1 |
|
3. The iCalUId Trap (CRITICAL!)
RFC 5545 specifies that ALL occurrences in a recurring series share the same UID.
This means iCalUId is NOT unique for recurring instances. Using it for deduplication will merge all your weekly meetings into one record.
1 |
|
4. Complete Sync Sequence
sequenceDiagram
participant S as Scheduler/Job
participant SS as SyncService
participant DB as Database
participant P as Provider(Outlook/Google)
participant W as WindowExpander
S->>SS: Trigger user sync
SS->>DB: Check bootstrapStatus
alt Status is Pending (Cold Start)
SS->>W: Force Windowed Scan (-7d..+90d)
W->>P: List all instances in window
W->>DB: Initialize Series & Instances
SS->>DB: Update bootstrapStatus = Completed
else Status is Completed (Incremental)
SS->>P: Fetch delta (token/page)
alt Series master changed
SS->>W: Mark series for rebuild
W->>P: List instances (window: -7d..+90d)
W->>DB: Upsert created/updated
W->>DB: Delete window-missing
else Occurrence/Exception changed
SS->>DB: Upsert occurrence (no iCalUId fallback)
end
end
SS-->>S: Report metrics
5. Quota Management
Recurring series generate massive record counts. One user with 10 weekly meetings = ~120 instances per quarter.
Protect against API throttling:
1 |
|
Production Checklist
Before deploying to production, verify:
- DST Handling: Create a weekly meeting spanning DST transition (March/November). Verify times don’t drift
- Exception Preservation: Modify one instance’s time, then change series subject. Verify the modified instance keeps its time
- Deletion Sync: Delete one future instance in Outlook. Confirm CRM removes it. Delete entire series. Confirm all instances are removed
- Long-running Series: Create a monthly meeting for 2 years. Verify only ~90 days are synced
- All-day Events: Create all-day recurring event. Verify it doesn’t span two days due to timezone conversion
- Token Recovery: Simulate delta token expiration. Verify system falls back to full sync
- Logging: All sync operations log (seriesMasterId, instanceId, operation) for debugging
Comparison: Outlook vs Google Calendar
| Aspect | Microsoft Graph (Outlook) | Google Calendar API |
|---|---|---|
| Recurring Model | Master + Exception (occurrences are virtual) | Event + Recurrence (can expand with singleEvents=true) |
| Delta Behavior | Master change returns only Master | Master change can return all affected instances |
| ID Reference | seriesMasterId |
recurringEventId |
| Deletion | Physical delete or status change | Often status: cancelled |
| Complexity | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
Google’s singleEvents=true parameter makes expansion easier, but the windowed approach remains necessary for performance.
Key Takeaways
- Never trust Delta Sync alone for recurring meetings. Use delta as a signal, not as truth.
- Windowed expansion is essential. Infinite series must be bounded to a constant number of instances.
- iCalUId is half-truth. Same for all occurrences—dangerous as a unique key.
- Bootstrap state matters. Explicit initialization scan prevents “cold start” data gaps.
- Log everything. When debugging sync issues, you’ll need the history.
Data Model Reference
erDiagram
OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_SERIES : tracks
OUTLOOK_CALENDAR_USER ||--o{ OUTLOOK_CALENDAR_EVENT : owns
INTERNAL_EVENT ||--o{ OUTLOOK_CALENDAR_EVENT : linked-by
OUTLOOK_CALENDAR_USER {
string id PK
string deltaToken
string bootstrapStatus
}
OUTLOOK_CALENDAR_SERIES {
string id PK
string seriesMasterEventId
boolean resyncRequested
}
OUTLOOK_CALENDAR_EVENT {
string id PK
string eventId "Immutable Outlook ID"
string iCalUId "Shared by Series - NOT unique!"
string eventType "occurrence/master/exception"
string seriesMasterEventId
datetime windowStart
datetime windowEnd
}
INTERNAL_EVENT {
string id PK
string subject
datetime start
datetime end
}
Resources
- Microsoft Graph Delta Query Documentation
- Get incremental changes to events in a calendar view
- Best practices for working with Microsoft Graph
- RFC 5545: iCalendar Specification