Azure Functions on .NET 10: Why the isolated worker model is now the only story
If you last touched Azure Functions when C# functions were a class library loaded into the Functions host process, .NET 10's message is stark: that model β in-process β is capped at .NET 8 forever. From .NET 9 onward, and for .NET 10, C# Functions run in the isolated worker model: your code is a separate process the host launches and speaks to. This article maps the two models, shows what changes in code you actually write, and explains why Microsoft's break was the right call.
The two models in one table
| In-process (legacy) | Isolated worker | |
|---|---|---|
| Runtime | Your classes load into the Functions host | Separate process; host spawns your dotnet.exe and proxies calls |
| .NET versions | .NET Framework 4.8, .NET 6/8 only β hard cap | .NET Framework 4.8, .NET 8/9/10 β anything current |
| Binding contract | Attributes + framework types
(HttpRequest, IActionResult) | Attributes +
worker types (HttpRequestData,
HttpResponseData) or typed over HTTP |
| Dependency injection | Optional (WebJobs builder), easy to skip | Default β DI is the wiring model, not an opt-in |
| App settings | ConfigurationManager plus options patterns |
Standard Microsoft.Extensions.Configuration |
| Startup cost | Fast (same process) | Slightly slower (process hop), improved by worker reuse |
| Couple of quirks | Host's dependencies are yours; version collisions are the classic outage | You own every dependency; no host collisions possible |
Why isolated had to win
The in-process design looked efficient β one process, no serialization β but it chained Functions to the host's runtime. Every ASP.NET Core or WebJobs version the host shipped was a version your app had to tolerate; AssemblyLoading conflicts were a genre of support ticket. Meanwhile .NET itself moved to a cadence in which each major version is a new runtime. A host that must load your code can only support runtimes it was built against β hence the .NET 8 ceiling. The isolated worker inverts the contract: the host stops loading your code and starts orchestrating a process that speaks a small gRPC-ish protocol. The host no longer cares which runtime the worker runs β which is why .NET 10 support arrived as "run your worker on .NET 10" rather than a host release.
What the code looks like now
dotnet new func --name McpFunction # isolated template on .NET 10
# Program.cs - the entire model in 20 lines
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults() // worker middleware pipeline
.ConfigureServices(services =>
{
// Standard generic-host DI - the same code as any worker service.
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
services.AddHttpClient<IGenericApiClient, GenericApiClient>();
services.AddSingleton<IKeyVaultCredentialProvider, KeyVaultCredentialProvider>();
})
.Build();
host.Run();
// A function: [Function] names it; the trigger attribute binds. Typed input,
// HttpResponseData out - no IActionResult, no HttpContext, no WebJobs types.
public class StatusFunctions(IGenericApiClient api)
{
[Function("GetStatus")]
public async Task<HttpResponseData> GetStatus(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "status/{id}")]
HttpRequestData req, string id)
{
var status = await api.GetStatusAsync(id);
var resp = req.CreateResponse(System.Net.HttpStatusCode.OK);
await resp.WriteAsJsonAsync(status);
return resp;
}
}
The differences that actually bite
- No
HttpContext, different HTTP types. In-process ASP.NET Core integration used the realHttpRequest/IActionResult; the worker model's HTTP isHttpRequestData/HttpResponseData(or typed bindings on .NET 10, below). Migration work is mostly mechanical retyping of this surface. - Serialization is yours. The host does not deserialize your JSON;
WriteAsJsonAsyncuses your DI-configured options β setJsonSerializerOptionsinConfigureServiceslike any worker service. - middleware instead of filters. Cross-cutting concerns move from MVC
filters to worker middleware β the observable override of
ConfigureFunctionsWorkerDefaultsis where you wrap the whole pipeline. - local.settings.json still rules locally (git-ignored, samples checked in) while app settings rule in Azure β same as before, now consumed through standard configuration providers.
Typed bindings and the modern conveniences
.NET 8+ isolated model grew the features that make the migration feel like an upgrade:
typed HTTP results (return Results.Ok(dto); from functions β
no HttpResponseData plumbing), input bindings as parameters
(queue/blob/table payloads as POCOs), and first-class support for the extension ecosystem β
our MCP extension
(Microsoft.Azure.Functions.Worker.Extensions.Mcp), for one, is
isolated-only, as are several newer extensions. The extension gap closed in the worker
model's favor.
// .NET 10 isolated: typed request AND response, POCO binding, no plumbing
public record IssueReport(string Id, string Severity);
[Function("IngestReport")]
public async Task<Results<Ok<string>, BadRequest<string>>> Ingest(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
[QueueOutput("reports", Connection = "MapleCartStorage")] out IssueReport? queued)
{
var report = await req.ReadFromJsonAsync<IssueReport>();
if (report is null) { queued = null; return "bad payload"; } // BadRequest
queued = report; // Ok
return "queued";
}
// And the same POCO binds as INPUT on a queue trigger - symmetric, typed:
[Function("ProcessReport")]
public async Task Process(
[QueueTrigger("reports", Connection = "MapleCartStorage")] IssueReport report)
=> await _handler.HandleAsync(report);
Migrating an in-process app: the short version
- Switch the SDK:
Microsoft.NET.Sdk.FunctionsβMicrosoft.Azure.Functions.Worker.Sdk, addMicrosoft.Azure.Functions.Workerplus per-trigger extension packages. - Add
Program.cswith the generic host (DI, config, Insights) β the file above. - Retype bindings:
HttpRequest/IActionResultβHttpRequestData/HttpResponseDataor typedResults<,>; timer/queue/blob signatures change shape but not substance. - Move any
Startup : FunctionsStartuplogic intoConfigureServices. - Deploy with
FUNCTIONS_WORKER_RUNTIME=dotnet-isolatedset β the app setting that tells the host which worker to launch (and the one people forget, yielding a mystifying 500 on every function).
The result is a Functions app that is finally just a .NET worker service with triggers: standard DI, standard configuration, standard hosting, on whatever .NET you want β including .NET 10 β for as long as you want it. The in-process model bought milliseconds of startup by coupling you to a host; the isolated model sells those milliseconds back cheaply and hands you the whole runtime catalog. That trade was worth making, and 2025 is the year it became mandatory.