Microsoft Graph for Microsoft 365 Developers: A Practical Guide

22 min read

A practical guide to Microsoft Graph for Microsoft 365 developers covering what it is, key APIs, authentication, SDKs, webhooks, delta queries, and how to call it from SPFx, Teams apps, and Azure Functions.


If you’ve spent any time building on Microsoft 365 (M365), you’ve hit Microsoft Graph (MSGraph). And if you’ve hit it, you’ve probably also hit the wall of trying to figure out how to call it from your specific scenario.

Microsoft has done real work to make MSGraph the unified front door for every piece of data in M365. What they haven’t done is put that guidance in one place. Their docs scatter MSGraph across a dozen product-team landing pages. Search for “call Microsoft Graph from my app” and you’ll land on a Microsoft Learn page, a GitHub sample, a blog post from a field engineer, and a decommissioned tutorial, all in the same afternoon.

I’m not going to fix Microsoft’s documentation. But I can give you the map.

This guide orients you on what MSGraph actually is, the APIs that matter, the authentication model, a brief take on the SDKs, and the three deployment patterns most M365 developers run into: the SharePoint Framework (SPFx), Microsoft Teams (MSTeams) apps, and Azure Functions. I’ll also cover the production trio you’ll eventually need: webhooks, delta queries, and batching with throttling.

What this guide won’t do is replace the official reference.

Diagram of Microsoft Graph as the unified gateway to Microsoft 365: a central https://graph.microsoft.com node connected to the services it fronts — Users, Groups, Mail, Calendar, SharePoint, OneDrive, Microsoft Teams, and Planner & To Do — with every call carrying a single Microsoft Entra ID token, either delegated or application permissions

Microsoft Graph is the gateway

One REST endpoint and one Microsoft Entra ID token in front of every Microsoft 365 service.

What is Microsoft Graph?

Microsoft Graph is a single REST API that fronts almost everything in M365. That means you just have one single endpoint and authentication model to get all details about something in your tenant, whether you want the signed-in user’s profile, an email, a file in OneDrive, a Teams channel message, or a Planner task.

It wasn’t always like this. Before MSGraph, every product had its own API surface: SharePoint had the SharePoint REST API and CSOM, Exchange had Exchange Web Services (EWS), Azure Active Directory had Azure AD Graph (confusingly named), Skype had its own thing, and so on. Each one had its own auth story, its own SDK, and its own quirks. Building anything cross-product was a research project before a coding project.

MSGraph collapsed all of that into one endpoint: https://graph.microsoft.com. That’s the mental model I want you to leave this section with. If it’s data in M365, you reach it through Graph.

1GET https://graph.microsoft.com/v1.0/me
2Authorization: Bearer <access-token>

There are two versions exposed: v1.0 and beta. The v1.0 endpoint is production-stable; the contract won’t change out from under you… at least it isn’t supposed to, but like all cloud services, things get deprecated over time so keep an eye on the M365 Message Center announcements. The beta endpoint is where Microsoft ships new features first, and it’s tempting because some APIs live there for a long time before they graduate.

Just be aware that anything under /beta can change with no notice. If you ship a dependency on /beta to production, that’s a decision, not a default. Code defensively (isolate the beta call, version-check the response shape, fail gracefully), and make sure you have good monitoring and telemetry so you hear about breakage before your users do.

Before-and-after diagram. On the left, four separate product APIs — SharePoint REST and CSOM, Exchange Web Services, Azure AD Graph, and Skype UCWA — each labelled with its own auth and its own SDK, connecting to an app through tangled crossing lines. On the right, a single Microsoft Graph endpoint at https://graph.microsoft.com connecting to the same app through one clean line

Four APIs became one

Every Microsoft 365 product used to ship its own API, its own auth story, and its own SDK. Microsoft Graph collapsed all of it into a single endpoint.

Key APIs and resources you’ll actually use

Microsoft’s docs organize MSGraph by product team. That makes sense for the people who own the content and almost no sense for the developer building a feature. Let me reorganize it by what you actually build.

Group the resources into four buckets.

People and identity. /users, /me, and /groups. The starting point for almost everything. Who is the signed-in user, who’s in what group, what’s their manager, what department are they in.

Communication. /messages for mail, /events for calendar, /chats and /teams/{id}/channels for Teams conversations. The “send something to someone” endpoints.

Content. /drives for OneDrive and document libraries, /sites for SharePoint sites, /lists for SharePoint lists. The “read and write documents” endpoints.

