Azure Functions — A Complete Guide with Real Code (Why, When, and vs Web API)
Deep-dive into Azure Functions: when to choose them over Web API, hosting plans, triggers and bindings, real .NET code (HTTP, Queue, Timer, Durable Functions), cold starts, cost model, advantages and disadvantages.
- Author
- Randhir Jassal
- Published
- Reading time
- 16 min read
Azure Functions is Microsoft's serverless compute service: you write a function, Azure runs it when something triggers it, and you pay per execution + memory-second. There is no VM to manage, no Kubernetes to operate, and the platform scales from zero to thousands of concurrent executions automatically.
This guide is the complete picture: what Azure Functions is, why you would choose it over a Web API, the hosting plans, triggers and bindings, real .NET code, cold starts, cost, and the production reality.
Why Azure Functions exists
Traditional .NET web apps run as long-lived processes — Kestrel servers, IIS app pools, App Service VMs. They idle waiting for requests, consuming memory and CPU even when nothing is happening. You pay for that idle time, and you have to scale them up or down by hand (or via autoscale rules).
For many real workloads this model is wasteful:
- A webhook receiver that fires 50 times a day
- A nightly batch job that runs for 12 minutes then sleeps for 24 hours
- A queue consumer that processes spikes of work
- An image processor triggered by uploads
- A cron job that runs every hour
For all of these, Azure Functions gives you:
- Pay only when code is running — true scale-to-zero. Idle = ₹0.
- Auto-scale based on load — 1 to 200 concurrent instances in seconds.
- No server management — no VM patching, no OS updates, no capacity planning.
- Built-in triggers for HTTP, queues, blobs, timer, Event Grid, Cosmos DB change feeds, and more.
Architecture in one diagram
Azure Functions — How It Works
──────────────────────────────
Trigger sources Functions runtime Bindings
────────────── ───────────────── ──────────
HTTP request ─────┐
│
Queue message ─────┤
│ ┌──────────────────────┐ ┌──────────┐
Timer (cron) ─────┼──────────▶│ Scale Controller │ │ Blob │
│ │ (scales 0..N) │ ───▶ │ Storage │
Blob created ─────┤ └──────────┬───────────┘ └──────────┘
│ │
Service Bus ─────┤ ▼ ┌──────────┐
│ ┌──────────────────────┐ ───▶ │ Cosmos │
Event Grid ─────┤ │ Function instances │ │ DB │
│ │ (your code) │ └──────────┘
Cosmos changes ─────┘ │ - run on demand │
│ - scale to 0 idle │ ┌──────────┐
│ - charged per exec │ ───▶ │ SendGrid │
└──────────────────────┘ │ Twilio.. │
└──────────┘
inputs trigger the function outputs flow through bindings
(no manual HTTP wiring needed) (no manual SDK setup needed)
Three core concepts:
- Trigger — what starts the function (an HTTP request, a queue message, a schedule). One trigger per function.
- Bindings — declarative inputs and outputs (read a blob, write to Cosmos DB) without writing SDK boilerplate.
- Runtime + Scale Controller — Azure provisions and disposes function instances based on incoming load. You see only your code.
Hosting plans — choose by workload shape
Azure Functions runs on four different hosting models. Picking the right one is the single biggest cost + performance decision.
| Plan | Scale to zero | Cold start | Max execution | Price model | Best for |
|---|---|---|---|---|---|
| Consumption | ✅ yes | ~1-5 s (managed) | 10 min default | Per-execution + GB-s | Bursty workloads, webhooks |
| Premium | ❌ minimum 1 instance | None (pre-warmed) | 60 min | Per vCPU-hour | Predictable load + no cold-start tolerance |
| App Service Plan | ❌ shared with web apps | None | Unlimited | Per VM-hour | Existing App Service infra; long-running jobs |
| Flex Consumption (newer) | ✅ yes | Reduced (~200 ms) | 60 min | Per-execution + GB-s | Best-of-both — most new projects |
Rule of thumb:
- New project, modest traffic → Flex Consumption
- Sustained high traffic, no cold-start tolerance → Premium
- Already running App Service with capacity to spare → App Service Plan
- Old projects with established Consumption setup → keep Consumption until migration
Real code — four common trigger patterns
1. HTTP trigger (REST-ish endpoint)
The .NET 8+ isolated worker model:
public class CustomerFunctions
{
private readonly CustomerService _service;
public CustomerFunctions(CustomerService service) => _service = service;
[Function("GetCustomer")]
public async Task<HttpResponseData> GetAsync(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "customers/{id:guid}")]
HttpRequestData req,
Guid id,
FunctionContext ctx)
{
var customer = await _service.GetAsync(id);
if (customer is null)
return req.CreateResponse(HttpStatusCode.NotFound);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(customer);
return response;
}
}
URL after deploy: https://<app>.azurewebsites.net/api/customers/{id}. Requires a function key in the x-functions-key header (or ?code= query) for Function authorization level.
2. Queue trigger (process work asynchronously)
public class OrderProcessor
{
private readonly IPaymentService _payments;
public OrderProcessor(IPaymentService payments) => _payments = payments;
[Function("ProcessOrder")]
public async Task RunAsync(
[QueueTrigger("orders", Connection = "StorageConnection")] OrderMessage msg,
FunctionContext ctx)
{
await _payments.ChargeAsync(msg.OrderId, msg.Amount);
}
}
A new message in the orders queue auto-invokes this function. Azure Functions handles message dequeue, lock, retry, and dead-letter on N consecutive failures.
3. Timer trigger (cron jobs)
public class NightlyReports
{
private readonly IReportService _reports;
public NightlyReports(IReportService reports) => _reports = reports;
// NCronTab — runs daily at 02:00 UTC
[Function("DailyRevenueReport")]
public async Task RunAsync(
[TimerTrigger("0 0 2 * * *")] TimerInfo timer,
FunctionContext ctx)
{
var yesterday = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1));
await _reports.GenerateDailyAsync(yesterday);
}
}
Replaces Windows Task Scheduler / cron / IHostedService timer code. Survives platform restarts (no missed runs).
4. Blob trigger (react to uploads)
public class ImageProcessor
{
[Function("ResizeImage")]
public async Task RunAsync(
[BlobTrigger("uploads/{name}", Connection = "StorageConnection")] Stream incoming,
string name,
[BlobOutput("thumbnails/{name}", Connection = "StorageConnection")] Stream output,
FunctionContext ctx)
{
using var image = await Image.LoadAsync(incoming);
image.Mutate(x => x.Resize(200, 200));
await image.SaveAsJpegAsync(output);
}
}
When a file lands in the uploads container, this function runs. The input blob is read via the BlobTrigger binding; the resized output is written via the BlobOutput binding. No BlobClient boilerplate.
5. Durable Functions (orchestrated workflows)
For multi-step workflows that need to wait for events, retry, run for hours/days:
public class OrderOrchestration
{
[Function("PlaceOrder_Orchestrator")]
public async Task<OrderResult> RunAsync(
[OrchestrationTrigger] TaskOrchestrationContext ctx)
{
var input = ctx.GetInput<PlaceOrderInput>()!;
var reservation = await ctx.CallActivityAsync<Reservation>(
"ReserveStock", input.Items);
try
{
var payment = await ctx.CallActivityAsync<PaymentResult>(
"ChargeCard", new ChargeRequest(input.CustomerId, input.Total));
}
catch
{
// Compensate
await ctx.CallActivityAsync("ReleaseStock", input.Items);
throw;
}
// Wait for shipping confirmation event (could be hours/days)
var shipped = await ctx.WaitForExternalEvent<ShipmentEvent>("Shipped",
timeout: TimeSpan.FromDays(7));
return new OrderResult(input.OrderId, "completed");
}
[Function("ReserveStock")]
public Task<Reservation> ReserveStockAsync(
[ActivityTrigger] List<LineItem> items, FunctionContext ctx) { ... }
}
Durable Functions implement the SAGA pattern as code. The orchestrator is replayed from event history on each step, so it survives crashes, redeploys, and long waits.
Azure Functions vs ASP.NET Web API — the real comparison
Both can serve HTTP endpoints. They are NOT interchangeable.
| Factor | Azure Functions (HTTP trigger) | ASP.NET Web API |
|---|---|---|
| Hosting model | Serverless; scales to 0 | VM/container; always-on |
| Cold start | 1-5 s (Consumption) | None (warm) |
| Cost at idle | ₹0 | Cost of running VM |
| Cost under load | Per-execution; can grow fast | Flat VM rate; predictable |
| Max execution | 10 min default (60 min Premium) | Unlimited |
| Long-lived connections (WebSockets, SignalR) | Poor fit | Native |
| Stateful per-request | Awkward | Natural |
| DI, middleware, filters | Limited (different model) | First-class |
| Routing, model binding | Per-function | Centralised |
| Testing | Function isolated unit tests | Standard MVC tests |
| Observability | App Insights via Functions worker | Full ASP.NET Core diagnostics |
| OpenAPI / Swagger | Via extensions | First-class |
| Auth | Function keys, AAD, custom | Identity, JWT, OAuth |
Choose Azure Functions when
- Traffic is bursty (webhooks, queue spikes, weekly cron)
- Workload is genuinely event-driven (file upload → process)
- You want scale-to-zero to save cost
- Functions are short-lived (under 5 min typical)
- The team wants zero infrastructure overhead
Choose Web API when
- Sustained traffic (10+ requests/sec continuously)
- The API has rich middleware needs (auth, rate limit, content negotiation, caching)
- You need WebSockets / SignalR / streaming
- Requests are long-lived (>10 min)
- You want OpenAPI for client SDK generation
- The API is part of a bigger system already on App Service
What people get wrong
- Building a public REST API of 30 endpoints as Azure Functions. The per-execution cost adds up, cold starts hurt p99, and you reinvent middleware. Use Web API.
- Running chatbots / SignalR on Functions. The 10-min execution cap and stateless model fight you. Use Web API + Azure SignalR Service.
- Using Functions for CPU-heavy long jobs (PDF rendering for 8 minutes). You will hit the timeout. Use Container Apps Jobs or App Service.
Hybrid is normal
Most production .NET systems use BOTH:
- Web API on App Service / Container Apps for the customer-facing API
- Azure Functions for webhooks, queue consumers, scheduled jobs, file processing
Triggers and bindings — the killer feature
A "binding" is declarative I/O. Instead of writing SDK code to read/write Blob Storage, Cosmos DB, Service Bus etc., you decorate your function parameters:
[Function("CopyToBackup")]
public async Task RunAsync(
[BlobTrigger("incoming/{name}")] Stream source,
[BlobInput("config/policy.json")] string policy,
[BlobOutput("backup/{name}")] Stream destination,
[CosmosDBOutput("logs", "audit", Connection = "CosmosConn")] IAsyncCollector<AuditLog> log,
FunctionContext ctx)
{
await source.CopyToAsync(destination);
await log.AddAsync(new AuditLog(...));
}
The function ABOVE reads a blob, reads a config blob, writes a destination blob, writes a Cosmos document — with zero SDK setup code. Bindings cover:
- Blob, Queue, Table, File Storage
- Service Bus, Event Hubs, Event Grid
- Cosmos DB (including change feed)
- SignalR Service, SendGrid, Twilio
- HTTP webhooks
- Custom (via extension authoring)
Cold starts — the unavoidable tax
When a function hasn't been called recently on Consumption plan, the next invocation triggers a "cold start":
- Azure allocates a new instance
- Loads your function app
- JIT-compiles your assemblies
- Establishes connections (DB, Service Bus)
- Runs your function
Total: 1-5 seconds for .NET. Compared to:
- Node.js: ~500 ms - 2 s
- Python: ~500 ms - 2 s
- .NET (with AOT compilation in .NET 8+ isolated worker): ~500 ms - 1 s
- Java: 3-10 s (worst)
Mitigations
- Use Premium or Flex Consumption plan — pre-warmed instances eliminate cold starts entirely
- Reduce dependencies — fewer NuGet packages = faster JIT
- .NET 8+ isolated worker with AOT — significantly shorter cold start
- Health-check ping — keep one instance warm by hitting the function every 5 min
- Lazy initialization — defer expensive setup until first non-trigger code path
If your app is user-facing and 1-5 s cold start matters, you should be on Premium or App Service Plan, not Consumption.
Cost model — the surprises
Consumption pricing (Mumbai region, approximate):
- ₹0.16 per million executions (first 1M free per month)
- ₹0.016 per GB-second of memory used (first 400K GB-s free per month)
For a typical webhook receiver: 50 executions/day × 1 GB × 200 ms = trivial cost. Stays in the free tier.
For a heavy queue consumer: 10M executions/month × 1 GB × 500 ms = ~₹820 / month. Still cheap.
For a sustained 100 RPS API: 260M executions/month + memory = ~₹15,000-30,000 / month. An App Service P1V3 at ~₹8,000/month + 1 instance is cheaper.
The break-even point
Above ~20-30 sustained requests per second, Web API on App Service usually becomes cheaper than Azure Functions on Consumption.
Advantages
- Scale to zero — pay nothing when idle
- Auto-scale — 0 to 200 instances in seconds
- Built-in triggers + bindings — write less SDK boilerplate
- Zero infrastructure overhead — no VMs, no Kubernetes
- First-class Azure integration — Service Bus, Cosmos DB change feed, Event Grid
- Durable Functions for stateful long-running workflows
- Fast iteration — deploy a single function, no full app redeploy
Disadvantages
- Cold start — 1-5 s for .NET on Consumption (mitigable with Premium)
- Execution time cap — 10 min default, 60 min Premium. Not for long jobs.
- Limited DI / middleware compared to ASP.NET Core
- Cost grows fast under sustained load — not the right tool for "always-on" APIs
- Vendor lock-in — Function bindings are Azure-specific; porting to AWS Lambda means rewriting binding code
- Local development complexity — needs Azure Functions Core Tools + storage emulator
- Observability requires effort — App Insights instrumentation isn't fully automatic
Production checklist
- Health-check function for monitoring tools to ping
- Application Insights wired up (
AddApplicationInsightsTelemetryWorkerService) - Connection-string secrets in Azure Key Vault, not in
local.settings.jsoncommitted to git - Retry + dead-letter policy explicit on every queue trigger (
max_dequeue_countin host.json) - Idempotent handlers — every queue/event message can arrive twice
HostedService-style background work — avoid; use Functions for triggered work, App Service for long-lived- Per-function timeouts in host.json — fail fast on stuck dependencies
- Output binding error handling — bindings can fail; have a fallback path
- Cost dashboard — Azure Cost Management alert at 80% of budget
- CI/CD via Azure DevOps or GitHub Actions — deploy slot + slot swap for zero-downtime updates
When to migrate AWAY from Functions
Signals it's time to move (partially) to App Service or Container Apps:
- Sustained 30+ RPS — cost crossover
- Function execution regularly exceeds 5 min — timeout risk
- Cold starts hurt p99 user experience
- You're rebuilding ASP.NET features inside Functions (auth, middleware, OpenAPI)
- You want to ship as a container with custom dependencies
Summary
Azure Functions is the right answer to event-driven and bursty workloads on Azure: webhooks, queue consumers, scheduled jobs, file processing, lightweight HTTP webhooks. Pay-per-execution scale-to-zero is genuinely cheaper for these patterns than running a 24/7 VM.
It is the WRONG answer for sustained-traffic HTTP APIs, long-lived connections, or workloads with hard latency SLAs (because of cold starts on Consumption).
The pragmatic .NET stack: Web API on App Service / Container Apps for the public API, Azure Functions for webhooks, scheduled jobs, and queue-driven background work. Use Durable Functions when you need orchestrated workflows (SAGA pattern, multi-step, long-running).
Pick the hosting plan deliberately. Monitor cold starts. Watch the cost crossover. The platform is fantastic when used for what it's good at; expensive and frustrating when forced into roles it wasn't designed for.
📚 Test your knowledge → Practice with our Azure Functions interview questions — hosting plans, cold starts, triggers, Durable Functions, and when to pick Functions vs Web API.
Get the next issue
A short, curated email with the newest posts and questions.