Architecture

Fix Microsoft Graph 429 throttling with Retry-After, batching and delta

Stop 429 Too Many Requests errors from Microsoft Graph: honour Retry-After, tune the SDK retry handler, batch correctly, cut request cost and replace polling with delta queries.

13 min read
On this page

A 429 Too Many Requests response from Microsoft Graph means your app crossed a throttling limit for the app, the tenant or the specific service it is calling, and Graph is refusing further requests until the interval in the Retry-After header has passed. The fix has two halves: handle the 429 correctly by waiting exactly the Retry-After seconds before retrying (the Graph SDKs do this for you on single requests, but not inside JSON batches), and make fewer, cheaper calls by using $select, sensible batching, and delta queries or change notifications instead of polling. Immediate retries make throttling worse, because Microsoft Graph keeps counting throttled requests against your usage.

Who this is for and what you will have

This guide is for administrators and developers whose scripts, sync jobs or apps fail with 429 errors against Microsoft Graph: user and group exports, mailbox processing, SharePoint scanners, Teams automation or nightly reports. At the end you will have:

  • A clear picture of which limit you are hitting and how Graph tells you.
  • Graph PowerShell and Invoke-RestMethod code that waits on Retry-After correctly.
  • A batching pattern that detects and retries throttled requests inside a batch.
  • A delta query loop that replaces full rescans of users or groups.
  • A troubleshooting list for the throttling cases that don't behave like the rest.

What a throttled response looks like

When a threshold is exceeded, Microsoft Graph limits further requests from that client for some time, returns HTTP 429 and suggests a wait time in the response header. Microsoft's documented sample looks like this:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 10
 
{
  "error": {
    "code": "TooManyRequests",
    "innerError": {
      "code": "429",
      "message": "Please retry after",
      "status": "429"
    },
    "message": "Please retry again later."
  }
}

Throttling can be selective. Microsoft notes that thresholds vary by request type, so writes can be throttled while reads still succeed. The two most common causes Microsoft lists are a large number of requests across all applications in a tenant, and a large number of requests from one application across all tenants.

How Microsoft Graph limits are structured

Graph applies two categories of limits at the same time: a global limit that applies to every call, and service-specific limits. The first limit reached triggers throttling. Microsoft states that the published limits are subject to change, so treat the following figures as a guide to where pressure builds up rather than numbers to design right up to.

ServiceDocumented limit (selection)Scope
Global130,000 requests per 10 secondsPer app across all tenants
Outlook (mail, calendar, contacts)10,000 requests per 10 minutes, 4 concurrent requests, 150 MB upload per 5 minutesPer app per mailbox
Identity and access3,500 / 5,000 / 8,000 resource units per 10 seconds for small / medium / large tenantsPer app per tenant
Identity and access writes3,000 requests per 2 minutes 30 secondsPer app per tenant
Microsoft 365 usage reports (CSV)14 requests per 10 minutesPer app per tenant, per report API
Subscriptions (change notifications)500 POST, PUT, PATCH or DELETE requests per 20 secondsPer app per tenant
Teams default GET30 requests per secondPer app per tenant

Two details matter in practice. The Outlook limit applies to each app and mailbox combination, so exceeding it on one mailbox doesn't stop the app reaching other mailboxes. And SharePoint and OneDrive resources (drive, driveItem, list, listItem, site) follow SharePoint's own throttling rules, which can return either 429 or 503 Server Too Busy, both with Retry-After.

Identity requests have a cost

For Microsoft Entra resources such as users, groups and applications, Graph uses a token bucket that adds up the cost of each request in resource units. Unlisted requests cost 1, but common reads cost more: GET users and GET applications cost 2, GET groups/{id}/members costs 3 and GET groups/{id}/transitiveMembers costs 5. Query options change the cost:

  • $select decreases the cost by 1.
  • $top with a value under 20 decreases the cost by 1.
  • $expand increases the cost by 1.
  • A request can never cost less than 1.

So GET /users?$select=displayName&$top=10 costs 1 resource unit instead of 2. Identity responses also carry headers that show how close you are:

HeaderMeaning
x-ms-resource-unitResource units consumed by this request
x-ms-throttle-limit-percentageReturned once you pass 0.8 of the limit; 1.0 means throttling starts
x-ms-throttle-scopeOn throttled responses: which scope (Tenant_Application, Tenant or Application) and limit (Read, Write, ReadWrite) was hit
x-ms-throttle-informationOn throttled responses: the reason, such as ResourceUnitLimitExceeded or WriteLimitExceeded

