Using Microsoft Graph delta queries to sync changes incrementally in Microsoft 365
Sync only changed users, groups, or messages with Microsoft Graph delta queries: store deltaLinks verbatim, handle tombstones, survive token expiry, with a worked C# example.
03 Oct 2026, 18:36 UTC

The answer up front: sync only what changed
If you keep a local copy of Microsoft 365 users, groups, or messages, you do not need to re-read every object on each sync. Microsoft Graph delta queries return only items created, updated, or deleted since your last call. The first request to an endpoint such as GET /users/delta returns the full set plus an opaque @odata.deltaLink. Store that link and use it as the starting point for the next sync; the response then contains only the changes. This cuts bandwidth, shortens sync windows, and gives you deletions (as tombstones) in the same flow as updates.
How the delta token flow works
A sync cycle has three states:
- First run: call the delta endpoint with no token. You receive all current items, possibly across multiple pages (
@odata.nextLink), and a@odata.deltaLinkon the final page. - Incremental runs: call the stored deltaLink verbatim. You receive only changed items and a new deltaLink to store.
- Token expired: the service rejects the old token (commonly HTTP 410 Gone or a resync-required error, depending on the workload). Discard local state assumptions and do a full resync.
The deltaLink is opaque and URL-encoded. Persist the exact string you received — do not decode it, re-encode it, trim it, or append your own query parameters. Corrupting the link is the most common cause of missed or duplicated changes.
Worked example: incremental user sync in C#
The example below uses the Microsoft Graph .NET SDK (v5.x generation) with an app registration that has the application permission User.Read.All, admin-consented. Run it from any .NET 6+ host (console app, worker service, Azure Function). Replace the placeholder tenant ID, client ID, and secret; in production, load the secret from a vault rather than source code.
using Azure.Identity;
using Microsoft.Graph;
using Microsoft.Graph.Models;
// Persisted between runs — store the EXACT string, e.g. in a database row.
string? deltaLink = await LoadStoredDeltaLinkAsync();
var credential = new ClientSecretCredential(
"", "", "");
var graphClient = new GraphServiceClient(credential);
// First page: either the delta endpoint or the stored deltaLink.
var response = deltaLink is null
? await graphClient.Users.Delta.GetAsync()
: await graphClient.Users.Delta.WithUrl(deltaLink).GetAsync();
while (response?.Value != null)
{
foreach (var user in response.Value)
{
if (user.AdditionalData != null &&
user.AdditionalData.ContainsKey("@removed"))
{
// Tombstone: the user was deleted (or soft-deleted).
DeleteLocalUser(user.Id);
}
else
{
UpsertLocalUser(user);
}
}
if (response.OdataNextLink is string next)
{
// More pages in this sync cycle.
response = await graphClient.Users.Delta.WithUrl(next).GetAsync();
}
else
{
// Final page: persist the new delta link for the next cycle.
if (response.OdataDeltaLink is string fresh)
await StoreDeltaLinkAsync(fresh);
break;
}
}
Key points in this flow: WithUrl passes the stored link back unchanged; tombstones surface as an @removed entry in AdditionalData, so every sync loop must branch on it; and the new deltaLink is saved only after the last page is processed, so a crash mid-cycle simply replays the previous cycle.
Handling token expiration
Delta tokens expire after a service-defined period that varies by workload (often days, but do not hardcode a number). Wrap the sync in a catch that detects the resync-required failure — typically a 410 Gone or a ServiceException whose error code indicates the token is stale — and restart from a null deltaLink:
try
{
await RunDeltaSyncAsync();
}
catch (ServiceException ex) when (ex.ResponseStatusCode == 410)
{
await StoreDeltaLinkAsync(null); // force full resync next run
await RunDeltaSyncAsync();
}
Because the exact error shape differs between resources, log the status code and error body the first time you hit it in a test tenant and confirm your catch condition matches.
Limits and common mistakes
- Resource coverage: delta is well established for users, groups, messages, and calendar events, but not every Graph resource supports it, and support differs between v1.0 and beta. Check the documentation for your specific entity before designing around incremental sync.
- Narrower query options:
$filter,$select, and$expandsupport on delta endpoints is more limited than on regular list endpoints. Unsupported options may be rejected or ignored depending on the resource — verify behavior rather than assuming parity. - Throttling still applies: delta responses consume the normal Graph throttling budget. For large tenants, page with
$top, honorRetry-Afterheaders, and schedule syncs instead of polling in a tight loop. - Ignoring tombstones: treating every returned entity as active leaves deleted users in your local store forever. Always branch on
@removed. - Saving the token too early: if you persist the deltaLink before processing all pages, a mid-cycle crash loses changes. Save it only after the final page is handled.
How to verify your sync works
- In a test tenant, create a user. Call
/users/deltaand store the returned deltaLink. - Modify the user (for example, change the display name), then call the stored link. Confirm the response contains only that user.
- Delete the user and call the link again. Confirm the response contains an item with an
@removedannotation and that your local store removes it. - Confirm the new deltaLink differs from the previous one and that repeating the call with the newest link returns an empty change set.
If all four checks pass, the incremental pattern is sound. Before production, add retry logic for throttling, secure storage for the client secret, and monitoring for repeated full resyncs, which usually indicate the sync interval exceeds the token lifetime.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.