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-RestMethodcode that waits onRetry-Aftercorrectly. - 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.
| Service | Documented limit (selection) | Scope |
|---|---|---|
| Global | 130,000 requests per 10 seconds | Per app across all tenants |
| Outlook (mail, calendar, contacts) | 10,000 requests per 10 minutes, 4 concurrent requests, 150 MB upload per 5 minutes | Per app per mailbox |
| Identity and access | 3,500 / 5,000 / 8,000 resource units per 10 seconds for small / medium / large tenants | Per app per tenant |
| Identity and access writes | 3,000 requests per 2 minutes 30 seconds | Per app per tenant |
| Microsoft 365 usage reports (CSV) | 14 requests per 10 minutes | Per app per tenant, per report API |
| Subscriptions (change notifications) | 500 POST, PUT, PATCH or DELETE requests per 20 seconds | Per app per tenant |
| Teams default GET | 30 requests per second | Per 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:
$selectdecreases the cost by 1.$topwith a value under 20 decreases the cost by 1.$expandincreases 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:
| Header | Meaning |
|---|---|
x-ms-resource-unit | Resource units consumed by this request |
x-ms-throttle-limit-percentage | Returned once you pass 0.8 of the limit; 1.0 means throttling starts |
x-ms-throttle-scope | On throttled responses: which scope (Tenant_Application, Tenant or Application) and limit (Read, Write, ReadWrite) was hit |
x-ms-throttle-information | On 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.Authenticationmodule) for the PowerShell examples, or PowerShell 7 forInvoke-RestMethodretries. - 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 5Raising 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 5If 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
$selectthe properties you need. For identity calls it lowers the cost by 1, and it shrinks every response. - Avoid
$expandon 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
$selectgo only on the first request; they are encoded into the state token. Don't append them again. - For users and groups,
$expand,$topand$orderbyaren't supported, and only properties in$selectare tracked. - To start from now without downloading current state, append
$deltatoken=latestfor Entra resources (token=latestfor 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 Gonewith aLocationheader 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
- Run the job with
-ResponseHeadersVariableand logx-ms-resource-unitandx-ms-throttle-limit-percentagefor identity calls. The percentage header only appears above 0.8 of the limit; if it stops appearing, you have headroom. - Count 429 responses per run and the total seconds spent waiting. Both should drop after you add
$selectand switch to delta. - For batches, confirm every request
idends 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-Afterand never retries immediately. - Graph PowerShell sessions set
-MaxRetryand-RetryDelaydeliberately for long jobs. - Every batch response is inspected by
id; throttled requests are retried. $selecton every query; no$expandon bulk directory reads.- Scheduled full scans replaced with delta queries, with token expiry and
410 Gonehandled. - 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
- Microsoft Graph throttling guidance
- Microsoft Graph service-specific throttling limits
- Combine multiple HTTP requests using JSON batching
- Use delta query to track changes in Microsoft Graph data
- Set-MgRequestContext
- Invoke-MgGraphRequest
- Invoke-RestMethod
- Handling errors in Microsoft Graph API requests with Invoke-RestMethod
- Avoid getting throttled or blocked in SharePoint Online