You can also send x-ms-throttle-priority with Low, Normal (default) or High. It doesn't raise any limit; it only decides which of your requests are throttled first, so mark background jobs Low to protect interactive traffic from the same app.

Prerequisites

  • Microsoft Graph PowerShell (Microsoft.Graph.Authentication module) for the PowerShell examples, or PowerShell 7 for Invoke-RestMethod retries.
  • An app registration or delegated sign-in with the Graph permissions your workload already uses. Nothing in this guide needs extra permissions.
  • Logging that records status codes and response headers, so you can see which limit you hit.

Step 1: Let the SDK retry single requests

Microsoft Graph SDKs already implement a retry handler that honours Retry-After and falls back to exponential backoff when the header is missing. In Graph PowerShell the handler applies to every cmdlet and to Invoke-MgGraphRequest. Set-MgRequestContext documents the defaults: -MaxRetry defaults to 3 with a maximum of 10, and -RetryDelay defaults to 3 seconds with a maximum of 180. -RetriesTimeLimit caps the total time spent retrying.

Connect-MgGraph -Scopes "User.Read.All"
 
# Allow more retries for a long-running export in this session
Set-MgRequestContext -MaxRetry 6 -RetryDelay 5

Raising the retry count helps a script survive bursts. It doesn't make the script faster and doesn't fix a workload that is simply too chatty; that is what the later steps are for.

Step 2: Handle 429 yourself when you need control

When the SDK gives up, or when you use plain REST, handle the status code and header yourself. Invoke-MgGraphRequest supports -SkipHttpErrorCheck, -StatusCodeVariable and -ResponseHeadersVariable, which lets you read the status and Retry-After without a try/catch:

function Get-RetryAfterSeconds {
    param($Headers)
    if (-not $Headers) { return $null }
    foreach ($key in $Headers.Keys) {
        if ($key -ieq 'Retry-After') { return [int](@($Headers[$key])[0]) }
    }
    return $null
}
 
