Skip to content
CloudOps
Newsletter
All guides
AI for Microsoft Teams By James Joyner IV · · 11 min read

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.

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.

Free download · 368-page PDF

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.