Work coordination. /planner and /todo. Task and plan management, which trips people up because Planner is for shared team plans and To Do is for personal lists, and MSGraph surfaces them through different resources.

Each cluster has its own quirks. The SharePoint resources in MSGraph don’t have full parity with the SharePoint REST API yet, so if you need advanced list schema manipulation you’ll still hit the SharePoint API directly. The Teams messaging resources require extra consent because Microsoft treats chat content as sensitive. The calendar resources have some of the weirdest time-zone handling in the product. Watch for those before you assume something is broken.

Two-column table mapping common scenarios to Microsoft Graph endpoints: read the signed-in user's profile is GET /me; list recent files is GET /me/drive/recent; read the inbox is GET /me/messages; send mail is POST /me/sendMail; create a calendar event is POST /me/events; get group members is GET /groups/{id}/members; send a Teams channel message is POST /teams/{id}/channels/{id}/messages; read SharePoint list items is GET /sites/{id}/lists/{id}/items; create a Planner task is POST /planner/tasks; keep a local user cache in sync is GET /users/delta

Scenario to endpoint

The ten things Microsoft 365 developers reach for most, and the endpoint that does each one.

Once you know what endpoint to call, the next thing worth learning is how to shape the response. Microsoft Graph supports the standard OData query parameters: $select to pick only the fields you want, $filter to narrow results, $expand to pull related entities in a single call, $top and $skip for paging. Using $select alone will cut your payload sizes by 80 percent or more in most scenarios, which matters for mobile clients and matters even more for avoiding throttling.

1GET https://graph.microsoft.com/v1.0/users?$select=displayName,mail,department&$filter=department eq 'Engineering'&$top=25
2Authorization: Bearer <access-token>
  • $select returns only the three fields you asked for instead of the full user object — the single biggest payload win.
  • $filter narrows server-side, so you’re not paging through the whole directory to find one department.
  • $top caps the page size; follow the @odata.nextLink in the response for the next page.

Authentication for Microsoft Graph

This is where most people get stuck the first time. Not because it’s conceptually hard, but because Microsoft’s docs assume you already know the difference between an app registration and an enterprise application, and the permission names are inconsistent enough (Mail.Read vs User.Read.All) that you can read three pages and still be unsure what to click.

Every MSGraph call needs an OAuth 2.0 access token issued by Microsoft Entra ID (Entra ID, formerly called Azure Active Directory (AD)). The interesting question is what kind of token. You have two choices, and the choice is driven by one question: is there a user signed in?

If yes, use delegated permissions. The app acts as the signed-in user. The token is scoped to what that user can access. If the user can read their own mail, the app (acting on their behalf) can read their mail. If they can’t, it can’t.

If no, use application permissions. The app acts as itself. This is for daemons, background jobs, timer-triggered Azure Functions, anything that runs without a user in front of the screen. Application permissions are broader (often tenant-wide) and always require admin consent.

That’s the decision, and I’d argue it’s the single most important mental model in all of MSGraph. Once you have it, everything else (MSAL, consent flow, app registrations) becomes implementation detail.

The Microsoft Authentication Library (MSAL) is the library family that handles the token dance: MSAL.js for browsers, MSAL Node for server-side JavaScript, MSAL .NET, MSAL Python, MSAL Java, MSAL Go. The API surface is similar across languages. Acquire a token, attach it as a bearer header, call MSGraph.

Decision tree starting from the question is there a user signed in. Yes leads to delegated permissions, where the app acts as the signed-in user, then to a second question about how privileged the scope is, branching to user consent for scopes like User.Read and Mail.Read, or admin consent for scopes like User.Read.All. No leads to application permissions, where the app acts as itself, which always requires admin consent

Which permission type, and who consents

One question picks the permission type. A second question picks who has to approve it.

 1// (a) Application permissions — no user, MSAL Node
 2import { ConfidentialClientApplication } from '@azure/msal-node';
 3
 4const cca = new ConfidentialClientApplication({
 5  auth: {
 6    clientId: process.env.CLIENT_ID!,
 7    authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
 8    clientSecret: process.env.CLIENT_SECRET!,
 9  },
10});
11
12const { accessToken } = await cca.acquireTokenForClient({
13  scopes: ['https://graph.microsoft.com/.default'],
14});
 1// (b) Delegated permissions — user signed in, MSAL.js
 2import { PublicClientApplication } from '@azure/msal-browser';
 3
 4const pca = new PublicClientApplication({
 5  auth: { clientId: '<client-id>', authority: 'https://login.microsoftonline.com/<tenant-id>' },
 6});
 7await pca.initialize();
 8
 9const request = { scopes: ['User.Read', 'Files.Read'] };
