The case for leaving Azure Functions used to fit in one sentence, and Flex Consumption deleted it: on the plan Microsoft now recommends for new serverless work, the maximum execution timeout is unbounded. What that leaves behind is the harder question. Which limits survived the plan that was supposed to remove them, and are those the kind you engineer around or the kind you migrate away from?
None of the survivors are about execution time. They are 230 seconds for any HTTP response, on every plan; 4 GB of memory across exactly three instance sizes; a scale-out rate that gets slower the busier your app already is; and a plan boundary that ends up deciding your team topology whether you wanted it to or not. Those are structural limits, and functionTimeout moves none of them.
This is written about apps that already work. Nothing below argues Functions was the wrong call, and most of the Functions code running in production today should stay exactly where it is. The argument starts at the point where the hosting model begins making architectural decisions on your behalf. Where this series goes when that happens is Azure Container Apps with Dapr and .NET Aspire; whether you are actually there yet is the thing worth settling first.
The plan everyone is arguing about no longer exists
If your mental model of Functions hosting is "Consumption or Premium," it is a generation out of date. The Consumption plan now carries a legacy banner in its own documentation, Flex Consumption is the recommended serverless plan for new function apps, and Linux Consumption hosting retires on 30 September 2028, receiving no new features or language versions before then. Apps running on Windows in a Consumption plan are explicitly not affected yet, so keep the Linux qualifier when you repeat this to your platform team.
That changes the maximum execution time picture completely:
- Flex Consumption: 30 minutes by default, unbounded maximum
- Premium (Elastic Premium): 30 minutes by default, unbounded maximum
- Dedicated (App Service): 30 minutes by default, unbounded with Always On
- Container Apps: 30 minutes by default, unbounded when min replicas is 1 or more
- Consumption (legacy): 5 minutes by default, 10 minutes maximum
The famous ten-minute wall sits on exactly one row of that list, and it is the row Microsoft is retiring. If you are still hitting it, the answer is a hosting change rather than an architecture change. Moving to Flex is not a plan toggle (there is no in-place migration, a point that comes back later), but it is a redeploy, not a rewrite.
Everything after this section is about the limits that a redeploy will not fix.
The enterprise ceiling
Timeouts that are unbounded until they aren't
"Unbounded" carries two asterisks, and both of them bite in production.
The first is that no HTTP response can take longer than 230 seconds, on any plan, ever. That number is not a Functions setting: it is the Azure Load Balancer idle timeout sitting in front of your app, so functionTimeout cannot raise it and no host.json value will help. A function that runs for 25 minutes is fine. A function that runs for 25 minutes and answers an HTTP request at the end of it is not, and the failure is asymmetric: the caller's connection is dropped while your function carries on executing, entirely unaware that nobody is listening.
The documented way around it is the Durable async HTTP pattern: accept the request, start the work, hand back somewhere to poll.
[Function("StartMonthlyReport")]
public static async Task<HttpResponseData> Start(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "reports/monthly")]
HttpRequestData req,
[DurableClient] DurableTaskClient client)
{
var request = await req.ReadFromJsonAsync<ReportRequest>()
?? throw new InvalidOperationException("Report request body was empty.");
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
nameof(MonthlyReportOrchestrator), request);
// 202 Accepted plus statusQueryGetUri, terminatePostUri, and friends.
// The HTTP request finishes in milliseconds. The report does not.
return await client.CreateCheckStatusResponseAsync(req, instanceId);
}
This works, and it is the right pattern. Count what it costs, though. You have added a Durable task hub with its own storage account and transaction bill, a status contract every caller now has to implement, a polling loop in each of those callers, and an orchestration whose replay semantics you have to test differently from ordinary code (the previous series spent a whole article on exactly that). A 40-second synchronous call became a distributed workflow because of a load balancer setting.
The second asterisk is that unbounded execution is bounded anyway. Scale-in gives a running instance a 60-minute grace period on Flex and Premium; platform updates give it 10 minutes. A three-hour job is not safe on Functions even where the docs print the word "unbounded" next to your plan. Premium stacks three more interrupts on top: an idle timer stops the worker after 60 minutes with no new executions, scale-in can shut a worker down after the same 60 minutes, and a slot swap terminates executions on both the source and target slots.
Read together, those give you long-running execution, not guaranteed-to-finish execution. If your workload cannot tolerate being killed mid-flight, you owe it checkpointing regardless of which plan you are on.
4 GB is the ceiling, and there are only three rungs
Flex Consumption offers three instance sizes. Not a range, not a slider. Three:
- 512 MB with 0.25 CPU cores
- 2,048 MB with 1 core (the documented default recommendation)
- 4,096 MB with 2 cores
There is a further 272 MB of platform buffer memory on top of each size, which you are not billed for. Nothing exists between 512 MB and 2,048 MB, so an app whose working set peaks at 700 MB buys the 2 GB rung and pays for the other 1.3 GB on every billed second of execution.
Container Apps allocates the same two resources in fixed pairs, from 0.25 vCPU / 0.5 GiB up to 4.0 vCPU / 8.0 GiB in quarter-core steps, with memory always at 2 GiB per vCPU. That is 16 rungs where Flex has three, and it goes twice as high on both axes. (A Consumption-only Container Apps environment caps at 2 cores / 4 GiB, which lands it back at parity with Flex, so the workload profile you choose matters.)
The consequence is worth stating without hedging: a serverless workload that needs more than 4 GB has no Functions answer. Your options are Premium EP2 (2 cores / 7 GB) or EP3 (4 cores / 14 GB), or Dedicated. All of them bill continuously whether or not anything is executing, which means the memory limit quietly converts a serverless cost model into an always-on one. That is not a scaling decision anymore. It is a budget decision, made for you by an instance-size table.
Cold start is a scale-out problem, not an idle problem
Almost every cold-start discussion frames it as an idle problem: nobody called your app for a while, the platform reclaimed the instance, the next caller pays for a fresh start. That framing is why cold start gets dismissed as a low-traffic concern, and it hides the case that actually breaks a latency SLA.
Reframe it. Every instance the platform adds during a spike is a cold start. A busy app that goes from 20 instances to 60 in ninety seconds just paid 40 cold starts, and it paid them at the exact moment its p99 was already under pressure.
That would be tolerable if the platform added instances at a constant rate. It does not. Flex Consumption applies a documented scale-out rate curve where the per-interval allowance shrinks as your app grows:
When an app is running only a few instances, the allowance is at its largest... As an app grows to run more and more instances, the platform grants each additional batch more gradually.
Microsoft deliberately does not publish per-interval numbers and tells you not to design against them, so take the shape rather than the arithmetic: scale-out is fastest when you need it least, and most measured when you need it most. That is the inverse of what a burst-shaped traffic profile asks for.
There is one documented exemption. Always-ready instances bypass the on-demand scale-out rate entirely, which makes them the supported way to pre-provision for a burst you can predict. The default is 0, so a Flex app that nobody has configured behaves like legacy Consumption at idle, and plenty of Flex apps in production have never been configured.
# Two HTTP instances stay running ahead of the burst. They are exempt from the
# on-demand scale-out rate curve, and they bill whether traffic arrives or not.
az functionapp create \
--name orders-api \
--resource-group rg-orders \
--storage-account stordersapi \
--flexconsumption-location eastus \
--runtime dotnet-isolated \
--instance-memory 2048 \
--always-ready-instances http=2
# Push per-instance HTTP concurrency past the size-derived default of 16,
# so each warm instance absorbs more of the spike before a new one is needed.
az functionapp scale config set \
--name orders-api \
--resource-group rg-orders \
--trigger-type http \
--trigger-settings perInstanceConcurrency=32
Those two commands are the whole mitigation, and they are also the whole trade. Always-ready capacity has its own billing meter that runs continuously, and unlike on-demand execution it gets no free grant at all. You are buying your way off the scale curve, which is a reasonable thing to do; it is just no longer scale-to-zero.
Premium's equivalent works differently enough to be worth naming, because the word "prewarmed" oversells it. A prewarmed instance only becomes active once all your currently active instances are in use, so it is a buffer covering the next instance, not a reserve that absorbs a burst. Always-ready is Premium's floor; prewarmed is a single step of headroom above it.
One number is missing from all of this on purpose. Microsoft publishes no cold-start latency figure for any plan, and the independent measurements floating around are browser-timed, meaning they include connection setup and TLS on top of whatever the platform actually did. If you need a number for an SLA conversation, measure your own app on your own plan and label it as your measurement. Anything else you have read is somebody else's network.
The scaling gaps nobody reads until they page you
The headline scale-out numbers look generous. Flex Consumption reaches 1,000 instances; Container Apps reaches 1,000 replicas, or 300 if you created the app from the portal; Premium reaches 100 on Windows and somewhere between 20 and 100 on Linux depending on the region; Dedicated manages 10 to 30, or 100 on an App Service Environment; legacy Consumption does 200 on Windows and 100 on Linux.
Five things sit underneath those numbers, and each one has paged somebody.
The defaults are nowhere near the maximums. Flex ships with a default max of 100, not 1,000. Container Apps ships with a default max of 10 replicas. Neither of those is the number in the marketing table, and neither changes because your traffic grew.
Your configured maximum is a request, not a promise. Flex Consumption enforces a regional subscription memory quota of 250 cores and 512,000 MB per region per subscription, shared across every Flex app you run there. Do the arithmetic on a single app configured at the recommended 2,048 MB size: 250 instances multiplied by 2,048 MB is 512,000 MB, and the quota is gone. One 512 MB app at its full 1,000 instances exhausts it just as completely. Your app can be sized correctly, configured correctly, and still stop scaling because a different team's Flex app in the same region and subscription got there first.
Private endpoints cap you at 20 instances. HTTP triggers behind a private endpoint scale no further than that, whatever your plan says. It is a networking decision, usually made by a different team, that silently becomes a throughput decision.
Concurrency is derived from a size you may have picked for memory reasons. Default per-instance HTTP concurrency on Flex is 4 requests at 512 MB, 16 at 2,048 MB, and 32 at 4,096 MB. (Python is 1 at every size.) The tie between the two settings is the trap: set concurrency explicitly and it stops tracking instance size forever, so the next engineer who resizes the app for memory gets none of the concurrency change they expected.
The last one is the least known and the most likely to invalidate a capacity plan. Per-function scaling is really per-group: all your http triggers scale together on shared instances, all your Event Grid blob triggers scale together, all your durable triggers scale together, and everything else scales individually. Your configured maximum then applies per group, not per app. An app set to 100 instances with HTTP, blob, and Durable triggers is not budgeted for 100 instances. It is budgeted for up to 300, and the bill arrives shaped accordingly.
Organizational challenges
The function app is the unit of scale, deployment, and blame
Resource limits are negotiable. You can buy your way past most of them, and the previous section was mostly about what that costs. The limits in this section are different, because no amount of money moves them: they are shape, not size.
Start with the one that surprises people who came to Functions from microservices. The function app, not the function, is the unit of scale. On Consumption and Premium every function inside an app shares the host instance and therefore shares its memory allocation. One PDF-rendering function that peaks at 3 GB drags every lightweight HTTP endpoint in the same app up onto a larger SKU with it, and those endpoints pay the higher rate on every instance, forever, for a function they never call.
The same boundary is also the unit of deployment. Two teams sharing a function app ship together and roll back together, which means one team's bad Friday afternoon is the other team's incident. They also share a single host.json, so logging levels, concurrency settings, retry policy, and extension bundle versions are one negotiation rather than two decisions.
And it is the unit of blame, which is the part that shows up in retrospectives rather than architecture diagrams. When the app throttles, the telemetry says the app throttled. Attributing that to the function that consumed the instance is work you do by hand, after the fact, under time pressure.
The obvious fix is to split the app, one per team. That fix has a bill attached, because splitting apps multiplies plans, and on Premium each new plan brings its own always-on floor. The organizational boundary you want costs real money to draw.
Premium couples every team through the plan
Premium lets you put up to 100 function apps in one App Service Plan, which reads like a cost optimization and behaves like a coupling.
The mechanic is in the Premium plan documentation, stated plainly. A plan's minimum instance count is the maximum of its apps' always-ready counts. Put three apps in one plan, set two of them to 1 always-ready instance and the third to 5, and the plan minimum becomes 5: "the minimum number of instances for which your plan is billed."
Follow that through to the org chart. The checkout team has a latency requirement and sets 5 always-ready instances to meet it. The reporting team, sharing the plan, is now billed against a floor of 5 as well, and nothing in the reporting team's own configuration explains why their number went up. Their always-ready setting still reads 1. The cost lives in a resource neither team owns, driven by a value neither team can see from where they work.
Flex Consumption inverts the whole arrangement: one function app per plan, full stop. That kills the noisy-neighbour problem outright, and it kills the shared-floor billing with it. It also removes a capability in the same stroke, which is the subject of the next section.
Deployment coupling and the doors that only open one way
Flex Consumption has no deployment slots. Premium gives you 3, legacy Consumption 2, Dedicated up to 20, and the recommended plan gives you none. If your zero-downtime story was "deploy to staging, warm it, swap," that story needs rewriting. The replacement is the rolling update site strategy, and two facts about it shape your release process before its behaviour does. It is generally available in East Asia, West Central US, North Central US, and West US 2 while the rollout to every other region continues, so check your region. And it can only be configured from Bicep or ARM templates, not the CLI, not the portal, and not VS Code, so whoever owns your deployment templates owns this decision.
There is also no in-place migration into or out of Flex Consumption. You create a new function app, redeploy, and cut over: DNS, keys, private endpoints, managed identity role assignments, the lot. That makes the plan choice a one-way door in both directions, which is an unusual property for a setting that looks like a dropdown.
The third door is the one that turns a runtime concern into a deployment failure. Flex Consumption times out app initialization after 30 seconds, the value is not configurable, and when you exceed it the symptom is a gRPC System.TimeoutException rather than anything mentioning startup. Everything your Program.cs does eagerly happens inside that window.
var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// Cheap to register, expensive to build. This one opens a blob connection and
// blocks the startup thread on it, inside the same 30-second budget.
builder.Services.AddSingleton<IOrderPricingCache>(sp =>
OrderPricingCache.LoadFromBlobAsync(sp.GetRequiredService<BlobServiceClient>())
.GetAwaiter().GetResult());
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Orders")));
builder.Build().Run();
A pricing cache that loads from blob storage in 8 seconds locally can take considerably longer from a cold instance against a throttled account, and there is no knob to buy more time. The fix is to make startup lazy (resolve the cache on first use, not at registration) rather than to make it faster, because "faster" has no floor you control. Discussion and workarounds are tracked in azure-functions-host#10482.
None of these three is a scaling limit. They are all deployment shape, and deployment shape is what your release process is built out of.
Debugging across triggers
One order arrives on an HTTP trigger, drops a message that a queue trigger picks up to reserve inventory, and finishes inside a Durable orchestration that handles fulfilment. That is one business transaction and three execution models. Split those functions across apps for the team-boundary reasons above and you have also split them across Application Insights resources, at which point no single query can see the whole transaction.
The Durable half of this is solvable, and the testing and monitoring article from the previous series covers the KQL for reconstructing one instance's history, including why isReplay has to be in the filter. That technique still works here. It just stops at the app boundary, because correlation across a queue hop is plumbing you write yourself and keep writing yourself at every trigger transition.
When migration makes sense
Every limit in the two sections above has a workaround, and most of those workarounds are defensible engineering. That is what makes the decision awkward. No single number tells you it is time to leave. What you get instead is a set of signals, and the useful question is not whether one of them is true but how many are true at once.
Signals you can check against your own system
You are chaining Durable Functions to dodge a limit rather than to model a workflow. An orchestration that exists because the business process genuinely has steps, compensations, and a lifetime measured in hours is Durable Functions doing the job it was built for. An orchestration that exists because a 40-second call cannot answer over HTTP is the 230-second constraint wearing a workflow costume, and you keep paying for the disguise in task hubs, polling contracts, and replay-aware tests.
A single function is choosing the instance size for every function beside it. If your size is set by one report renderer and the other eleven endpoints in the app are billed at that size on every instance they run on, the app boundary has stopped describing anything about your system except its worst-case memory.
Your working set has passed 4 GB, or your dependency list has passed what you can install without a container. Neither of those is a slope you can climb with configuration. There is no larger Flex instance to buy, and there is no package-install step in the sandbox.
Your p99 degrades during scale-out and always-ready capacity costs more than the latency is worth to the business. Always-ready instances work. The question is whether the number of them you need has quietly turned a serverless app into a fleet you are sizing by hand.
Team boundaries and function app boundaries have stopped lining up. The split that would fix it is the one that multiplies plans and billed floors, which is why it keeps getting deferred to next quarter. When the org chart and the resource graph disagree, one of them wins, and it is rarely the one on the wiki.
You need a capability the sandbox does not have. A GPU, a sidecar, an init container, mTLS between services. These are not limits you tune around. They are absences.
Counting matters more than any individual signal. One is a configuration problem: raise the instance size, buy always-ready capacity, split the app. Two or three usually mean a redesign inside Functions is still cheaper than leaving it. Once four or more are true, and the true ones are the structural kind (memory, boundaries, deployment shape), the choice is no longer between Functions and containers. It is between an accumulating pile of workarounds and one migration.
One more item belongs on that scale, and it is the point the plan section left hanging. Because Flex Consumption supports no in-place migration in either direction, any plan change is already a new function app, a redeploy, and a full cutover: DNS, keys, private endpoints, and every managed identity role assignment. Price the two moves next to each other. Getting from legacy Consumption onto Flex costs a new resource and a cutover. Getting onto Container Apps costs a new resource, a cutover, and a Dockerfile. Most of the migration people hesitate over is work they committed to the moment Flex became the recommended plan.
The case for staying
Most Functions code in production should not move, and the reasons are better than inertia.
Event-driven glue is what the programming model is for, and nothing about a container improves it. A queue trigger that validates a message and writes a row is a dozen lines with a binding and a hundred with a hosted service, a client, a lease policy, and a retry loop you now own and now have to test. Scheduled cleanup jobs, webhook receivers, and low-traffic internal APIs land in the same place: the trigger is the interesting part of the code, and Functions hands it to you already written.
The free grants are not a rounding error either. Flex Consumption includes 250,000 executions and 100,000 GB-seconds per subscription per month. A 2 GB function that runs for two seconds burns 4 GB-seconds, so the grant absorbs 25,000 of those runs before execution time costs anything at all. Container Apps has a grant too, 180,000 vCPU-seconds, 360,000 GiB-seconds, and 2,000,000 requests, but a single replica pinned at one vCPU consumes 180,000 vCPU-seconds in about 50 hours. A grant sized against bursts and a grant sized against a continuously running replica behave very differently by the end of the month.
Then there is duty cycle, which the next section turns into arithmetic. If your workload is spiky, short, and idle for most of the day, Functions is not a compromise you are tolerating. It is the correct answer, and a Container Apps replica held at one min replica would be a downgrade you paid extra for.
The last reason never appears on a pricing page. A migration costs your team its next two quarters: a new deployment pipeline, new observability wiring, new runbooks, and a set of failure modes nobody has met yet. Spending that to escape a limit you could have configured your way past is the most expensive mistake available anywhere in this decision.
The cost crossover, honestly
The memory section left a claim unpaid. Every escape hatch above 4 GB bills continuously; so does every serious mitigation for scale-out latency; so does a Container Apps replica with min replicas set to 1. Once scale-to-zero has left both sides of the comparison, the argument stops being serverless against containers and becomes arithmetic about what fraction of the month your code is actually running.
The anchor number first. One always-on Premium EP1 instance (1 core, 3.5 GB) costs about $157.71 a month in East US before a single execution runs, at $0.173 per vCPU-hour and $0.0123 per GiB-hour across 730 hours. A Container Apps replica of comparable shape (1 vCPU / 2 GiB), billed at active rates every second of that month, is $78.84. A Flex always-ready baseline instance at 2,048 MB is $21.02, plus execution time on top of it. Those three numbers are most of the reason Premium keeps showing up in cost reviews.
The crossover is more useful than any of them, because it tells you which side of the line your workload sits on. Here is the whole calculation, using pay-as-you-go rates pulled from the Azure Retail Prices API on 10 August 2026:
East US
Container Apps, 1 vCPU / 2 GiB, billed every second the replica exists:
1 x $0.000024 + 2 x $0.000003 = $0.000030 per second
Flex Consumption, 2,048 MB instance, billed only while code executes:
2 x $0.000026 = $0.000052 per second of execution
Break-even duty cycle: 0.000030 / 0.000052 = 57.7%
Above roughly 58% duty cycle in East US, an always-on Container Apps replica costs less than Flex Consumption on demand for the same compute shape. Below it, scale-to-zero wins.
Now run the identical calculation in another region, because this is the part that catches people out. Flex Consumption is priced the same in West Europe as in East US, to the sixth decimal place. Container Apps is not: vCPU is 42% more expensive there.
West Europe
Container Apps, 1 vCPU / 2 GiB:
1 x $0.000034 + 2 x $0.000004 = $0.000042 per second
Flex Consumption, 2,048 MB: = $0.000052 per second of execution
Break-even duty cycle: 0.000042 / 0.000052 = 80.8%
The same two services, the same workload shape, and the crossover moves from 58% to 81%. A quoted crossover figure with no region attached is not a rough number; it is a wrong one.
Both of those percentages are my arithmetic, not a Microsoft-published figure, and they are only as good as their inputs. They use list pay-as-you-go rates for two regions on one date. They exclude the free grants, per-execution and per-request charges, storage, Application Insights, networking, and the Container Apps idle rate (vCPU idle is eight times cheaper than active in East US, while memory is billed at the same rate idle or active in both regions). They also assume the two platforms get equivalent throughput from a core, which is a property of your workload rather than of Azure. Treat 58% as a starting point for modelling your own numbers, not a constant to quote in a design review.
One billing mechanic deserves its own sentence, because it wrecks the intuition behind these percentages for short functions. Flex Consumption's minimum billable execution period is 1,000 ms, rounding up to the nearest 100 ms after that. A function that finishes in 40 ms bills as if it took a full second, which means an app made of very short executions is running at a much higher effective duty cycle than its telemetry suggests.
The cloud-native alternative
Naming the destination matters less than naming what it fixes. Container Apps is the runtime, Dapr is the distributed-systems toolkit that replaces what bindings were doing, and Aspire is how you keep the local development loop from getting worse. Only the first of those is a hosting decision.
Container Apps
Take the limits from the first half of this article and check them off one at a time. Execution time is unbounded when min replicas is 1 or more. CPU and memory come in sixteen steps from 0.25 vCPU / 0.5 GiB to 4.0 vCPU / 8.0 GiB rather than three, so an app that peaks at 700 MB buys a 0.5 vCPU / 1 GiB pairing instead of the 2 GB rung. Scaling is KEDA, which means HTTP and TCP rules, CPU and memory rules, and any custom scaler in the KEDA catalogue, instead of platform heuristics you cannot inspect. Deployment safety comes from revisions with traffic splitting, which does blue/green and A/B rather than the single staging swap slots gave you. Custom images and GPU exist. Sidecars and init containers exist.
Three things go the other way and should be said in the same breath. The 230-second HTTP wall does not vanish, it moves: Container Apps times a request out at its own ingress after 240 seconds, and the only supported way past that is premium ingress on a dedicated workload profile, where the limit becomes a configurable idle timeout of up to 30 minutes. Ten extra seconds is not an answer to the problem that sent you looking. An app that scales on CPU or memory load cannot scale to zero at all, so the KEDA flexibility buys you a floor in exactly the cases where you were hoping it would not. And the container image limit is 8 GB per replica on a Consumption workload profile, which is generous until somebody bakes a model into the image.
The fact that de-risks this whole conversation belongs here rather than at the end: function apps run inside Container Apps. You deploy the same app as a container app with --kind functionapp, and the platform generates KEDA scale rules from the triggers you already have instead of making you rewrite them as scale rules by hand.
# The same function app, hosted on Container Apps. The triggers stay; the platform
# translates them into KEDA scale rules unless allowScalingRuleOverride opts out.
az containerapp create \
--name orders-api \
--resource-group rg-orders \
--environment cae-orders \
--kind functionapp \
--storage-account stordersapi \
--image acrorders.azurecr.io/orders-api:2026.08.14 \
--ingress external \
--target-port 80 \
--min-replicas 1 \
--max-replicas 50
That makes migration incremental rather than a cutover weekend, which matters more than any capability in the list above.
It is not free of holes, and the gaps are documented. Durable Functions autoscaling only works with the MSSQL or Durable Task Scheduler storage providers, so a Durable app on the default Azure Storage provider scales on the rules you write yourself. Blob-trigger autoscaling requires Event Grid. There is no autoscaling at all for the Azure Cache for Redis or Azure SQL triggers. Deployment slots do not exist, and neither do portal-generated Functions access keys. If your release process or your on-call tooling depends on any of those, the container-hosted version is a step backwards until you replace them.
Dapr
Triggers and bindings are an excellent deal right up to the day you want the same message-publishing code to run inside a service, a console app, and a test. Then the binding turns out to be a property of the host rather than of your code, and there is no version of that code which runs anywhere else unchanged.
Dapr moves that contract into a sidecar your app talks to over HTTP or gRPC. Which broker, which state store, which secret store: all of it becomes component configuration attached to the environment instead of an attribute on a method signature. The building blocks that are GA in Container Apps cover service invocation, state management, publish/subscribe, bindings, actors, secrets, and configuration, which is most of what a bindings-heavy function app was doing, plus a virtual actor model that Functions has no answer for outside Durable entities.
The sidecar is not everywhere, though. Dapr is not supported for Container Apps jobs, so anything you were going to model as a scheduled job runs without it. Actor reminders are the other edge: they require min replicas of at least 1, which removes scale-to-zero from any actor-based design before you have written a line of it.
.NET Aspire
Aspire is the least dramatic piece here and the easiest to justify, because adopting it does not require you to have containerized anything. Aspire integrates with Azure Functions directly, so an existing function app can join an AppHost alongside the queue, the database, and whichever service you are building next, and a single F5 starts all of them with the connection wiring already generated. That makes Aspire the bridge into this stack rather than the reward at the end of it: you can run it against the architecture you have while you are still deciding whether to change it.
What the rest of this series does
Seven articles follow this one.
- Introduction to Dapr for Azure Developers: the building blocks, and how they map onto Azure services you already run.
- Building Your First Dapr + Web API Service: .NET 10 Minimal APIs, service invocation, and state management end to end.
- .NET Aspire: Orchestrating Cloud-Native Apps: AppHost, service defaults, the local loop, and wiring Dapr into it.
- Migration Strategy: Incremental Transition Patterns: strangler fig, Functions and containers running side by side, and the data migration nobody budgets for.
- Enterprise Architecture: Front Door, API Management, Container Apps: traffic management, gateway policy, and the Managed Identity and RBAC patterns underneath.
- Infrastructure as Code: Terraform for Your New Stack: module structure for Container Apps, networking, and CI/CD.
- The Complete Picture: A Reference Architecture: the assembled stack, what it costs to operate, and what did not survive contact with production.
Conclusion
The ceiling moved, and it moved somewhere harder to see. When the argument was about time you could tell the moment you hit it: a job died, the log said so, and the fix had a name. What replaced it never announces itself. An instance-size table quietly sets your monthly bill. A plan boundary decides which two teams share an incident, and whether you have a deployment slot determines what your release process is allowed to promise. None of those throw an exception, and all of them compound.
That is also why the honest answer for most readers is to stay put. If your functions are event-driven glue running for a few hundred milliseconds a few thousand times a day, the free grant probably covers the bill, everything above is theoretical, and migrating would buy you a Dockerfile and a worse on-call rota. The case for moving arrives when the workarounds stop being individual decisions and start being an architecture, when what you built to stay inside the hosting model has grown larger than the thing it hosts.
The rest of this series assumes you got there. Dapr for the building blocks, Aspire for the local loop, Container Apps for the runtime, Terraform to make it reproducible. None of it requires switching everything off first, which is the whole reason the migration article keeps Functions running alongside the containers rather than replacing them.
When your Functions app hit its ceiling, was it a resource limit (memory, timeout, scale-out) or an organizational one (two teams stuck inside one deployment)?

Top comments (0)