Microsoft Graph Batch Requests for Faster Teams Automation
Stop firing twenty serial Graph calls. The $batch endpoint bundles up to 20 requests into one round trip with dependencies. Here's how to use it without footguns.
- #microsoft-teams
- #graph-api
- #batch
- #performance
- #automation
I once watched a Teams onboarding script take four minutes to provision a single user. Four minutes. Not because Graph is slow, but because I had written it like a polite person waiting in line: create the team, wait, add the channel, wait, set the membership, wait, post the welcome message, wait. Twenty round trips, each one paying the full latency tax of crossing the public internet to graph.microsoft.com and back. Multiply that across a hundred new hires and you have a job that quietly times out during the Monday morning rush.
The fix was embarrassingly simple, and I had been ignoring it for months: the $batch endpoint. It bundles up to twenty requests into a single HTTP call, supports dependency ordering, and turns those four minutes into a few seconds. This post is the guide I wish I’d had — including the footguns that bit me along the way.
What $batch actually is
$batch is a single endpoint that accepts a JSON document describing several requests, executes them, and returns the collected responses. You POST to:
POST https://graph.microsoft.com/v1.0/$batch
Content-Type: application/json
Authorization: Bearer <token>
The body wraps an array named requests. Each entry is a miniature HTTP request: an id you choose, a method, and a url. If the request has a payload, you add a body and the appropriate headers. Here is a real envelope that reads a user’s profile, lists the teams they belong to, and posts a message into a channel — all in one shot:
{
"requests": [
{
"id": "1",
"method": "GET",
"url": "/me"
},
{
"id": "2",
"method": "GET",
"url": "/me/joinedTeams"
},
{
"id": "3",
"method": "POST",
"url": "/teams/9f4b.../channels/19:abc.../messages",
"headers": {
"Content-Type": "application/json"
},
"body": {
"body": {
"contentType": "html",
"content": "Welcome to the team!"
}
}
}
]
}
Two details that trip people up immediately. First, the url inside each request is relative to the Graph version root — write /me, not the full https://graph.microsoft.com/v1.0/me. Second, when an inner request carries a body, you must set Content-Type: application/json in that request’s own headers. The outer Content-Type on the POST does not cascade down to the children. Forget the inner header and Graph returns a 400 for that item with a message that does not obviously point at the cause.
The response shape
A successful batch comes back as HTTP 200 OK at the outer level, with a responses array. Each entry mirrors one of your requests by id, and crucially each one carries its own status code:
{
"responses": [
{
"id": "1",
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": { "displayName": "James Joyner", "id": "..." }
},
{
"id": "3",
"status": 429,
"headers": { "Retry-After": "12" },
"body": {
"error": { "code": "TooManyRequests", "message": "..." }
}
},
{
"id": "2",
"status": 404,
"body": { "error": { "code": "ResourceNotFound" } }
}
]
}
Read that carefully. The outer call returned 200, but request 3 got throttled with a 429 and request 2 came back 404. A 200 batch can contain failed inner items. If your code only checks the outer HTTP status, you will happily log “success” while half your operations silently failed. Always iterate responses, match by id, and inspect each inner status.
Also note that the responses array is not guaranteed to be in the order you sent them. Match on id, never on array index. I learned that one in production when a “reliable” loop started attributing the wrong results to the wrong users.
Pro Tip: Honor per-item Retry-After. When an inner response is 429, the Retry-After header lives on that inner item, not the outer call. Collect just the throttled ids, wait the longest Retry-After you saw, and resubmit only those — don’t blindly replay the whole batch and re-do the work that already succeeded.
Sequencing with dependsOn
Plain batches run their requests in no guaranteed order and possibly in parallel. That is exactly what you want for independent reads. But provisioning is full of “B needs A to exist first” steps, and that is where dependsOn comes in. Each request can name the ids it depends on, and Graph will not run it until those complete:
{
"requests": [
{
"id": "create",
"method": "POST",
"url": "/teams",
"headers": { "Content-Type": "application/json" },
"body": { "displayName": "Project Phoenix", "...": "..." }
},
{
"id": "post",
"method": "POST",
"url": "/teams/.../channels/.../messages",
"dependsOn": ["create"],
"headers": { "Content-Type": "application/json" },
"body": { "body": { "content": "Channel is live." } }
}
]
}
A few rules the docs are quiet about. If any request uses dependsOn, the dependency chain has to be valid for the whole batch, and Graph effectively serializes the dependent items. If a dependency fails, the dependent request returns a 424 Failed Dependency instead of running. Keep chains short; deeply nested dependsOn graphs are slower and harder to reason about than just splitting into two batches.
Chunking past the 20-request ceiling
The hard limit is 20 requests per batch. Send 21 and the whole call is rejected. Real automation rarely fits in twenty, so you need a helper that chunks a long list into batches, fires them, and flattens the results back together. Here is the one I reach for:
const GRAPH = "https://graph.microsoft.com/v1.0/$batch";
const MAX = 20;
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
async function batchAll(requests, token) {
const results = [];
for (const group of chunk(requests, MAX)) {
const res = await fetch(GRAPH, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ requests: group }),
});
if (!res.ok) {
throw new Error(`Batch transport failed: ${res.status}`);
}
const { responses } = await res.json();
results.push(...responses);
}
// Surface inner failures the outer status hid from us
const failed = results.filter((r) => r.status >= 400);
if (failed.length) {
console.warn(`${failed.length} inner requests failed`, failed.map((f) => f.id));
}
return results;
}
Notice the assumptions baked in: it gives every request a stable id (you supply those upstream), it never trusts the outer status alone, and it logs the inner failures by id so you can decide whether to retry or alert. For very large jobs you might also run a couple of chunks concurrently — but watch your throttling budget, because twenty batched requests still count as twenty requests against your tenant’s rate limits.
Where AI fits — and where it doesn’t
Hand-writing batch envelopes is tedious and error-prone, and this is genuinely a place where an AI assistant earns its keep. I treat a tool like Claude or GitHub Copilot as a fast junior engineer: it drafts the JSON envelope, remembers to add the inner Content-Type, and sketches the dependsOn graph in seconds. I keep reusable scaffolds for exactly this in my prompt workspace, and there are ready-made prompts for generating Graph payloads from a plain-English description of what you want provisioned.
But a junior’s draft does not go straight to a tenant. I review every generated envelope before it runs, because the model does not know your channel IDs, your retention policies, or which membership change will page the wrong team. Never hand the model real tenant credentials or a live bearer token — it does not need them to draft JSON, and pasting secrets into a chat window is how credentials end up somewhere you can’t revoke. If the automation wires up a webhook or an incoming connector, verify that security yourself: confirm the subscription validation handshake, check the connector’s signing, and confirm who can post to it.
Pro Tip: Have the AI generate the batch, then run it once against a throwaway test team or a $batch dry run before you point it at production. The model is confident; your tenant is unforgiving. A two-minute human review is cheaper than un-provisioning a hundred mis-created channels.
If you are building a library of these patterns, the curated Microsoft Teams content and the downloadable prompt packs are good starting points for envelopes you can adapt rather than write from scratch.
A quick checklist before you ship
- Inner
urls are relative to the version root (/me, not the full URL). - Every request with a
bodyhas its ownContent-Type: application/jsonheader. - You iterate
responses, match byid, and check each innerstatus. Retry-Afteris read per-item; you resubmit only the throttledids.dependsOnchains are valid and short; failed dependencies surface as 424.- Lists over 20 are chunked; you never assume response order.
- A human reviewed the AI-drafted envelope, and no real credentials touched the model.
Conclusion
Batching is the difference between a Teams automation that crawls and one that snaps. Bundle your reads, sequence your writes with dependsOn, respect the 20-request ceiling, and — most importantly — never trust an outer 200 to mean everything inside succeeded. Let AI draft the boilerplate, keep a human on the review, and your four-minute provisioning job becomes a footnote instead of an outage.
microsoft-graph-batch-requests-for-faster-teams-automation
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.