Graph Api, Batch Like You Mean It
We’ve all been there. You open the fridge, grab the milk, walk back to your desk, realize you also need juice, walk back to the fridge, grab the juice, sit down again - and then remember the eggs. It’s exhausting, and it’s exactly what most Graph API scripts do under the hood.
This post is about breaking that habit.
The One-Item Shopping Trip Problem
Imagine you need to check the license status of 50 users in your tenant. The naive approach looks something like this:
$users = Get-MgUser -Filter "department eq 'Sales'" -All
foreach ($user in $users) {
$licenses = Get-MgUserLicenseDetail -UserId $user.Id
# do something with $licenses
}
Simple, readable, obvious. Also slow.
What’s happening behind the scenes? For every user in that foreach loop, PowerShell is:
- Opening an HTTPS connection to Graph
- Sending an authenticated request
- Waiting for a response (~150–300ms round trip)
- Closing/returning the connection
For 50 users that’s 50 separate round trips. At 200ms each, you’re looking at 10 seconds of pure waiting - and that’s on a good day without throttling.
This is the equivalent of going to the store with a shopping list that has one item per trip. You drive there, grab the milk, drive home. Drive there again, grab the eggs, drive home. Fifty times.
Nobody does this at a grocery store. But we do it constantly against APIs.
One Trip, Twenty Items
Microsoft Graph supports JSON batching, which lets you bundle up to 20 requests into a single HTTP call. One trip to the store, 20 items in the cart.
Under the hood: what the batch payload looks like
POST https://graph.microsoft.com/v1.0/$batch
{
"requests": [
{ "id": "1", "method": "GET", "url": "/users/[email protected]/licenseDetails" },
{ "id": "2", "method": "GET", "url": "/users/[email protected]/licenseDetails" },
{ "id": "3", "method": "GET", "url": "/users/[email protected]/licenseDetails" }
]
}
Graph processes all requests in parallel server-side and returns one response with all the results.
That’s where pt.EntraGraphUtils comes in. You describe what you want. It handles the orchestration.
# Build your list of user IDs
$userIds = (Get-MgUser -Filter "department eq 'Sales'" -All).Id
# Hand off ALL requests at once - the module handles chunking and orchestration
$results = Invoke-ptGraphBatchRequest -BatchItems ($userIds | ForEach-Object {
New-ptGraphRequestItem -Method GET -Url "/users/$_/licenseDetails"
})
# $results is a flat array of response objects, one per request
You pass in all 50 requests in one go. The module chunks them into groups of 20, fires each batch, and stitches the results back into a single flat array. Instead of 50 round trips you end up with 3 batch calls - roughly 600ms instead of 10 seconds. No manual chunking, no tracking which responses belong to which requests, no boilerplate to write.
Same list. One trip to the store.
There’s a second win that often goes unnoticed: throttling. Microsoft Graph pushes back hard when it sees a flood of requests from the same service principal - hit it with 200 rapid-fire individual calls and you will eventually get a 429 Too Many Requests. Three batch calls spaced a few hundred milliseconds apart look very different to Graph’s throttle detector. You’re not just faster - you’re a better citizen of the API. And if a 429 does slip through anyway, pt.EntraGraphUtils handles that too: it backs off automatically and retries with exponential backoff, so you don’t need to wire up any throttle-handling logic in your own script.
The Cart That Only Holds 100
Batching fixes how many calls you make. But there’s a second place you’re leaving trips on the table: how much data comes back per call.
When Graph returns a large collection, it paginates the results. By default, most endpoints return 100 items per page. If you have 1,000 users, Graph sends you 100, then hands you a @odata.nextLink URL to get the next 100, and so on.
The Get-MgUser -All flag handles this automatically - it follows the nextLink chain until the collection is exhausted. That’s convenient, but it hides something important: how many items are coming back per page.
Many Graph endpoints support a $top query parameter that lets you request up to 999 items per page (the limit varies by endpoint). Using the default of 100 when you could request 999 is like making 10 trips when your cart could hold everything in 2.
# Default - 100 users per page, 10 pages for 1,000 users = 10 round trips
Get-MgUser -All
# Better - up to 999 per page, potentially 2 pages for 1,000 users
Get-MgUser -All -Top 999
Check what each endpoint supports, but when you’re pulling large datasets this single parameter can cut your page-fetch round trips by an order of magnitude.
The same idea applies when batch results are themselves paged. Say you’re fetching group members for 50 groups - each group could return multiple pages of results. Tracking those nextLink chains manually across 50 parallel requests would be a real mess. pt.EntraGraphUtils takes that entirely off your plate with the -Pagination parameter:
'auto'- automatically follows all pagination links until the full dataset is retrieved'none'- returns only the first page, no questions asked- Not specified - returns the first page but warns you that more pages are available
# Follows all nextLink pages automatically, requests 999 members per page per group
$responses = Invoke-ptGraphBatchRequest -BatchItems ($groupIds | ForEach-Object {
New-ptGraphRequestItem -Method GET -Url "/groups/$_/members" -PageSize 999
}) -Pagination auto
Both at Once
Put the two habits together and the happy path looks like this:
# Fetch all users efficiently - maximize page size to minimize round trips
$users = Get-MgUser -All -Top 999 -Select "id,displayName,department"
# Hand everything to the module - it batches, orchestrates, and follows paging for you
$responses = Invoke-ptGraphBatchRequest -BatchItems ($users | ForEach-Object {
New-ptGraphRequestItem -Method GET -Url "/users/$($_.Id)/licenseDetails" -PageSize 999
}) -Pagination auto
Here’s what that actually means for 1,000 users end to end:
| Approach | Total API calls | Time (1,000 users) |
|---|---|---|
foreach loop, default $top |
~1,010 | ~3–5 minutes |
Batched, default $top |
~60 | ~15–20 seconds |
Batched, $top=999 |
~52 | ~10–12 seconds |
The work is identical. The data is the same. The difference is entirely in how you’re talking to the API.
Wrapping Up
Graph API performance usually isn’t a Graph problem - it’s a how you’re calling it problem. Two habits fix most of it:
- Batch your follow-up requests. Up to 20 per call, automatic chunking, built-in retry on throttling.
pt.EntraGraphUtilshandles all of that withNew-ptGraphRequestItemandInvoke-ptGraphBatchRequest. - Maximize your page size. Know what
$topsupports on the endpoint you’re hitting and use it.
If you want to try it:
Install-Module pt.EntraGraphUtils -Scope CurrentUser
Stop making one-item shopping trips. Write the full list before you leave the house.
The pt.EntraGraphUtils module is available at PowerShellToday/pt.EntraGraphUtils or at PowerShell Gallery Feedback and PRs welcome.