When your Power Automate flow works perfectly on 50 records but collapses under 5,000, you have a batching problem. This expert-level lesson teaches you how to architect production-grade flows using Do Until loops, take/skip array chunking, exponential backoff for API rate limits, and stateful checkpointing so your automation never loses progress—no matter the data volume.

You've built a flow that works perfectly on your test data — fifty records, clean results, done in thirty seconds. Then you run it against production. Four thousand customer records. Eight hundred SharePoint items. Twelve thousand rows from a SQL export. The flow either times out after its 30-day run limit kicks in unexpectedly, throws a cascade of throttling errors from the downstream API, or burns through your action capacity faster than you thought possible.
This is the batching problem, and it's one of the most common walls that intermediate Power Automate builders hit when they try to graduate from "clever automation" to "production-grade automation." The gap isn't about knowing more connectors or more expressions. It's about fundamentally rethinking how your flow interacts with data — treating large datasets not as monolithic blobs to be processed all at once, but as streams of work to be divided, paced, and managed deliberately.
By the end of this lesson, you'll know how to architect flows that can handle hundreds of thousands of records reliably, how to implement array chunking with pure expressions, how to use Do Until loops as your primary engine for bounded batch iteration, and how to build rate-limit-aware flows that respect API throttling constraints without human intervention.
What you'll learn:
You should be comfortable with Power Automate's expression language — specifically skip(), take(), length(), variables(), and concat(). You should have built at least a few multi-step flows and understand how conditions, loops, and variables work in the designer. Familiarity with HTTP actions and reading API responses is helpful, since much of this lesson involves inspecting status codes and headers. If you haven't worked with the HTTP action and custom connector patterns before, the lesson on advanced custom connectors and HTTP actions will give you the background you need.
Let's be precise about what we mean by "high volume" in the Power Automate context. The platform imposes hard limits that quickly become relevant:
Key insight: The single most important mindset shift is recognizing that your flow is not the only consumer of a shared API. SharePoint, Dataverse, Exchange, and every external service you call has throttling that protects all users on the platform. Your flow has a social contract to be a polite consumer.
The architectural answer is batching: break work into discrete chunks, process one chunk at a time with appropriate pauses, handle failures at the chunk level rather than the item level, and maintain enough state to resume from where you left off if something goes wrong.
The foundation of any batching strategy is array chunking — taking a flat array of N items and dividing it into sub-arrays of size K. In languages like Python or JavaScript, this is trivial. In Power Automate's expression language, it requires more thought because the platform doesn't have a native chunk() function.
The strategy we'll use relies on three functions working together:
skip(array, count) — returns everything in the array after skipping the first count itemstake(array, count) — returns the first count items from an arraylength(array) — returns the number of items in an arrayTo get chunk n (zero-indexed) of size K from an array called allItems, the expression is:
take(skip(variables('allItems'), mul(variables('chunkIndex'), variables('chunkSize'))), variables('chunkSize'))
Let's break that down:
mul(variables('chunkIndex'), variables('chunkSize')) calculates the starting offset (e.g., chunk 3 of size 100 starts at position 300)skip(variables('allItems'), <offset>) removes everything before that positiontake(<result>, variables('chunkSize')) takes only the next K itemsWhen you're on the last chunk, take() will naturally return fewer than K items without error — it just returns whatever remains. That's exactly the behavior you want.
Tip: Always compute your total chunk count upfront and store it in a variable. Use
div(length(variables('allItems')), variables('chunkSize'))for the base, then add 1 if there's a remainder (mod(length(variables('allItems')), variables('chunkSize'))is greater than 0). You'll need this to set your Do Until exit condition correctly.
The Do Until loop is Power Automate's mechanism for repeating a block of actions until a condition is met. It's more flexible than Apply to Each for batching because you control the iteration explicitly — you decide when to advance, when to pause, and when to stop.
Here's the architectural skeleton for a complete batching engine:
Before the Do Until loop, initialize these variables:
| Variable | Type | Initial Value | Purpose |
|---|---|---|---|
allItems |
Array | (your dataset) | The full dataset to process |
chunkIndex |
Integer | 0 | Current chunk (zero-indexed) |
chunkSize |
Integer | 100 | How many items per batch |
totalChunks |
Integer | (calculated) | Total number of batches |
processingErrors |
Array | [] | Error log for failed items |
isComplete |
Boolean | false | Loop exit flag |
For totalChunks, use a Compose action with this expression:
add(
div(length(variables('allItems')), variables('chunkSize')),
if(
greater(mod(length(variables('allItems')), variables('chunkSize')), 0),
1,
0
)
)
Set your totalChunks variable with the output of that Compose.
Add a Do Until loop with this exit condition:
greaterOrEquals(variables('chunkIndex'), variables('totalChunks'))
Set the loop limit to something generous — if you're processing 10,000 items in chunks of 100, you need 100 iterations. The default Do Until limit is 60 iterations, which you can increase up to 5,000 in the loop settings. Set the timeout to something appropriate for your data volume, such as PT12H (12 hours in ISO 8601 duration format).
Warning: The "Count" limit on Do Until loops is often overlooked. If your loop exits prematurely because you hit the iteration limit before processing all chunks, you'll get a silently incomplete run. Always calculate your maximum needed iterations and set the limit accordingly — never leave it at the default 60.
Inside the Do Until, add a Compose action to extract the current chunk:
take(
skip(variables('allItems'), mul(variables('chunkIndex'), variables('chunkSize'))),
variables('chunkSize')
)
This output is your currentChunk — an array of up to chunkSize items. Now you process this chunk with an Apply to Each over outputs('Compose_CurrentChunk').
At the end of the Do Until body (outside the Apply to Each), increment your counter:
Set variable: chunkIndex = add(variables('chunkIndex'), 1)
This is the heartbeat of your engine. Every iteration of the Do Until processes one chunk and advances the pointer.
Let's make this concrete. Imagine you're automating user provisioning: you receive a CSV file with 3,000 new employees from HR, and you need to create each one in Azure AD and then send them a welcome email. The Microsoft Graph API has a per-user rate limit, and the email connector has its own throttling. You can't just loop over 3,000 items.
Your flow architecture looks like this:
Trigger: Recurrence (daily at 6 AM) or HTTP trigger from an upstream system
Phase 1 — Data Loading:
allItemstotalChunksPhase 2 — Batch Processing (Do Until loop):
Do Until: chunkIndex >= totalChunks
|
├── Compose: Extract current chunk (take/skip expression)
|
├── Apply to Each: currentChunk
| ├── HTTP: POST to Graph API /users endpoint
| ├── Condition: Check response status
| | ├── 201 Created → Compose success log entry
| | ├── 429 Too Many Requests → Delay + Retry (see next section)
| | └── Other error → Append to processingErrors
| └── Append to processingErrors (if needed)
|
├── Delay: 2 seconds (inter-chunk pause)
|
└── Set variable: chunkIndex = chunkIndex + 1
Phase 3 — Completion:
processingErrors empty?Notice the deliberate 2-second delay between chunks. This is your primary throttle control mechanism. With a chunk size of 50 and a 2-second inter-chunk pause, you're processing 50 items roughly every 2 seconds — around 25 items per second. If the Graph API limit is 300 requests per minute for your tenant, you're safely under that ceiling.
Note: The right chunk size and inter-chunk delay are specific to your target API and your data characteristics. Start conservative (small chunks, longer pauses), observe your flow run times in Run History, and tune from there. Don't optimize prematurely.
A fixed inter-chunk delay is a blunt instrument. It works, but it's inefficient — if the API is lightly loaded, you're waiting unnecessarily. If the API is heavily throttled, a fixed delay might not be enough. The more sophisticated approach is reactive rate limiting: detect when the API is telling you to slow down, and respond accordingly.
When a well-behaved API is being throttled, it returns HTTP 429 Too Many Requests with a Retry-After header. This header tells you exactly how many seconds to wait before retrying. Power Automate can read this:
In your HTTP action that calls the API, configure it to run whether the previous action succeeded or failed (configure the Run After settings). Then add a Condition immediately after:
Condition: outputs('HTTP_CreateUser')['statusCode'] is equal to 429
If true:
outputs('HTTP_CreateUser')['headers']['Retry-After']outputs('Compose_RetryAfter') secondsThis creates a mini retry loop within your main batching loop.
For APIs that don't return a Retry-After header, or for network errors (5xx responses), exponential backoff is the industry standard approach. The idea: your first retry waits 1 second, the second waits 2 seconds, the fourth waits 8 seconds, and so on. This prevents a thundering herd of retries from making the situation worse.
Implement this with a nested Do Until inside your main batch loop:
Initialize: retryCount = 0
Initialize: retryDelay = 1 (second)
Initialize: apiCallSucceeded = false
Do Until: apiCallSucceeded OR retryCount >= 5
|
├── HTTP: Call the API
|
├── Condition: Status code is 200 or 201?
| ├── Yes → Set apiCallSucceeded = true
| └── No:
| ├── Set retryDelay = mul(variables('retryDelay'), 2)
| ├── Set retryCount = add(variables('retryCount'), 1)
| └── Delay: retryDelay seconds
The delay sequence becomes 1, 2, 4, 8, 16 seconds before giving up. After 5 attempts with no success, you append the failed item to your processingErrors array and move on — you don't want one stubborn API call to block your entire batch.
Tip: Add jitter to your exponential backoff by adding a small random offset to the delay. In distributed systems, pure exponential backoff can cause synchronized retry storms when multiple flow instances are running simultaneously. In Power Automate, use
rand(1, 5)to add 1-4 random seconds to each retry delay.
For a deeper dive into structured retry patterns across all your flows, the article on error handling and retry patterns covers this topic comprehensively including scope-level error trapping.
So far, our batching engine keeps all state in variables — which means if the flow fails mid-run, the state is lost and you restart from scratch. For truly critical high-volume processing, you need a stateful design: the flow saves its progress periodically, and if it fails or is terminated, the next run picks up from the checkpoint.
The checkpoint mechanism requires external storage. The most practical options in Power Automate are:
Here's how the pattern works with SharePoint:
Checkpoint Schema (SharePoint List):
| Column | Type | Description |
|---|---|---|
RunId |
Text | Unique identifier for this processing job |
LastProcessedChunk |
Number | Index of the last successfully completed chunk |
TotalChunks |
Number | Total chunks in this job |
Status |
Choice | Pending / In Progress / Complete / Failed |
ErrorCount |
Number | Running count of item-level errors |
StartedAt |
DateTime | When this job began |
Modified Flow Architecture:
On start:
RunId)LastProcessedChunk and set chunkIndex = LastProcessedChunk + 1chunkIndex = 0Inside the Do Until loop, after each chunk completes:
LastProcessedChunk = chunkIndexchunkIndexOn completion:
If the flow fails at chunk 47 of 200, the next run sees the checkpoint, skips chunks 0-47, and resumes from chunk 48. No reprocessing, no data loss.
Key insight: Checkpoint storage also gives you an audit trail for free. After the flow runs, you have a SharePoint record showing exactly how long each batch job took, how many errors occurred, and whether it completed successfully. This data is invaluable for capacity planning and troubleshooting.
This pattern pairs naturally with orchestrating child flows — the checkpoint management can live in a parent orchestrator flow, while each chunk is processed by a child flow that receives the chunk as its input parameter.
Choosing the right chunk size is more nuanced than "just use 100." It's an engineering decision that depends on several factors:
Most APIs express limits as "X requests per Y seconds." Divide X by Y to get your per-second budget. With a chunk size of K and an inter-chunk delay of D seconds, your sustained request rate is K / (processing_time + D). Make sure this rate stays below the API's per-second limit.
For the Microsoft Graph API on an enterprise tenant, the limit is typically 300 requests per second per application, but the practical throttling kicks in much earlier for bulk operations. Starting with chunks of 25-50 and a 1-second delay is a safe baseline.
Remember that every action inside your Do Until loop runs once per chunk. Every action inside your Apply to Each runs once per item. With 1,000 items in chunks of 100:
If your Apply to Each has 5 actions, that's 5,000 action executions from the inner loop alone, plus 10 × (however many actions are in your chunk-level logic). Larger chunks mean more Apply to Each executions relative to Do Until overhead — but also larger payloads if you're processing the chunk as a unit.
For licensing awareness, review Power Automate licensing options to understand how action limits apply to your specific plan.
Smaller chunks mean that when something fails, you lose less work. If a network error kills the flow during Apply to Each over a chunk of 500, you need to reprocess 500 items. With chunks of 50, the maximum rework is 50 items. The checkpoint pattern makes this precise: you know exactly which chunk failed.
Power Automate has undocumented (and variable) memory limits on how large an expression can be and how large a variable payload can be stored. Arrays with thousands of complex JSON objects can hit these limits. If you're loading a full dataset into a single variable, test with your real data before committing to a chunk size.
Practical Starting Points:
| Scenario | Recommended Chunk Size | Inter-Chunk Delay |
|---|---|---|
| Microsoft Graph API (users/groups) | 25-50 | 2 seconds |
| SharePoint list operations | 100-500 | 1 second |
| External REST APIs (conservative) | 10-25 | 5 seconds |
| Internal/high-limit APIs | 100-500 | 0-1 second |
| Email sending (Exchange) | 10-20 | 5 seconds |
Sequential batch processing is safe and predictable, but it's slow. If you have a time-sensitive job and your API can support higher throughput, you can parallelize at the chunk level.
The architecture: instead of processing one chunk at a time in a Do Until loop, you pre-generate all chunks, store them in a SharePoint list or Azure Service Bus queue, and then fan out to multiple child flows that each claim and process one chunk.
The Queue-Based Approach:
This gives you inherent fault tolerance (failed chunks are automatically retried) and horizontal scaling (add more worker flow instances to increase throughput).
The tradeoff: more architectural complexity, requires Azure Service Bus, and you need to be careful about concurrent writes to shared state (like a results store). The lesson on parallel branching and concurrency control covers the concurrency hazards you'll encounter in parallel designs.
For smaller-scale parallel batching (without Azure Service Bus), you can use Power Automate's "Run a Child Flow" action with multiple parallel branches in your parent flow, each branch processing a different chunk. This works well for up to 10-20 parallel executions before the concurrency overhead dominates.
A closely related challenge: what if your data source paginates and you don't have the full dataset loaded before you start batching? This is common with SharePoint lists (4,000-item view threshold), REST APIs that return 100 items per page, and SQL queries with result set limits.
The solution is a two-phase approach:
Phase 1 — Full Data Collection:
Use a separate Do Until loop to collect all pages from the data source into your allItems array before any processing begins.
Initialize: allItems = []
Initialize: nextPageToken = ""
Initialize: hasMorePages = true
Do Until: hasMorePages = false
|
├── HTTP: GET /api/items?pageToken=nextPageToken&pageSize=100
|
├── Apply to Each: response body items
| └── Append to Array: allItems
|
├── Condition: Does response contain nextPageToken?
| ├── Yes → Set nextPageToken = response.nextPageToken
| └── No → Set hasMorePages = false
Phase 2 — Batch Processing:
Only after Phase 1 completes and allItems contains everything do you start your batching engine from the previous sections.
Warning: Storing tens of thousands of JSON objects in a single Power Automate array variable can cause performance degradation or hit undocumented payload limits. For truly massive datasets (50,000+ records), consider an intermediate storage strategy: write each page to SharePoint or Dataverse during collection, then read from there during processing rather than keeping everything in memory.
For a focused look at the pagination side of this problem, the lesson on handling pagination and throttling when querying large datasets goes deep on the mechanics of different API pagination styles (cursor-based, offset-based, token-based) and how to handle each in Power Automate.
Processing high-volume data often involves transforming records before sending them to the target system. The Select and Filter Array actions in Power Automate are purpose-built for bulk transformation and filtering — and critically, they operate on an entire array without looping, which is much more efficient than using Apply to Each for transformations.
For your current chunk, you can chain transformations before the API call:
Compose (currentChunk)
→ Filter Array (exclude inactive records)
→ Select (reshape each record to API's expected format)
→ HTTP (send transformed batch to API)
The Select action is particularly powerful. For a chunk of user records that need to be reshaped for a Graph API batch request, your Select map might look like:
{
"accountEnabled": true,
"displayName": "@{item()['FullName']}",
"mailNickname": "@{toLower(first(split(item()['Email'], '@')))}",
"userPrincipalName": "@{item()['Email']}",
"passwordProfile": {
"forceChangePasswordNextSignIn": true,
"password": "TempPass2024!"
}
}
This runs once per item within the Select action itself — not as a separate Apply to Each iteration — making it significantly more efficient. For comprehensive coverage of Select, Filter, and Compose together, the article on transforming and mapping data with Compose, Select, and Filter Array is essential reading.
If you're working with Microsoft Graph API specifically, there's a much more powerful option than making individual API calls for each item: the Graph API's $batch endpoint lets you send up to 20 requests in a single HTTP call. This is true server-side batching and can dramatically reduce the number of HTTP actions your flow executes.
The batch request format:
{
"requests": [
{
"id": "1",
"method": "POST",
"url": "/users",
"headers": { "Content-Type": "application/json" },
"body": { ... user 1 ... }
},
{
"id": "2",
"method": "POST",
"url": "/users",
"headers": { "Content-Type": "application/json" },
"body": { ... user 2 ... }
}
]
}
To use this with your batching engine:
chunkSize to 20 (the batch endpoint maximum)requests array format abovehttps://graph.microsoft.com/v1.0/$batchThe batch response contains a responses array with a status code for each sub-request. You process these exactly as you'd handle individual responses — check each status code, retry 429s, log failures.
This approach reduces a 1,000-item operation from 1,000 HTTP calls to 50 batch HTTP calls. The action count reduction and speed improvement are substantial.
Build a complete batching flow that processes a large dataset with rate limit handling. Use the following scenario: you have a SharePoint list called "CustomerEmails" with 500 items, each containing a customer email address and a personalized message. You need to send each customer their message via the Outlook connector, with a maximum of 10 emails per minute (a realistic Exchange throttling constraint).
Step 1: Set Up Test Data
Create a SharePoint list with at least 50 items (testing with all 500 is optional but instructive). Each item should have CustomerEmail (text) and PersonalizedMessage (multiline text) columns.
Step 2: Initialize the Flow
Create a manually triggered flow with these variables:
allItems (Array, initially [])chunkIndex (Integer, 0)chunkSize (Integer, 10)totalChunks (Integer, 0)emailErrors (Array, [])Add a "Get items" action for your SharePoint list (enable pagination in settings if needed, set threshold to 500). Use an Apply to Each to populate allItems from the SharePoint response — append each item's body to the array.
Step 3: Calculate Total Chunks
Add a Compose action with the expression:
add(
div(length(variables('allItems')), variables('chunkSize')),
if(greater(mod(length(variables('allItems')), variables('chunkSize')), 0), 1, 0)
)
Set totalChunks from this output.
Step 4: Build the Do Until Loop
Configure: chunkIndex >= totalChunks, limit 100 iterations, timeout PT2H.
Inside the loop:
items('Apply_to_each')['CustomerEmail'], Body = items('Apply_to_each')['PersonalizedMessage']emailErrorschunkIndexStep 5: Completion Logic
After the Do Until:
emailErrors equal to 0?length(variables('emailErrors')) emails failed." Attach the error array as JSON in the body.Challenge extension: Modify the flow to read and write a checkpoint to a second SharePoint list so that if the flow is cancelled at chunk 7, the next manual run resumes from chunk 8 instead of restarting.
Symptom: Your flow completes successfully but only processes some of your data. The run history shows the Do Until loop exited cleanly, but chunkIndex is less than totalChunks.
Cause: The default iteration limit of 60 was hit. The loop exits successfully (not as an error) when the limit is reached, which is why this is so insidious.
Fix: In the Do Until settings, set "Count" to at least your maximum expected chunk count. For safety, set it generously — if you might have 200 chunks, set the limit to 300.
Symptom: Your chunk boundaries drift over time. Records get processed twice or are skipped entirely.
Cause: Something inside your loop is modifying allItems — perhaps an "Append to array variable" that's accidentally targeting the source array instead of a results array.
Fix: Treat allItems as immutable once the loop starts. Never write to it inside the Do Until. Use separate variables for results and errors.
Symptom: Works in testing, fails silently with parallel Apply to Each enabled.
Cause: Some flows try to use the implicit index within Apply to Each for chunking logic. When parallel execution is enabled, iteration order is non-deterministic and the index becomes meaningless.
Fix: Use the explicit chunkIndex variable and take/skip expressions. These are deterministic regardless of Apply to Each concurrency settings.
Symptom: After a successful item, the next item's retry delay starts at 16 seconds instead of 1 second.
Cause: retryDelay and retryCount variables from the exponential backoff pattern aren't reset at the start of each Apply to Each iteration.
Fix: Add "Set variable" actions at the beginning of each Apply to Each iteration to reset retryCount = 0 and retryDelay = 1 before each item's processing block.
Symptom: Flow runs until it hits the iteration limit or timeout.
Cause: A logic error in chunkIndex incrementing — perhaps the increment action is inside a conditional branch that doesn't always execute.
Fix: Place the chunkIndex increment unconditionally at the very end of the Do Until body, outside all conditions and error handlers. The counter must advance on every iteration, regardless of what happened during processing.
Warning: Always test your Do Until exit condition with a small dataset where you can manually verify the final
chunkIndexvalue matchestotalChunksin the run history. An infinite loop on production data is expensive both in action count and in potential rate limit damage to your tenant.
Symptom: Compose actions silently truncate large arrays, or flow throws "The request body exceeds the maximum size" errors.
Cause: Power Automate has a 100MB limit on action inputs/outputs (though the practical limit for in-memory variables is much lower, around 5-10MB for complex JSON).
Fix: If your items are large JSON objects (e.g., rich SharePoint list items with many columns), reduce chunk size significantly. Test with length(outputs('Compose_CurrentChunk')) to verify you're getting the expected count.
As your data volumes grow, the pure Power Automate batching approach will eventually hit practical limits. Here's how to think about when to augment or replace parts of the pattern:
Under 50,000 items: The Do Until + take/skip pattern works well with appropriate chunk sizes and delays.
50,000 to 500,000 items: Consider the queue-based parallel pattern with Azure Service Bus. Also consider whether Power Automate is the right tool for the heavy lifting — Azure Data Factory, Azure Functions, or Logic Apps with their higher action limits may be more appropriate for the processing layer, with Power Automate handling orchestration and notification.
Over 500,000 items: Power Automate should probably be your orchestrator only, not your processor. Use it to trigger a Synapse Analytics pipeline, Azure Batch job, or Databricks notebook. Power Automate is excellent at event-driven trigger logic and business process integration; raw data processing at scale is better served by purpose-built data platforms.
For enterprise-scale deployments, the way you structure and deploy these flows matters as much as the flow logic itself. The article on deploying and managing Power Automate solutions across environments covers how to parameterize your chunk sizes and API endpoints via environment variables so the same flow works in dev, test, and production without code changes.
You've now got the full toolkit for high-volume data processing in Power Automate. Let's consolidate what you've built:
The core pattern is a Do Until loop using take/skip expressions to extract fixed-size chunks from a pre-loaded array. This gives you explicit control over iteration boundaries, failure scope, and progress tracking.
Rate limit management requires a two-layer approach: a proactive layer (fixed inter-chunk delays sized to stay under API limits) and a reactive layer (HTTP 429 detection with Retry-After header parsing or exponential backoff). Neither alone is sufficient for production robustness.
Stateful checkpointing turns a fragile single-run operation into a resumable job. The extra complexity of writing checkpoint data to SharePoint or Dataverse pays for itself the first time your flow fails at record 4,723 of 5,000 and you don't have to start over.
The right chunk size is an engineering calculation, not a guess. Start from your API's rate limits, work backward to a safe sustained request rate, then choose a chunk size and delay combination that achieves that rate.
Graph API batching ($batch endpoint) is the elite move for Microsoft 365 scenarios — it compresses 20 API calls into 1 HTTP action, which is both faster and massively more action-efficient.
Where to go next:
The ability to process large datasets reliably and efficiently separates automation that runs in demos from automation that runs in production. You now have the architecture to build the latter.