Power Automate gets you moving in an afternoon, then quietly punishes you six months later when the same solution holds dozens of flows that nobody can reason about. We learned this on a KPI reporting build for a driving school in Japan: one solution with six Canvas Apps, 35 Power Automate flows, a handful of web resources, and a set of environment variables holding it together. At that size, power automate performance stops being about a single slow action and becomes about whether the whole thing behaves predictably. Without shared conventions, every flow grows its own personality, errors vanish into the void, and the user clicks a button and watches "Processing" spin forever. What follows is the convention set that kept that solution alive.
Design for fewer round trips, not algorithmic elegance
The instinct from regular code is to optimize the algorithm. In Power Automate that instinct is mostly wrong. The thing that costs you wall clock time is not your logic, it is the round trip. Every action in a flow is a call across the network to a connector, and each one carries latency you cannot tune away. Ten cheap actions in sequence will lose to two expensive ones almost every time.
The single biggest offender is a Get rows (or any lookup) sitting inside a loop. If you iterate 500 records and fetch related data once per iteration, you have just signed up for 500 round trips, and the flow will crawl regardless of how tidy the loop body looks. Pull the data once before the loop with a filter that returns everything you need, then work against the in memory result. Batch your writes the same way where the connector allows it. The mental model that serves you well: count the actions that touch the network, and treat that count as your real performance budget. A design that looks redundant on paper, fetching a bit more up front so you never reach back out mid loop, will beat the elegant version that chats with Dataverse on every pass.
One shared return schema for everything
Once you have many flows calling other flows, the question that eats your time is not "did it work" but "how do I find out whether it worked, this time, from this particular flow." Answer it once, globally. Every child flow and every HTTP triggered flow in our solution returns exactly three fields: success, message, and detail. success is the boolean the caller branches on, message is the human readable line you can show a user, and detail carries the structured payload or the error context when you need to dig.
The part that pays off most is extending the same contract past the edge of Power Platform. When we push heavy work to Azure Functions, those functions return the identical { success, message, detail } shape. The caller does not know or care whether a result came from a flow or from a C# function, because both speak the same language. That means one code path on the consuming side, no special casing, no "if this came from the function then parse it differently."
On the Canvas App side this collapses into a clean little pattern. The app runs the flow, reads success, and tells the user, all in one expression:
With(
{ r: ReportGeneration.Run(FiscalYear.SelectedText.Value) },
If(r.success,
Notify(r.message, NotificationType.Success),
Notify(r.message, NotificationType.Error))
)
There is no parsing ceremony, no guessing at field names per flow. Every button in the app follows the same With then If then Notify rhythm, and a new developer can read any one of them after seeing one.
Set Run a Child Flow retry policy to None
This one feels backwards the first time you hear it. The default retry policy on a Run a Child Flow action will quietly rerun a failed child several times before giving up. For a background batch job that might be fine. For anything a person is waiting on, it is the worst possible behavior, because the user has no idea retries are happening. They clicked the button, they see "Processing," and the spinner just keeps going while the platform silently grinds through attempt after attempt.
Set the retry policy to None. If the child fails, let it fail immediately, let the parent catch it, and return success: false so the user gets a clear answer in seconds rather than a minute of false hope. Fast failure with an honest message beats slow success theater. People can retry a button themselves once they know it failed. They cannot do anything useful with a spinner that never resolves.
Try and Catch scopes so a flow never dies unstructured
A flow that throws an unhandled error does not return your nice schema. It returns whatever the platform feels like, the run goes red, and the caller is left holding a result it cannot interpret. The fix is structural and you apply it everywhere: wrap the real logic in a Try scope, then add a Catch scope configured to run only when the previous scope "has failed," "is skipped," or "has timed out."
Inside the Catch scope you do one job: take whatever went wrong, format it into the same { success, message, detail } shape, set success to false, and return it. Now a failure looks exactly like a success to the caller's parsing logic, just with the flag flipped. No flow in the solution is ever allowed to die unstructured. Whether a run sails through or blows up halfway, the thing that comes back is always the contract the caller already knows how to read.
Prevent duplicate launches with a status table mutex
This is the pattern almost nobody adds until it bites them. Dataverse triggers can fire more than once for what looks like a single change. An insert or update event is not the clean one shot signal you assume it is, and if your flow kicks off expensive work on every fire, you get duplicate reports, doubled writes, and racing runs stepping on each other.
We solved it with an explicit lock backed by a table. A Data Processing Status table acts as a mutex, and three small child flows manage it: one to Create a status row, one to Get the current status, and one to Update it. The parent flow owns the protocol. Before doing any real work it calls Get. If a run is already marked in progress, the parent bails out quietly and does nothing. If the slot is free, it Creates or Updates the status to in progress, does the work, and clears the status when finished (including in the Catch scope, so a failure never leaves a stuck lock).
Trigger fires
|
v
Get status --> already running? --> yes --> exit, do nothing
|
no
v
Create/Update status = In Progress
|
v
Do the work
|
v
Update status = Done (also runs in Catch on failure)
It is a few extra actions, but it converts a noisy, multi firing trigger into a single well behaved execution, and it is the difference between a report that generates once and a report that generates three times at 2am.
Cancellation for anything over five minutes
If a unit of work can run longer than about five minutes, you owe the user a way out. Nobody should be trapped watching a flow that may or may not be hung, with no signal and no stop button. Long jobs need a cancellation path: a flag the user can set, or a stage boundary where the flow checks whether it should keep going before committing to the next expensive chunk.
The deeper move is to stop building marathon flows in the first place. Break the long job into stages, split work across smaller flows that each do one bounded piece, and surface progress between them. If a job genuinely needs to run for a very long time, that is a conversation to have with your PM about the right architecture, not a problem to paper over with a homegrown workaround that nobody can maintain. A flow you can cancel and resume is worth far more than one that is slightly faster but impossible to interrupt.
HTTP security on in the final handoff
During development it is tempting to leave the HTTP request and response triggers wide open so you can poke at them with a quick call. Fine for a sandbox. The rule for the shipped version is simple and non negotiable: HTTP security is on for the final handoff. The trigger that exposes a flow over HTTP, and the call out to any external endpoint, both run with authentication enforced.
This pairs with how you pass credentials. Secrets and keys come from environment variables at runtime, never baked into a default value where they would ride along inside the exported solution file. When you call a function, send the key in a header rather than stuffing it into the URL as a query parameter where it leaks into run history and logs. Use a fixed publisher prefix and let connection references and app settings carry the real values. Treat the value itself as xxxxx in anything you write down. A flow that is fast and convenient but quietly authenticates nothing is not done, it is a liability with a green checkmark.
Takeaway
Copy this checklist into your own solution and tick it before you ship a flow:
- Count network round trips, not lines of logic, and never put a Get rows inside a loop.
- Return
{ success, message, detail }from every child flow and every Azure Function so the caller has one code path. - Set Run a Child Flow retry policy to None for anything a user is waiting on.
- Wrap real work in a Try scope and a Catch scope, and always return the schema with
successflipped on failure. - Guard expensive work with a Data Processing Status table mutex, because Dataverse triggers can fire more than once.
- Give any job over five minutes a cancellation path, and prefer splitting long work into stages.
- Ship with HTTP security on, keep secrets in environment variables, and pass keys in headers, not URLs.
None of these are exotic. They are the boring discipline that lets a 35 flow solution stay fast and predictable instead of degrading into a pile of one off flows nobody trusts. If you are staring down a Power Platform build that is starting to sprawl, or you want a second set of eyes on the performance and reliability of one you already have, get in touch and take a look at how we help teams ship Power Platform solutions that hold up at scale.
Top comments (0)