10const result = await pca
11  .acquireTokenSilent(request)
12  .catch(() => pca.loginPopup(request)); // fall back to interactive
13const accessToken = result.accessToken;

A common mistake I see over and over: requesting the .default scope when you actually need granular consent. The .default scope tells Entra ID “give me a token for whatever I’ve pre-configured in the app registration,” which is fine for application permissions on a backend, and a footgun for delegated scenarios where you want incremental consent as features get used. If you find yourself using .default on the client side, stop and ask whether you should be requesting specific scopes instead.

Checkpoint. Before you move on, you should be able to answer two questions for your scenario:

  1. Which permission type do I need, delegated or application?
  2. Who consents to it, the end user or an admin?

If either answer is unclear, the rest of the article won’t land.

If you want a deep dive into the auth model from an SPFx angle, I walk through about three hours of it in my Mastering the SharePoint Framework course, where the whole sprint is MSGraph and Entra ID.

Microsoft Graph SDKs: a light take

There are official SDKs for JavaScript/TypeScript, .NET, Python, Java, Go, and PHP. Each is a thin-to-medium wrapper over the REST API, plus a retry handler, plus token plumbing.

Here’s my honest, short take: the SDKs are fine for most cases, and you should usually reach for one. Pick by language. If you’re in SPFx, skip the standalone JS SDK and use the MSGraphClientV3 that SPFx bundles for you. If you’re doing a one-off script, raw fetch or requests is faster than fighting the SDK abstraction. Everything else, use the SDK that matches your stack.

The places where I’ve stopped reaching for the SDK are narrow: when I need to pin to a specific API version the SDK hasn’t typed yet, when bundle size matters (SPFx web parts), and when the fluent builder is fighting the query I actually want to write. If none of those apply, the SDK saves you time.

Comparison table of the official Microsoft Graph SDKs with columns for language, package, maturity, and key gotcha. JavaScript and TypeScript use @microsoft/microsoft-graph-client and are mature, but in SPFx you should use the bundled MSGraphClientV3 instead. .NET uses Microsoft.Graph and is mature, with v5 request builders being a real rewrite from v4. Python uses msgraph-sdk and is async-first. Java uses com.microsoft.graph:microsoft-graph with verbose builders and Android bundle size concerns. Go uses microsoftgraph/msgraph-sdk-go, a newer generated surface thin on docs. PHP uses microsoft/microsoft-graph and is the thinnest, trailing the others

The official SDKs at a glance

Six languages, one REST API underneath. Pick by stack, and know the gotcha before you commit.

Calling Microsoft Graph from SPFx

SPFx is where most readers here live, so this section gets the most depth. It’s also the easiest of the three deployment patterns, because SharePoint does the token management for you.

The client you want is MSGraphClientV3, available from the web part context’s msGraphClientFactory. You don’t manage tokens. You don’t call MSAL. You ask the factory for a client, it hands you one with a valid MSGraph token already attached, and you make calls. That’s it.

The trade-off is that the permissions your web part uses have to be declared up front in package-solution.json, in the webApiPermissionRequests block. When a tenant admin deploys your solution package, those permission requests show up as pending approvals in the SharePoint admin center’s API access page. Until an admin approves them, your web part can run, but MSGraph calls will fail with an unauthorized response.

That approval step is not Microsoft being annoying. It’s governance. A web part can be installed on hundreds of sites by hundreds of people, and MSGraph permissions are tenant-wide, not site-scoped. If a site owner could grant Mail.Read by installing a web part, we’d have a problem. The admin-approval gate is the whole point.

One quick pragmatic-critique beat: the naming. MSGraphClientV3. Why V3? Because V1 and V2 existed before it and got deprecated, and Microsoft decided the version number should live in the class name forever. We could have just called it MSGraphClient. Anyway.

1{
2  "solution": {
3    "webApiPermissionRequests": [
4      { "resource": "Microsoft Graph", "scope": "User.Read" },
5      { "resource": "Microsoft Graph", "scope": "Files.Read" }
6    ]
7  }
8}