function Invoke-GraphWithRetry {
    param([string]$Uri, [string]$Method = 'GET', $Body, [int]$MaxAttempts = 8)
 
    $backoff = 2
    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $response = Invoke-MgGraphRequest -Method $Method -Uri $Uri -Body $Body `
            -SkipHttpErrorCheck -StatusCodeVariable 'status' -ResponseHeadersVariable 'headers'
 
        if ($status -lt 400) { return $response }
 
        if ($status -in 429, 503, 504) {
            $wait = Get-RetryAfterSeconds $headers
            if (-not $wait) { $wait = $backoff; $backoff = [Math]::Min($backoff * 2, 120) }
            Write-Warning "HTTP $status on $Uri - waiting $wait s (attempt $attempt)"
            Start-Sleep -Seconds $wait
            continue
        }
 
        throw "Graph request failed with HTTP $status : $($response | ConvertTo-Json -Depth 5 -Compress)"
    }
    throw "Gave up on $Uri after $MaxAttempts attempts"
}

On PowerShell 7 with Invoke-RestMethod, the built-in retry parameters already do the right thing. -MaximumRetryCount retries on status codes from 400 to 599, and -RetryIntervalSec is overridden by Retry-After when the failure code is 429:

$headers = @{ Authorization = "Bearer $token" }
Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/users?$select=id,displayName&$top=999' `
    -Headers $headers -MaximumRetryCount 5 -RetryIntervalSec 5

If you call Invoke-RestMethod inside a try/catch instead, Microsoft's troubleshooting article shows reading the header in the catch block with $_.Exception.Response.Headers["Retry-After"]. Test that expression on the PowerShell version you run, because the exception's response object differs between Windows PowerShell 5.1 and PowerShell 7; on PowerShell 7 the built-in retry parameters shown above are the simpler option.

Step 3: Make each request cheaper

Fewer resource units per call means more calls before the bucket empties.

  • Always $select the properties you need. For identity calls it lowers the cost by 1, and it shrinks every response.
  • Avoid $expand on large directory reads; it adds a resource unit to every request.
  • Prefer one query with a filter over many single-object lookups.
  • For SharePoint content, Microsoft's SharePoint guidance prices a delta request with a token at 1 resource unit and a multi-item query at 2, and recommends Microsoft Graph over CSOM and REST because Graph usually consumes fewer resources.
  • Decorate SharePoint traffic with a User-Agent such as NONISV|Contoso|InventoryScanner/1.0. Microsoft prioritises well-decorated traffic over undecorated traffic.
  • Don't register extra app IDs to get more quota. SharePoint documents that apps in the same tenant share the tenant's resources, and the practice ends up throttling every app.

Step 4: Batch, but inspect every response

JSON batching combines up to 20 requests in one POST https://graph.microsoft.com/v1.0/$batch. It saves round trips, not quota. Microsoft is explicit: requests in a batch are evaluated individually, any that exceed limits fail with 429, and the batch itself still returns 200. Throttled requests inside a batch aren't retried automatically by the SDKs.

$batch = @{
    requests = @(
        @{ id = '1'; method = 'GET'; url = "/users/adele@contoso.com?`$select=id,displayName" }
        @{ id = '2'; method = 'GET'; url = "/users/alex@contoso.com?`$select=id,displayName" }
        @{ id = '3'; method = 'GET'; url = "/groups/$groupId/members?`$select=id" }
    )
}
 
$pending = $batch.requests
while ($pending.Count -gt 0) {
    $result = Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/$batch' `
        -Body (@{ requests = $pending } | ConvertTo-Json -Depth 10) -ContentType 'application/json'
 
    $throttled = @($result.responses | Where-Object { $_.status -eq 429 })
    foreach ($r in $result.responses | Where-Object { $_.status -ne 429 }) {
        # Process successes and log other errors by id here
    }
    if ($throttled.Count -eq 0) { break }
 
    $wait = ($throttled | ForEach-Object { Get-RetryAfterSeconds $_.headers } | Measure-Object -Maximum).Maximum
    if (-not $wait) { $wait = 10 }
    Start-Sleep -Seconds $wait
    $ids = $throttled | ForEach-Object { $_.id }
    $pending = @($pending | Where-Object { $_.id -in $ids })
}

Responses can come back in any order, so always correlate by id. Microsoft suggests retrying all failed requests in a new batch after the longest retry-after value, which is what the loop does.

Mailbox batches need extra care. For unordered batches against Outlook, Graph sends at most four requests at a time to the Outlook service, which keeps a single batch inside the per-mailbox concurrency limit. If several unordered batches for the same mailbox run in parallel, you can still exceed four concurrent requests. Microsoft's guidance for the same mailbox is either to run one unordered batch at a time, or to order the requests in each batch with dependsOn and run up to four such sequential batches concurrently. If a request in a dependsOn chain fails, every request that depends on it fails with 424 Failed Dependency.

Step 5: Replace polling with delta queries

Microsoft lists continuous polling and repeated full scans as the patterns most likely to be throttled. Delta query returns only what changed since your last call. The first call returns the current state across pages of @odata.nextLink, ending with an @odata.deltaLink you store for next time:

$stateFile = '.\users-delta.txt'
$uri = if (Test-Path $stateFile) { (Get-Content $stateFile -Raw).Trim() }
       else { 'https://graph.microsoft.com/v1.0/users/delta?$select=displayName,mail,accountEnabled' }
 
do {
    $page = Invoke-GraphWithRetry -Uri $uri
    foreach ($user in $page.value) {
        if ($user.ContainsKey('@removed')) { "Removed: $($user.id)" }
        else { "Changed: $($user.id) $($user.displayName)" }
    }
    $uri = $page.'@odata.nextLink'
    if ($page.'@odata.deltaLink') { Set-Content -Path $stateFile -Value $page.'@odata.deltaLink' }
} while ($uri)

Rules from Microsoft's delta documentation to build in:

  • Query parameters such as $select go only on the first request; they are encoded into the state token. Don't append them again.
  • For users and groups, $expand, $top and $orderby aren't supported, and only properties in $select are tracked.
  • To start from now without downloading current state, append $deltatoken=latest for Entra resources (token=latest for OneDrive and SharePoint).
  • Delta tokens for directory objects are valid for seven days. An expired token returns a 40X error such as syncStateNotFound, and you must run a full sync again.
  • Graph can return 410 Gone with a Location header to force a full resynchronization.
  • Expect replays: the same change can appear more than once, so make your merge idempotent.

For near-real-time needs, combine delta with change notifications: subscribe to the resource, and when a notification arrives, call your stored delta link instead of polling on a timer. The receiving endpoint has its own reliability rules, covered in webhooks that don't double-charge.

Step 6: Spread load across time and scope

  • Limit concurrency per mailbox to four or fewer, and per Teams channel or chat to the per-resource rates Microsoft lists (many Teams operations allow 1 request per second per chat or channel).
  • Run bulk jobs off-peak. SharePoint states that throttling is more likely during peak hours, and describes off-peak hours as typically nights and weekends in your tenant's region.
  • For bulk extraction of Microsoft 365 data, Microsoft recommends Microsoft Graph Data Connect instead of the REST APIs, because it isn't subject to the same throttling limits.
  • Large migrations, such as the ones in the tenant-to-tenant migration architecture, should budget Graph calls per mailbox and per site from the start rather than discovering limits mid-cutover.

Verify the fix

  1. Run the job with -ResponseHeadersVariable and log x-ms-resource-unit and x-ms-throttle-limit-percentage for identity calls. The percentage header only appears above 0.8 of the limit; if it stops appearing, you have headroom.
  2. Count 429 responses per run and the total seconds spent waiting. Both should drop after you add $select and switch to delta.
  3. For batches, confirm every request id ends in a success or a logged non-retryable error. None should silently disappear.

Troubleshooting

Batch returns 200 but data is missing. Individual responses had status 429. Inspect responses[].status, retry the throttled ids after the longest Retry-After.

429 with no Retry-After header. Identity protection and conditional access resources (one request per second per tenant) and OneNote don't return Retry-After. Use exponential backoff for those services.

503 Server Too Busy from SharePoint or OneDrive. SharePoint returns 503 for temporary load spikes and includes Retry-After. Persistent 503s after repeated violations can mean the app was blocked; Microsoft notifies the tenant in the Message Center.

410 Gone or syncStateNotFound on a delta link. The token expired or Graph requested a resync. Delete the stored link and run the initial delta query again.

424 Failed Dependency in a batch. A request that this one depended on through dependsOn failed. Fix or retry the parent request first.

Usage reports throttled almost immediately. CSV report APIs allow 14 requests per 10 minutes per app per tenant, per report. Cache report output instead of requesting it per user.

Closing checklist

  • Every Graph call path waits on Retry-After and never retries immediately.
  • Graph PowerShell sessions set -MaxRetry and -RetryDelay deliberately for long jobs.
  • Every batch response is inspected by id; throttled requests are retried.
  • $select on every query; no $expand on bulk directory reads.
  • Scheduled full scans replaced with delta queries, with token expiry and 410 Gone handled.
  • Concurrency capped per mailbox, per channel and per site.
  • Background jobs send x-ms-throttle-priority: Low.
  • SharePoint traffic carries a decorated User-Agent.

References

Questions people ask

What does a 429 Too Many Requests error from Microsoft Graph mean?

Your app exceeded a throttling limit, either the global per-app limit or a service-specific limit such as the Outlook per-mailbox limit or the identity resource-unit budget. Graph rejects further requests for a short time and returns a Retry-After header with the number of seconds to wait.

How long should I wait before retrying a throttled Graph request?

Wait exactly the number of seconds in the Retry-After header, then retry. If the retry is throttled again, keep using the new Retry-After value. Only fall back to exponential backoff when the response has no Retry-After header, which happens for a few services such as identity protection and OneNote.

Does JSON batching avoid Microsoft Graph throttling?

No. Each request inside a batch is evaluated individually against the throttling limits, so a batch can return 200 OK while several of its requests failed with 429. You must inspect every response in the batch and retry the throttled ones, because the SDKs don't retry batched requests automatically.

What is the default retry behaviour of Microsoft Graph PowerShell?

Set-MgRequestContext documents a default of 3 retries (maximum 10) and a default retry delay of 3 seconds (maximum 180 seconds). The retry handler honours Retry-After when Graph sends it. You can change the values for the session with Set-MgRequestContext -MaxRetry and -RetryDelay.

Microsoft GraphMicrosoft Entra IDGraph SDKPowerShellSharePoint Online
  1. Migrate EWS apps to Microsoft Graph before Exchange Online turns off EWS

    Move mail, calendar and contact integrations from EWS to Microsoft Graph: map operations, replace impersonation with scoped permissions, convert stored IDs and plan for gaps.

    Architecture13 min read
  2. Multi-tenant SaaS on Entra ID: admin consent, tenant allowlist, app lock

    Register a multi-tenant SaaS app in Microsoft Entra ID, onboard customer tenants through admin consent, enforce a tenant allowlist in token validation and lock service principal credentials with app instance property lock.

    Architecture12 min read
  3. Dataverse Application Users: Server-to-Server Auth with an Entra App

    Register a Microsoft Entra app, create a Dataverse application user with a least-privilege security role, get a client credentials token and call the Web API, then fix common errors.

    Architecture10 min read