All articles

Azure for .NET Developers — Part 6: Azure Functions vs Background Services — When Serverless Wins

The isolated worker model, honest cold-start numbers, and a practical decision table for Functions vs IHostedService vs container jobs.

0 · log in to like, save & follow Share on LinkedIn Share on X

"Just make it a Function" has become the default answer to any background work on Azure, and it is wrong about half the time. Most .NET teams already run an App Service with spare capacity where an IHostedService costs nothing extra and deploys with the app. The interesting question is not whether Azure Functions work — they do — but when they beat what you already have.

Azure Functions vs Background Services — When Serverless Wins

The isolated worker model, briefly

If you last looked at Azure Functions a few years ago, the programming model has changed. The old in-process model ran your code inside the Functions host, which chained your app to the host's .NET version and its package versions — upgrading .NET meant waiting for the platform. That model is retired; the isolated worker model is now the only option for current .NET.

In the isolated model your function app is a normal console app with Program.cs, a HostBuilder, and your own DI container — structurally the same as any worker service. The Functions host runs as a separate process and talks to yours over gRPC. You choose your .NET version (including .NET 10), register middleware, and use whatever package versions you like. The trade-off is a thin layer of serialization between host and worker, which for typical workloads is noise.

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services.AddSingleton<IReportService, ReportService>();
builder.Build().Run();

Two triggers you will actually write

A timer trigger replaces the classic "cron job in a BackgroundService" pattern:

public class NightlyCleanup(IReportService reports, ILogger<NightlyCleanup> logger)
{
    [Function("NightlyCleanup")]
    public async Task Run([TimerTrigger("0 30 2 * * *")] TimerInfo timer)
    {
        logger.LogInformation("Cleanup starting, next run {Next}",
            timer.ScheduleStatus?.Next);
        await reports.PurgeExpiredAsync();
    }
}

An HTTP trigger is a minimal endpoint without an App Service around it:

public class ResizeImage
{
    [Function("ResizeImage")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        var payload = await req.ReadFromJsonAsync<ResizeRequest>();
        // ... do the work ...
        var response = req.CreateResponse(HttpStatusCode.Accepted);
        await response.WriteAsJsonAsync(new { payload!.BlobName, status = "queued" });
        return response;
    }
}

Constructor injection works exactly as in ASP.NET Core, which is most of what "isolated model" means in practice.

Cold starts: honest numbers

On the Consumption plan, an idle function app is deallocated after roughly 20 minutes without traffic. The next request pays for provisioning a worker, starting the host, and JIT-compiling your code. For a modest .NET isolated app, expect cold starts in the 1–4 second range; heavier apps with large dependency graphs can see 5–10 seconds. Warm requests are single-digit milliseconds of overhead.

Whether that matters is entirely about the trigger. A queue consumer or a nightly timer does not care about 3 seconds. A user-facing HTTP endpoint absolutely does. The Flex Consumption plan improves this with faster provisioning and optional always-ready instances, and Premium plan keeps instances warm for a fixed monthly cost — at which point you should run the comparison below with clear eyes, because Premium costs App Service money.

The comparison that matters

Azure Function (Consumption) BackgroundService in existing App Service Container Apps job
Marginal cost ~zero at low volume; per-execution Zero — capacity you already pay for Per-second while running
Cold start 1–10 s after idle None; always running Container pull + start, seconds
Scaling Automatic, to hundreds of instances Scales with the web app, like it or not KEDA-driven, scales to zero
Ops surface New resource, new deploy pipeline None new; ships with the app Registry, image builds, KEDA config
Execution limit 10 min default (Consumption) Unlimited Configurable, long-running fine
Best at Spiky, event-driven, bursty glue Steady work coupled to the app Long batch jobs, custom runtimes

The middle column is the one Azure marketing skips. If you run an App Service anyway, an IHostedService doing queue polling or scheduled work costs nothing, deploys atomically with the app that owns the data, and debugs like any other class in your solution. Its weaknesses are real too: it scales only with the web app, it competes with request traffic for CPU, and a crash loop takes your site's worker process with it.

When should I use Azure Functions instead of a background service?

Use a Function when the work is event-driven, spiky, or independent of any app you already run — the scale-to-zero economics and per-event triggers are exactly what serverless is for. Stay with a BackgroundService when the work is steady, modest, and belongs to an existing application, because you already pay for that compute and one deployable is simpler than two. The tiebreaker is scaling: if a traffic burst should spin up twenty workers without touching your web tier, that is a Function.

Concretely, serverless wins for: webhook receivers, blob-created thumbnailing, queue-driven fan-out, scheduled jobs for teams with no app to host them in, and integration glue between SaaS systems. It is overkill for: polling one queue every 10 seconds under a web app that is 5% utilized, workflows that run for an hour (execution limits will bite), and latency-sensitive HTTP APIs on Consumption where cold starts land on users.

Bindings: the queue trigger

Triggers and bindings are the genuinely differentiating feature — the plumbing you do not write. A queue consumer with retry, poison-message handling, and scale-out:

public class OrderProcessor(ILogger<OrderProcessor> logger)
{
    [Function("OrderProcessor")]
    public async Task Run(
        [QueueTrigger("orders", Connection = "Storage")] OrderMessage order)
    {
        logger.LogInformation("Processing order {Id}", order.OrderId);
        await ProcessAsync(order);
        // Return normally: message deleted.
        // Throw: message retried, then moved to orders-poison after 5 attempts.
    }
}

The equivalent BackgroundService needs a polling loop, backoff, visibility-timeout management, poison handling, and graceful shutdown — 100 lines we have all written and gotten subtly wrong. With Connection = "Storage" pointing at a Storage__serviceUri setting, this authenticates with managed identity from Part 4, no connection string involved.

Local development

The local story is solid. Install Azure Functions Core Tools and Azurite (the local storage emulator):

npm install -g azure-functions-core-tools@4 azurite
azurite --silent &          # local blob/queue/table endpoints
func start                  # runs your function app locally

With "Storage": "UseDevelopmentStorage=true" in local.settings.json, queue and timer triggers fire against Azurite, and you can debug in your IDE by attaching to the func process — Visual Studio and Rider do this automatically with F5. Drop a message into the local queue with the Azure Storage Explorer and watch your breakpoint hit. The complete timer, HTTP, and queue examples live in the 06-functions folder of the companion repo at github.com/laxmikant-geek/azure-for-dotnet-examples, with a local.settings.json template.

Deployment fits an existing pipeline without drama: func azure functionapp publish for quick pushes, or zip deploy from GitHub Actions exactly as you would an App Service — it is the same Kudu machinery underneath, so nothing about your CI setup needs rethinking.

One habit worth forming early: keep functions thin. Trigger, deserialize, call a service class, return. The service class is unit-testable without any Functions machinery, and if you ever migrate the workload into an App Service or a container job, you move one class instead of rewriting a project.

What's next: with compute and storage covered, observability is the missing leg. In Part 7 we wire up Application Insights and OpenTelemetry for .NET on Azure — distributed traces across App Service, Functions, and storage calls, and how to read them when something is slow at 2 a.m.

Part 6 of 8 — don't miss the rest New parts of Azure for .NET Developers publish every few days. Get each one by email the day it lands — no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.