resource is always the friendly name Microsoft Graph (not the endpoint URL), and each scope is one delegated permission. These are requests, not grants — they sit as pending approvals in the SharePoint admin center until an admin approves them tenant-wide.

 1import { MSGraphClientV3 } from '@microsoft/sp-http';
 2
 3export default class RecentFilesWebPart extends BaseClientSideWebPart<IProps> {
 4  private graphClient: MSGraphClientV3;
 5
 6  protected async onInit(): Promise<void> {
 7    // The factory hands back a client with a valid Graph token already attached.
 8    this.graphClient = await this.context.msGraphClientFactory.getClient('3');
 9  }
10
11  public render(): void {
12    this.graphClient
13      .api('/me/drive/recent')
14      .top(5)
15      .get()
16      .then((response: { value: { name: string }[] }) => {
17        const items = response.value.map((f) => `<li>${f.name}</li>`).join('');
18        this.domElement.innerHTML = `<ul>${items}</ul>`;
19      });
20  }
21}

Heads up: the permissions you declare here are tenant-wide. Don’t request Mail.ReadWrite if your web part only needs to read the signed-in user’s display name. Admins have long memories and shorter patience for over-permissioned solutions.

New to the SharePoint Framework?

If you’re new to SPFx, start with the SPFx 5W1H overview first, then come back.

Calling Microsoft Graph from Microsoft Teams apps

MSTeams apps that talk to MSGraph use single sign-on (SSO) plus the on-behalf-of (OBO) flow. That sentence will make sense in about two minutes.

The UX promise of MSTeams apps is that the user signed into MSTeams once, so they should never have to sign in again when your tab loads. The mechanism that delivers that promise is SSO. Your tab asks the Teams JavaScript SDK for a token, and Teams hands one back that’s scoped to your app, not to MSGraph. That token proves the user’s identity to your backend.

Your backend then does the interesting part. It takes that user token and exchanges it with Entra ID for an MSGraph token using the on-behalf-of flow. The result is a token that lets your backend call MSGraph as the signed-in user, without ever asking the user to log in again.

That exchange has to happen on the backend. You cannot do the OBO exchange from the browser, because it requires a client credential (a secret or, better, a certificate) that has no business being anywhere near a browser.

So the shape of a working MSTeams-to-MSGraph app is: client tab → your backend API (Azure Function or web API) → Entra ID (OBO exchange) → MSGraph → response back.

--- title: Microsoft Teams SSO and on-behalf-of flow to Microsoft Graph --- %%{init: {'themeVariables': {'noteBkgColor': '#9ca3af', 'noteTextColor': '#111827', 'noteBorderColor': '#6b7280'}}}%% sequenceDiagram autonumber participant User participant Teams as MSTeams client participant Tab as Your tab participant API as Your backend<br/>(Azure Function) participant Entra as Microsoft Entra ID participant Graph as Microsoft Graph note over User,Graph: The user already signed in to MSTeams — no second prompt User->>Teams: open your tab Teams->>Tab: load the tab in an iframe Tab->>Teams: authentication.getAuthToken() Teams-->>Tab: SSO token, scoped to YOUR app<br/>(not to Microsoft Graph) Tab->>API: call your API with that token note over API,Entra: The OBO exchange — backend only, never the browser rect rgba(217, 98, 0, 0.15) API->>Entra: acquireTokenOnBehalfOf(ssoToken)<br/>+ client credential Entra-->>API: Microsoft Graph access token,<br/>issued as the signed-in user end API->>Graph: GET /me/drive/recent Graph-->>API: the user's files API-->>Tab: rendered result
1// 1. Client (tab): get an SSO token scoped to YOUR app, then send it to your backend.
2import { authentication } from '@microsoft/teams-js';
3
4const ssoToken = await authentication.getAuthToken();
5await fetch('/api/recent-files', { headers: { Authorization: `Bearer ${ssoToken}` } });
 1// 2. Backend: exchange the SSO token for a Graph token (on-behalf-of flow).
 2import { ConfidentialClientApplication } from '@azure/msal-node';
 3
 4const cca = new ConfidentialClientApplication({
 5  auth: { clientId, authority, clientSecret }, // client credential stays server-side
 6});
 7
 8const { accessToken } = await cca.acquireTokenOnBehalfOf({
 9  oboAssertion: ssoToken,
10  scopes: ['https://graph.microsoft.com/Files.Read'],
11});
1// 3. Backend: call Graph as the signed-in user with the exchanged token.
2const res = await fetch('https://graph.microsoft.com/v1.0/me/drive/recent', {
3  headers: { Authorization: `Bearer ${accessToken}` },
4});
5const recent = await res.json();

