A plug-in that renders one document from one record looks finished. Point it at a saved view holding five hundred rows and press go, and it fails somewhere in the first few dozen, usually at a spot nobody tested for. Four separate ceilings sit behind that failure, and they belong to four different systems: the plug-in sandbox, Dataverse's service protection limits, Power Automate's own action budget, and whatever batch endpoint ends up doing the actual writing.
Each ceiling shows up differently. A plug-in timeout throws an exception and rolls the whole operation back. A service protection breach returns a 429 status with a header specifying how many seconds to wait, and Power Automate can simply stop adding actions to a flow run without throwing any error at all. The architecture that survives five hundred records is the one built by someone who already knows which of those four they're about to hit, rather than the one that finds out in production.
The plug-in sandbox has no room to loop
Dataverse gives a synchronous plug-in two minutes to finish the entire message operation it's attached to, not two minutes per record. Microsoft's API limits documentation is specific about this: the two-minute clock covers every synchronous plug-in involved in that one message, combined. Cross it and Dataverse throws a TimeoutException and rolls back the whole operation rather than only the record that was slow.
That budget disappears fast once you loop. A synchronous plug-in that renders a single document usually finishes in under two seconds, and that's already worth watching. Microsoft's own performance guidance for create and update operations says synchronous plug-ins running longer than two seconds seriously degrade bulk operation performance, which means a plug-in that feels instant on record one is already costing time by record two hundred, long before the two-minute timeout arrives. There's a sandbox message size limit too. A plug-in registered for a message whose payload passes 116.85 MB throws its own error, separate from the timeout. One generated document rarely gets near that. Five hundred of them stitched into a single response can.
The number that matters here is documents per plug-in invocation. A plug-in built to hand back one document keeps every one of these limits comfortably out of reach. Ask it to loop over five hundred in a single pass and all four ceilings for that batch land on one execution instead of five hundred separate ones.
What the plug-in exemption actually covers
Dataverse's service protection limits run per web server on a five-minute sliding window. Microsoft's API limits documentation puts the numbers at 6,000 requests, 1,200,000 milliseconds of combined execution time, which is 20 minutes, and 52 or more concurrent requests, with no window at all on that last one, so it returns an error the moment you cross it. The documentation is also clear that these are default values that can change and vary between environments, so the number in one sandbox isn't guaranteed in the next.
VerseBlocks already went through which of these limits actually get enforced against ordinary API traffic in Three request limits, and only one of them is being enforced. What matters for a plug-in doing bulk document generation is narrower. Plug-ins and custom workflow activities don't count toward the request or concurrency budget at all, because they run inside an isolated sandbox and never touch the public API endpoints those counters watch. That's a genuine reason to move chatty logic into a plug-in rather than call the Web API five hundred times from outside.
The exemption stops at the clock. Microsoft's documentation says the extra computation time a plug-in spends gets added back onto the request that triggered it, and that combined time still counts toward the 20-minute execution budget for the web server. That's the detail people miss when they read that plug-ins are exempt from service protection and stop reading there.
Power Automate adds its own set of ceilings
A flow that lists rows from Dataverse and loops over them inherits limits of its own, separate from anything Dataverse enforces. The list rows action returns 5,000 rows by default if pagination isn't turned on, and 5,000 is also the maximum page size per request for both standard and elastic tables, according to Microsoft's Power Automate documentation. Ask for more in one page and the service ignores the extra and caps the response at 5,000. Getting past that number means paging through the nextLink pattern rather than raising a setting.
Apply to each runs sequentially by default, one iteration at a time, with concurrency set to 1. It can go as high as 50, and for five hundred records that's the difference between a flow finishing in minutes and one still running an hour later. Microsoft's coding guidelines for optimising triggers document both figures. There's a ceiling on the array too: a Low-tier licence caps Apply to each at 5,000 items, every other licence goes to 100,000, so five hundred records sits well clear of either number.
Where volume actually bites is the action burst limit. Microsoft's guidance on understanding limits sets it at 100,000 actions in any five-minute rolling window, independent of licence. Every action inside a loop counts once per iteration, so five hundred records running four or five actions each adds up quickly once that flow shares a five-minute window with anything else in the environment. Underneath that sits a daily allocation that depends on what's licensing the flow. A Power Automate Premium per-user licence carries 40,000 requests in 24 hours, according to Microsoft's request limits and allocations documentation. A Process licence carries 250,000, and up to ten Process licences can stack on a single flow, each adding another 250,000, though a stacked flow still can't exceed 100,000 requests in any five-minute window. Five hundred documents won't come close to the daily figure on any licence. The five-minute burst limit sitting underneath it is what decides whether a flow built for five hundred survives being pointed at five thousand.
Batches change where the ceiling sits
ExecuteMultiple and the Web API's $batch endpoint both cap out at 1,000 operations per request, and batches can't nest inside each other. That looks like an answer to the loop problem: package five hundred document requests into one batch call and stay well under the cap. Microsoft's own guidance for this endpoint says otherwise. Most scenarios run fastest with single requests sent at high parallelism rather than one large batch, because bigger batches trade the request-count ceiling for the execution-time one, and a batch of five hundred operations still has to finish inside the same clock a plug-in does. The starting point Microsoft recommends is a batch size of 10 operations, raising concurrency from there until service protection errors start showing up that are worth retrying.
CreateMultiple and UpdateMultiple sit above ExecuteMultiple in throughput, with Microsoft recommending a batch size of 100 records for elastic tables. They come with two catches that matter here. They aren't supported inside plug-ins at all, only from external client applications, and they aren't available for every standard table. Custom tables get them; core tables like Account and Contact don't. A document generation process that lives entirely inside a plug-in can't reach these APIs no matter how it's restructured, which is one more reason the loop needs to move outside the sandbox rather than get tuned inside it.
Push any of this too hard and Dataverse says so plainly. A service protection breach returns a 429 status with a Retry-After header stating how many seconds to wait, and Microsoft's guidance is to treat that number as authoritative rather than build custom backoff math around the debug headers that ride along on every request. Start at a lower request rate than seems necessary and let the server's response set the pace from there. Dataverse also returns a recommended degree of parallelism in the response header for bulk operations, and running above that number slows the process down.
For work that's going to outgrow a synchronous plug-in or a single flow run, Microsoft's own documentation points elsewhere. The asynchronous service is a first-in-first-out queue built for exactly this, tracking each job in the System Job table, so five hundred document generations become five hundred tracked operations instead of one long loop. Azure Functions, triggered off a queue or a timer rather than a live request, is where Microsoft sends people for background work that needs to scale past what plug-in and flow limits were ever designed to hold, and Durable Functions extends that further for work that needs real orchestration across steps.
What decides whether an architecture holds at the two-hundred-record mark is the degree of parallelism Dataverse hands back in its own response header, and whether every client hitting the environment honours it. Two clients sharing a DOP of 50 split it honestly, at 25 each, rather than each client assuming the full 50 for itself. That's the number that decides whether five hundred records finish cleanly or hit the same ceiling the single-record version of this process never got close to.