A Microsoft 365 mailbox can reject your request with 429 Too Many Requests after a handful of sends. The message reads Application is over its MailboxConcurrency limit, and it shows up at volumes far below any published rate. Microsoft Graph enforces this limit per app ID and mailbox pair, and it counts requests in flight, not requests per minute. This page covers the numbers, why sending hits the limit first, and how to pace requests so the error stops.
What are the Microsoft Graph rate limits for Outlook mailboxes?
Section titled “What are the Microsoft Graph rate limits for Outlook mailboxes?”Microsoft Graph applies three limits to every app ID and mailbox combination: 10,000 requests per 10-minute window, 4 concurrent requests, and 150 MB of uploads per 5-minute window. Microsoft’s throttling limits documentation states the scope directly: “The Outlook service applies limits to each app ID and mailbox combination”.
| Limit | Value | Scope |
|---|---|---|
| Request volume | 10,000 requests per 10 minutes | One app ID against one mailbox |
| Concurrency | 4 requests in flight | One app ID against one mailbox |
| Upload volume | 150 MB per 5 minutes (POST, PATCH, PUT) | One app ID against one mailbox |
| Message rate | 30 messages per minute | One mailbox, all senders |
| Recipient rate | 10,000 recipients per 24 hours | One mailbox, all senders |
The first three rows come from Microsoft Graph. The last two are Exchange Online limits that apply to the mailbox itself, whether the message leaves through Graph, Outlook, or SMTP. Exceeding one mailbox’s limit doesn’t affect other mailboxes, so 500 connected accounts get 500 independent budgets. The number that bites in practice is the concurrency cap of 4, because it’s the only one a low-volume integration can hit.
Why do I get 429 MailboxConcurrency errors at low volume?
Section titled “Why do I get 429 MailboxConcurrency errors at low volume?”A MailboxConcurrency 429 means 4 Graph requests were already in flight against that mailbox under the same app ID when a fifth arrived. It measures overlap, not throughput. An integration sending 3 messages a minute can trigger it if those 3 sends run in parallel, while an integration sending 25 a minute sequentially rarely will.
Sending is where this surfaces first. One POST /v3/grants/{grant_id}/messages/send request makes several Graph calls, and messages with attachments make more, so each send keeps a slot busy for longer than a single read does. The Nylas-Provider-Request-Count response header reports the exact count for each request. Two or three sends dispatched at the same moment to the same grant, on top of other traffic under the same app ID, can fill all 4 slots, and Microsoft rejects the next call.
Every request under the same app ID counts, not only the ones your code makes. Nylas makes Graph calls for every grant on an ongoing basis to keep it in sync, and those calls use the same 4 slots. A 429 can appear even on the first send after a quiet period. Retrying after a short backoff usually clears it.
Which app ID counts against the MailboxConcurrency limit?
Section titled “Which app ID counts against the MailboxConcurrency limit?”The app ID is the Azure application the grant authenticated through. Microsoft scopes the limit to the app ID and mailbox pair, so the user’s Outlook desktop client, Outlook on the web, and mobile apps don’t consume your 4 slots. They authenticate through Microsoft’s own app IDs. Only traffic under your app ID counts against that mailbox’s 4 slots.
Which Azure app applies depends on how you authenticate. Grants created through a custom connector use your own Azure app registration, so each mailbox gets a pool of 4 dedicated to your integration. Grants created with default credentials use the shared Azure app behind hosted authentication. If another integration also connected the same mailbox with default credentials, the two share that mailbox’s 4 slots. In both cases the limit is scoped per mailbox, and one throttled mailbox never slows another. The hosted vs custom OAuth comparison covers the other reasons to register your own app.
How does the Nylas API change Microsoft Graph rate limit handling?
Section titled “How does the Nylas API change Microsoft Graph rate limit handling?”Connecting Microsoft accounts through Nylas Connect removes most of the Graph calls you would otherwise make yourself. One POST /v3/grants/{grant_id}/messages/send call replaces the draft, attachment upload, and send steps you would otherwise run against Graph. Reads from every provider return the same 429 shape, so one retry loop works for Microsoft, Google, and IMAP grants.
The bigger saving is on reads. A polling integration that checks a mailbox every 5 seconds makes 120 requests per 10 minutes before it does any real work, and more when each check makes several Graph calls. Webhooks deliver message.created and other events without your app making requests against the mailbox.
What doesn’t change is the ceiling itself. The 4-slot concurrency cap and the 10,000-request window belong to Microsoft, and no layer in front of Graph can raise them. The platform also applies its own API rate limits, but the Microsoft limits are the ones a Microsoft integration hits first, so they’re the ones to design around.
How does the Nylas API return Microsoft throttling errors?
Section titled “How does the Nylas API return Microsoft throttling errors?”When Microsoft throttles a Graph call, the API returns 429. On reads, error.type is rate_limit_error and Microsoft’s own error is in the provider_error field. If Microsoft says how long to wait, the read response includes a Retry-After header with that number of seconds, rounded up. Don’t retry early. Microsoft’s throttling guidance warns that “Microsoft Graph continues to log resource usage while a client is being throttled”. When a 429 has no Retry-After header, your own backoff sets the delay.
The read request below shows the shape of a throttled response. The message field is the API’s summary, and provider_error.error.message is Microsoft’s text verbatim. Check Microsoft’s text to tell a MailboxConcurrency rejection apart from other per-mailbox limits, such as IncomingBytes, or from an Exchange account throttle.
curl -i --request GET \ --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=20' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'HTTP/2 429content-type: application/jsonretry-after: 30
{ "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88", "error": { "type": "rate_limit_error", "message": "Microsoft rate-limited on application level. Please read provider error message in this response for more details. Learn more: https://developer.nylas.com/docs/dev-guide/best-practices/rate-limits/ and https://learn.microsoft.com/en-us/graph/throttling-limits#limits-per-mailbox", "provider_error": { "error": { "code": "ApplicationThrottled", "message": "Application is over its MailboxConcurrency limit." } } }}Read Nylas-Provider-Request-Count on successful responses too, to see which operations keep the mailbox’s slots busiest.
How do I avoid MailboxConcurrency errors when sending?
Section titled “How do I avoid MailboxConcurrency errors when sending?”Serialize sends per grant. One send in flight per mailbox eliminates MailboxConcurrency errors for most integrations, because sends that never overlap each other leave room in the 4 slots for sync and your other requests. Rate limiters that cap requests per second don’t help here. The limit is on overlap, so the fix is a concurrency limit of 1 per grant, not a slower rate.
A few more rules cover the remaining cases. Leave 2 to 5 seconds between consecutive sends on the same grant when messages carry attachments, since each upload adds Graph calls. After a send 429, wait the number of seconds in Retry-After if the response has it, and otherwise back off with a doubling delay and some jitter. And keep retry counts low for sends, because aggressive retry loops on a mailbox that’s also syncing keep colliding with the same 4 slots. Send with an Idempotency-Key, and follow that page’s guidance on when to reuse or replace the key after an error. A 429 on a raw MIME send doesn’t guarantee the message wasn’t delivered, so check the mailbox’s Sent Items before you resend one.
The examples below wrap the send endpoint in a per-grant queue that allows exactly 1 request in flight per mailbox. On a 429 it waits for Retry-After when the response has one, and otherwise backs off with jitter. Different grants still run in parallel, so total throughput scales with the number of mailboxes rather than dropping to 1 send at a time. The backoff function referenced here is the one from handling 429 rate limit errors.
// One send in flight per grant. Other grants proceed independently.const queues = new Map();
function enqueue(grantId, task) { const prev = queues.get(grantId) ?? Promise.resolve(); const next = prev.catch(() => {}).then(task); queues.set(grantId, next); return next;}
async function sendSerialized(grantId, apiKey, message, maxRetries = 3) { return enqueue(grantId, async () => { for (let attempt = 0; ; attempt++) { const res = await fetch( `https://api.us.nylas.com/v3/grants/${grantId}/messages/send`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(message), signal: AbortSignal.timeout(30_000), }, ); if (res.status !== 429 || attempt >= maxRetries) return res;
// Use Retry-After when the response has it; otherwise compute the delay. const retryAfter = Number.parseInt(res.headers.get("retry-after"), 10); const delayMs = Number.isNaN(retryAfter) ? backoffWithJitter(attempt) : retryAfter * 1000; await new Promise((resolve) => setTimeout(resolve, delayMs)); } });}# One send in flight per grant. Other grants proceed independently.import threadingimport timefrom collections import defaultdict
import requests
locks = defaultdict(threading.Lock)
def send_serialized(grant_id, api_key, message, max_retries=3): url = f"https://api.us.nylas.com/v3/grants/{grant_id}/messages/send" headers = {"Authorization": f"Bearer {api_key}"}
with locks[grant_id]: for attempt in range(max_retries + 1): res = requests.post(url, headers=headers, json=message, timeout=30) if res.status_code != 429 or attempt == max_retries: return res
# Use Retry-After when the response has it; otherwise compute the delay. retry_after = res.headers.get("Retry-After") if retry_after and retry_after.isdigit(): time.sleep(int(retry_after)) else: time.sleep(backoff_with_jitter(attempt))If you also make read requests against the same grant while sending, route them through the same per-grant queue. A message list that overlaps a send counts against the same 4 slots.
What are the Exchange Online sending limits?
Section titled “What are the Exchange Online sending limits?”Exchange Online caps every mailbox at 30 messages per minute and 10,000 recipients per 24 hours, and these limits sit underneath Graph’s concurrency rules. Microsoft’s Exchange Online limits apply them “per user to all outbound and internal messages”, so a person’s own Outlook activity and your integration draw down the same daily recipient budget.
A few details change how you plan. The per-message recipient limit defaults to 500 and admins can set it anywhere from 1 to 1,000, so a message to a large distribution list can fail on one tenant and succeed on another. For SMTP AUTH submissions, Microsoft says messages above 30 per minute are throttled and carried over into the following minutes, so over-rate sending can show up as slow delivery before it shows up as errors. A separate tenant-wide external recipient cap, which Microsoft sizes by license count, applies on top. For bulk or high-volume email to external recipients, Microsoft’s Exchange Online limits recommend Azure Communication Services Email rather than a user mailbox. The send email at scale recipe covers queueing across many mailboxes within these caps.
When should you send from an Agent Account instead?
Section titled “When should you send from an Agent Account instead?”Move app-owned sending off user mailboxes when the traffic isn’t really that person’s mail. Order confirmations, support intake, and agent-generated replies can run on a Nylas Agent Account, a hosted mailbox with its own address. It isn’t in a Microsoft tenant, so it doesn’t use any user mailbox’s 4 concurrent slots or 30-messages-per-minute limit.
Connected Microsoft grants stay right when a real person’s identity must be on the message, such as a sales rep’s follow-up or a reply from a shared support mailbox the customer already knows. The split is about who owns the address. Agent Accounts have their own usage limits by plan, and the Agent Accounts quickstart sends a first message in under 5 minutes.
What’s next
Section titled “What’s next”- Handle 429 rate limit errors for the shared backoff and jitter functions these examples call.
- Nylas API and provider rate limits for the platform’s own per-grant request limits.
- How to send Outlook email for the full send request with attachments.
- How to list Microsoft email messages for the read side, including folder names and message IDs.
- Webhooks vs polling for replacing scheduled reads with change notifications.
- Microsoft Graph API alternative for the wider Azure setup and admin consent tradeoffs.
- Client error responses 400-499 for every
429variant and its fix. - Gmail API quotas and limits for how Google’s quota-unit model differs.