One pragmatic-critique beat on the tooling: the Microsoft 365 Agents Toolkit (ATK, formerly Teams Toolkit) has re-scaffolded its project templates more times than I can count in the last two years. The patterns above are stable; the way ATK generates them has moved around. If you’re following a tutorial, check the publication date before assuming the file layout still matches.

Microsoft Teams SSO and the On-Behalf-Of Flow with Microsoft Graph

A practical, end-to-end guide to single sign-on for Microsoft Teams apps that call Microsoft Graph. Covers the on-behalf-of (OBO) flow, Microsoft Entra ID app registration, the Teams manifest, the client-side token request, the backend token exchange, and the failure modes that make this flow hard to debug the first time.

andrewconnell.com/articles/microsoft-teams-sso-on-behalf-of-flow-microsoft-graph

Calling Microsoft Graph from Azure Functions

When there’s no user in front of the screen (timer-triggered sync, webhook handler, queue-triggered processor), you’re in application-permissions territory. This is where the client credentials flow earns its keep.

The pattern is short: your Function presents its app ID and a credential (a client secret for dev, a certificate for production) to Entra ID, gets a token back, and calls MSGraph. No user in the loop. No consent at runtime. The admin consent was granted once when the app registration was configured, and the permissions are baked in.

Three Function shapes show up constantly. A timer trigger that syncs something from M365 into a downstream system (SQL, Cosmos, a reporting warehouse). An HTTP trigger that fronts an MSGraph webhook endpoint (more on that below). And a queue trigger that processes change notifications asynchronously so the webhook handler itself stays fast.

 1import { app, InvocationContext, Timer } from '@azure/functions';
 2import { ConfidentialClientApplication } from '@azure/msal-node';
 3
 4const cca = new ConfidentialClientApplication({
 5  auth: {
 6    clientId: process.env.CLIENT_ID!,
 7    authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
 8    clientSecret: process.env.CLIENT_SECRET!,
 9  },
10});
11
12export async function syncUsers(_timer: Timer, context: InvocationContext): Promise<void> {
13  const { accessToken } = await cca.acquireTokenForClient({
14    scopes: ['https://graph.microsoft.com/.default'], // app permissions, e.g. User.Read.All
15  });
16
17  const res = await fetch('https://graph.microsoft.com/v1.0/users?$select=id&$top=999', {
18    headers: { Authorization: `Bearer ${accessToken}` },
19  });
20  const data = await res.json();
21  context.log(`Retrieved ${data.value.length} users`);
22}
23
24// Every 30 minutes.
25app.timer('syncUsers', { schedule: '0 */30 * * * *', handler: syncUsers });
 1{
 2  "IsEncrypted": false,
 3  "Values": {
 4    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
 5    "FUNCTIONS_WORKER_RUNTIME": "node",
 6    "TENANT_ID": "<your-tenant-id>",
 7    "CLIENT_ID": "<your-app-client-id>",
 8    "CLIENT_SECRET": "<your-client-secret>"
 9  }
10}
Architecture diagram of three boxes. An Azure Function running on a timer, HTTP, or queue trigger presents its app ID and certificate to Microsoft Entra ID, which returns an app-only token via the client credentials flow. The Function then calls Microsoft Graph with that bearer token using application permissions. A badge notes that no user is signed in, so there is nothing to consent to at runtime

The client credentials flow

App permissions, no user, no runtime consent. The admin approved it once, when the app registration was configured.

Use certificate auth in production. Client secrets are fine for local development and for quick proofs of concept. In production, certificates win on rotation, compliance, and audit every time. Storing the cert in Azure Key Vault and having the Function load it at startup is more infrastructure than it feels like it should be, but it’s the right pattern.

Webhooks and change notifications

Polling MSGraph every five minutes to see if an inbox changed is expensive, slow, and a great way to get throttled. Webhooks flip the relationship: you tell MSGraph what you’re interested in, and MSGraph calls you when something changes.

The lifecycle has four steps, and missing any of them breaks the whole thing.

First, create a subscription by POSTing to /subscriptions with the resource you care about, the change types you want, your notification URL, and an expiration time. Second, validate your endpoint: MSGraph immediately sends a validation token to the URL you provided, and your endpoint has to echo that token back, as plain text, within 10 seconds. If you miss the 10-second window, MSGraph considers the URL unreachable and your subscription dies before it’s born. Third, receive notifications as POSTs to your endpoint whenever the subscribed resource changes. Fourth, renew the subscription before it expires, because every subscription has a max lifetime that varies by resource.

