Microsoft Graph Delta Queries for Incremental Teams Sync
Stop re-fetching every user and team on each run. Graph delta queries return only what changed since last time, cutting throttling and runtime dramatically.
- #microsoft-teams
- #microsoft-graph
- #delta-query
- #sync
- #automation
I once wrote a nightly job that synced our Teams membership into an internal access-control database. It worked by listing every team, every channel, and every member, every single night. For a few dozen teams that was fine. By the time we had a few hundred, the job took forty minutes, hammered Graph hard enough to trip throttling, and re-processed thousands of records that had not changed since the dinosaurs. Delta queries fixed all of it.
A delta query asks Graph a simple question: “what changed since the last time I asked?” Instead of a full enumeration, you get only additions, deletions, and modifications. This guide covers how delta tokens work, how to persist and resume them, and the gotchas that bite people. I scaffolded the token-handling loop with an AI assistant — it is good at the pagination boilerplate — but I want to set expectations: the model will write a loop that loses your delta token on the first error, and it will not handle the @removed annotation correctly unless you make it. Treat AI like a fast junior engineer, review the state-handling logic yourself, and never give the model real tenant tokens.
How delta works: deltaLink and nextLink
You start a delta cycle by calling the /delta endpoint for a resource that supports it — users, groups, and several others:
GET https://graph.microsoft.com/v1.0/users/delta?$select=displayName,mail,accountEnabled
Graph returns a page of results. If there are more pages, the response includes an @odata.nextLink. You follow nextLinks until you reach the final page, which instead carries an @odata.deltaLink. That deltaLink contains an opaque $deltatoken. You persist it. Next run, you call the deltaLink instead of the base endpoint, and Graph returns only what changed since you got that token.
async function runDeltaSync(startUrl, getJson, persistDeltaLink) {
let url = startUrl; // either the base /delta or a saved deltaLink
const changes = [];
while (url) {
const page = await getJson(url);
changes.push(...page.value);
if (page["@odata.nextLink"]) {
url = page["@odata.nextLink"]; // more pages, same cycle
} else {
// Final page: save the deltaLink for next run, then stop.
await persistDeltaLink(page["@odata.deltaLink"]);
url = null;
}
}
return changes;
}
The critical rule: only persist the deltaLink from the final page, after you have successfully processed every page. If you save it early and crash mid-cycle, you will skip the changes on the pages you never processed. They are gone — the next delta starts from the saved token forward.
Handling deletions: the @removed annotation
This is the part AI-generated code almost always gets wrong. When a user is deleted or a member leaves, the delta response does not omit them — it returns the object with an @removed annotation:
{
"id": "8a7b...",
"@removed": { "reason": "deleted" }
}
Your sync logic has to branch on this. An object with @removed means “delete this from my local store,” not “update it.” If you treat every item in value as an upsert, deleted users linger in your access database forever — a real security problem for an access-control sync.
for (const item of changes) {
if (item["@removed"]) {
await localStore.delete(item.id);
} else {
await localStore.upsert(item);
}
}
Pro Tip: The reason in @removed can be "deleted" or "changed". A "changed" removal usually means the object fell out of scope of your query (for example, the user no longer matches a $filter), not that it was hard-deleted. For an access sync, both should remove local access, but if you are mirroring a directory you may want to treat them differently.
Persisting tokens safely
The delta token is your sync state, so treat it like state, not like a cache you can lose. Store it in a durable place keyed by resource and query shape — if you change the $select or $filter, your old token is invalid and Graph returns a 410 Gone. When that happens, the recovery is to drop the token and do one full re-sync from the base /delta endpoint.
async function getJson(url, token) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 410) {
// Token expired or query changed — full resync required.
await clearSavedDeltaLink();
throw new ResyncRequiredError();
}
return res.json();
}
Catch ResyncRequiredError at the top of your job, clear state, and re-run from the base endpoint. Do not retry the same dead token in a loop — that is a common bug that turns one bad token into an infinite retry storm.
Pairing delta with change notifications
Delta queries and Graph change notifications solve adjacent problems. Notifications give you near-real-time pushes but can be missed; delta gives you a reliable “catch up on everything since X” pull. The robust pattern is both: react to notifications for low latency, and run a delta sync on a schedule as the reconciliation backstop that guarantees you never permanently miss a change. If a notification is dropped, the next delta cycle picks up the change anyway.
For monitoring whether the sync is keeping up, I surface delta cycle duration and change counts the same way I surface other signals on the monitoring-alerts dashboard, so a job that suddenly processes zero changes for days gets noticed.
Letting AI write the loop, then reviewing it
The pagination-and-token loop is repetitive enough that I let Claude or GitHub Copilot draft it, then I review three things by hand: that the deltaLink is saved only after full success, that @removed is handled, and that a 410 triggers a clean resync rather than a retry loop. The starter prompts in my prompts library include a delta-sync skeleton, and I refine the edge cases in the prompt workspace. The model accelerates the boilerplate; the state-correctness review is on you.
Conclusion
Delta queries turn an O(everything) nightly sweep into an O(what-changed) increment, cutting runtime and throttling dramatically. The discipline is in the state handling: persist the deltaLink only after processing every page, branch on @removed, and treat a 410 as a clean full-resync signal. Let AI write the loop, review the correctness yourself, and keep tenant tokens out of the prompt. More Graph automation lives in the Microsoft Teams category.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.