The single biggest gotcha is that notifications don’t carry the changed data. They tell you which resource changed and give you enough identity to go fetch it. This is by design (webhook payloads go over the public internet, so they’re intentionally light), but it means your handler needs a token and a separate MSGraph call to do real work. That’s where delta queries come in handy, which we’ll hit next.

Four-step lifecycle diagram. Step one, create: POST the resource, change types, notification URL, and expiry to /subscriptions. Step two, validate: Microsoft Graph immediately pings your URL and you must echo the token back as plain text within 10 seconds or the subscription dies. Step three, receive: notifications POST in on every change but carry no data, so you return 202 fast and queue the work. Step four, highlighted in orange, renew: PATCH the subscription before it expires, looping back to the receive step forever

The subscription lifecycle

Four steps, and the fourth one is a loop you have to keep running. Miss it and the subscription quietly stops delivering.

 1### (a) Create the subscription
 2POST https://graph.microsoft.com/v1.0/subscriptions
 3Authorization: Bearer <access-token>
 4Content-Type: application/json
 5
 6{
 7  "changeType": "created,updated",
 8  "notificationUrl": "https://your-func.azurewebsites.net/api/notifications",
 9  "resource": "/me/messages",
10  "expirationDateTime": "2026-04-15T18:23:00Z",
11  "clientState": "secretClientValue"
12}
1// (b) Validation: echo the token back as text/plain within 10 seconds, or the subscription dies.
2export async function notifications(req: HttpRequest): Promise<HttpResponseInit> {
3  const validationToken = req.query.get('validationToken');
4  if (validationToken) {
5    return { status: 200, headers: { 'Content-Type': 'text/plain' }, body: validationToken };
6  }
7  // ...otherwise queue the change notifications from req.body and return fast.
8  return { status: 202 };
9}
1### (c) Renew before expiry
2PATCH https://graph.microsoft.com/v1.0/subscriptions/{subscription-id}
3Authorization: Bearer <access-token>
4Content-Type: application/json
5
6{ "expirationDateTime": "2026-04-16T18:23:00Z" }

I have a deep dive on webhooks and delta queries in production that walks through every gotcha I’ve hit, including the renewal pattern, the validation timeout, and why a queue-triggered Function is almost always the right handler shape.

Delta queries

Delta queries solve a specific problem: you have a local cache of something in M365 (users, files, messages), and you want to keep it in sync without re-downloading the world every time.

The flow is simple. You call the delta endpoint (for example, /users/delta). MSGraph returns the full current state plus a @odata.deltaLink in the response. You store that deltaLink. Next time you want to sync, you call the deltaLink directly; MSGraph returns only what changed since you got the token, plus a new deltaLink for next time.

That’s it. No timestamps to manage, no per-resource change-tracking logic to write. MSGraph handles the hard part.

Delta pairs beautifully with webhooks. The webhook tells you “something changed in this resource.” The delta call tells you “and here’s exactly what changed.” Together they’re the backbone of most M365 sync scenarios.

Not every resource supports delta. Check the resource’s reference page before you architect around it.

 1// Initial sync: page through the full current state.
 2let url = 'https://graph.microsoft.com/v1.0/users/delta?$select=displayName,mail';
 3let deltaLink = '';
 4
 5while (url) {
 6  const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
 7  const page = await res.json();
 8  upsertUsers(page.value);                       // apply changes to your local cache
 9  url = page['@odata.nextLink'];                 // more pages in this sync
10  if (page['@odata.deltaLink']) deltaLink = page['@odata.deltaLink'];
11}
12
13// Persist the deltaLink between runs — it's the cursor that makes the NEXT
14// call return only what changed, instead of re-downloading everything.
15await saveCursor(deltaLink);
16
17// Next run: start from the stored deltaLink, not the delta root.
18const changes = await fetch(await loadCursor(), {
19  headers: { Authorization: `Bearer ${accessToken}` },
20});
Four-step sync flow. Initial sync calls the delta root, GET /users/delta, and pages through the full current state. The last page carries an @odata.deltaLink cursor that you persist between runs. On the next run you call the stored deltaLink instead of the delta root. Microsoft Graph returns only the added, updated, and deleted items, plus a fresh cursor, and the cycle repeats on every run after the first

How a delta sync stays cheap

You pay the full download once. After that the cursor does the work, and every run costs only what actually changed.

Batching and throttling

You will get throttled. MSGraph enforces per-app, per-resource, and per-tenant limits, and it does not publish the exact numbers. If you’re building anything real, design for 429 responses from day one.

The two tools that matter are batching on the way out, and honoring Retry-After on the way back.

Batching packs up to 20 individual requests into a single POST /$batch call. For fan-out scenarios (pull a user’s profile, their recent files, and their calendar for today in one logical operation), batching cuts latency dramatically and reduces the number of requests that count against your throttling budget.

When you do get throttled, MSGraph returns a 429 (or sometimes a 503) with a Retry-After header indicating how long to wait. Honor it. Don’t retry immediately, don’t back off for less than the header says, and don’t back off for much more. Wrap the whole thing in an exponential-backoff-with-jitter handler so a swarm of retries doesn’t synchronize into another 429.

The design-level answer is to avoid throttling in the first place. Use $select to cut payload sizes. Use delta so you re-read only what changed. Use $batch to combine requests. Cache aggressively when the data allows.

 1POST https://graph.microsoft.com/v1.0/$batch
 2Authorization: Bearer <access-token>
 3Content-Type: application/json
 4
 5{
 6  "requests": [
 7    { "id": "1", "method": "GET", "url": "/me" },
 8    { "id": "2", "method": "GET", "url": "/me/messages?$top=5" },
 9    { "id": "3", "method": "GET", "url": "/me/drive/recent" }
10  ]
11}
 1// Honor Retry-After on 429/503, with jitter so retries don't synchronize.
 2const jitter = (ms: number) => ms + Math.floor(Math.random() * 1000);
 3
 4async function callWithRetry(url: string, token: string, attempt = 0): Promise<Response> {
 5  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
 6  if (res.status !== 429 && res.status !== 503) return res;
 7
 8  const retryAfter = Number(res.headers.get('Retry-After')); // seconds
 9  const waitMs = retryAfter ? retryAfter * 1000 : 2 ** attempt * 1000; // fallback: exp backoff
10  await new Promise((r) => setTimeout(r, jitter(waitMs)));
11  return callWithRetry(url, token, attempt + 1);
12}
Comparison of round trips. The top row shows 20 separate calls as 20 individual blocks, costing 20 round trips and 20 requests against your throttling budget. The bottom row shows the same 20 operations packed into a single $batch call as one highlighted block, costing 1 round trip and 1 request against your throttling budget

Twenty round trips, or one

Each block is one HTTP round trip, not a latency measurement. The win is that 20 requests against your throttling budget become 1.

If you’re getting throttled in development, you will be crushed in production. Fix the access pattern before you ship.

The fastest way to learn MSGraph is to run real queries against it. Graph Explorer (aka.ms/ge) is an in-browser playground where you sign in with your tenant, pick a query from the catalog, see the real response, and modify it in place. Every developer I’ve worked with who got good at MSGraph spent time here first, before writing a single line of app code.

Use a sandbox tenant. Poking production from a query tool you’re still learning is a bad idea.

Microsoft Graph Explorer: testing the `/me` endpoint

Microsoft Graph Explorer: testing the `/me` endpoint

Where to go next depends on what you’re building.

If SPFx is your delivery vehicle, start with What is the SharePoint Framework (SPFx)? for the foundation, then come back to the SPFx section above for the MSGraph specifics.

If you’re building Copilot agents that need M365 data, the comparison between declarative agents and Copilot Studio knowledge connectors will save you a wrong turn.

If you’re modernizing SharePoint Add-ins and trying to figure out where MSGraph fits in the migration, the SharePoint Add-in migration FAQ is the first stop.

Wrapping up

Microsoft Graph is the gateway. Authentication is the hard part, and the delegated-vs-application decision is the single most useful mental model you can carry. The three deployment patterns (SPFx, MSTeams apps, Azure Functions) cover most of what M365 developers build, and the production trio of webhooks, delta queries, and batching with graceful throttling handling is what turns a demo into a system you can run.

Every section here deserves its own deep dive, and most of them have one on the blog already. Use this page as the map. Come back when you get lost.

What’s the part of MSGraph you wish someone had explained to you on day one? Drop it in the comments. That’s where my next deep dive comes from.

Share this article

Feedback & comments