Back to posts

Azure Functions: Serverless Done Right

Most teams build web applications by allocating servers, managing deployments, and scaling based on traffic patterns. It is the dominant model. It is also a lot of work.

Azure Functions takes a different approach. You write a function. You connect it to a trigger (HTTP request, message queue, timer, blob change). Azure runs your function when the trigger fires. You pay only for the time the function executes. No servers to manage. No scaling to configure.

This sounds too good to be true, and there are real trade-offs. But for certain workloads, serverless with Azure Functions is genuinely simpler and cheaper than traditional approaches.

This article covers how Azure Functions work, when to use them, how to use them well, and the real costs and benefits.

What Is Serverless Computing?

Serverless computing is a model where:

  • You write code that runs in response to events
  • The cloud provider manages the infrastructure
  • You pay only for execution time
  • You do not think about servers, scaling, or deployment

The name is misleading. There are still servers. You just do not manage them.

Serverless vs. traditional compute

Traditional (VMs or containers):

  • You provision servers
  • You deploy code to those servers
  • You monitor utilization and scale based on demand
  • You pay for server time, whether they are being used or not

Serverless:

  • You upload code
  • Code runs when triggered
  • Scaling is automatic
  • You pay for execution time only

Trade-offs:

Serverless is simpler but less flexible. You cannot run background processes that do not respond to events. You cannot customize the runtime environment extensively. You are bound by limits (execution time, memory, concurrent executions).

Traditional is more complex but more flexible. You can run anything. You control the environment. You pay for idle time.

How Azure Functions Work

The execution model

Azure Functions runs your code in response to events:

Event happens (HTTP request, message in queue, timer fires)
  ↓
Azure detects the event
  ↓
Azure allocates a container with your runtime (Node.js, Python, C#, Java)
  ↓
Azure loads your function code
  ↓
Azure invokes your function with the event as input
  ↓
Your function executes and returns a result
  ↓
Azure sends the result back or takes action based on the result
  ↓
Container is kept warm (for a while) or discarded

Cold starts vs. warm starts

A cold start is when Azure has to allocate a new container and load your code. This takes time (500ms - 2s typically).

A warm start is when a container is already allocated and your code is loaded. This is fast (10-50ms).

For most use cases, cold starts are acceptable. For latency-sensitive applications (e.g., user-facing APIs), they can be a problem.

Options to mitigate cold starts:

1. Premium plan

Keep instances warm all the time. Costs more but eliminates cold starts.

2. Consumption plan with App Service Plan warmup

Reserve minimum instances that are always running.

3. Durable Functions

Use orchestration to manage state across multiple function executions, reducing need for cold restarts.

4. Accept cold starts

For most use cases, cold starts are rare enough that accepting them is fine.

Pricing model

Azure Functions uses a pay-per-execution model:

  • First 1M executions per month: Free
  • After that: $0.20 per million executions
  • Plus: Memory-gb-seconds cost

For a function that executes 100K times per month for 1 second each, memory-gb-seconds is the main cost.

Example: Function that uses 512 MB for 1 second:

Cost = (512 / 1024) GB * 1 second * $0.000016 per GB-second
     = 0.5 * 1 * $0.000016
     = $0.000008 per execution
     = $0.80 per million executions

Compare to traditional: A small App Service (B1 tier) costs ~$10-15/month minimum.

For low-traffic workloads, serverless is cheaper. For high-traffic workloads where you can stay on consumption plan, it is still often cheaper. For very high traffic or always-on workloads, traditional compute becomes cheaper.

Trigger Types and Patterns

Azure Functions can be triggered by many events.

HTTP trigger

Triggered by HTTP requests. Essentially a REST API endpoint.

[FunctionName("HelloWorld")]
public static async Task<IActionResult> Run(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]
    HttpRequest req,
    ILogger log)
{
    return new OkObjectResult("Hello, World!");
}

Use case: Lightweight APIs, webhooks, scheduled reports.

Trade-off: Cold start affects user-facing latency. For high-traffic APIs, consider App Service instead.

Timer trigger

Triggered on a schedule (CRON-based).

[FunctionName("DailyCleanup")]
public static void Run(
    [TimerTrigger("0 0 * * *")] TimerInfo myTimer)
{
    // Runs daily at midnight
    CleanupOldData();
}

Use case: Scheduled jobs, nightly reports, periodic health checks.

Trade-off: None really. This is a great use case for serverless.

Queue trigger

Triggered by messages in Azure Storage Queue or Service Bus.

[FunctionName("ProcessOrder")]
public static void Run(
    [QueueTrigger("orders")] OrderMessage order,
    ILogger log)
{
    log.LogInformation($"Processing order {order.Id}");
    // Process the order
}

Use case: Async job processing, background work, decoupling services.

Trade-off: Queue-based processing adds latency. For real-time work, use direct calls instead.

Blob trigger

Triggered when a blob is uploaded or modified.

[FunctionName("ProcessImage")]
public static void Run(
    [BlobTrigger("images/{name}")] Stream image,
    string name,
    ILogger log)
{
    log.LogInformation($"Processing image {name}");
    // Resize, analyze, transform the image
}

Use case: Image processing, file transformations, data pipelines.

Trade-off: Latency between blob upload and function execution (typically seconds). Not suitable for real-time processing.

Event Grid trigger

Triggered by events from Event Grid (webhooks at scale).

[FunctionName("HandleStorageEvent")]
public static void Run(
    [EventGridTrigger] EventGridEvent eventGridEvent,
    ILogger log)
{
    if (eventGridEvent.EventType == "Microsoft.Storage.BlobCreated")
    {
        // Handle blob creation
    }
}

Use case: Complex event routing, cross-service orchestration.

Trade-off: Event Grid adds complexity. Good for decoupled event-driven systems.

Service Bus trigger

Triggered by messages in Service Bus (messaging at scale).

[FunctionName("HandleMessage")]
public static void Run(
    [ServiceBusTrigger("my-topic", "my-subscription")] string message,
    ILogger log)
{
    log.LogInformation($"Received: {message}");
}

Use case: Large-scale message processing, pub/sub patterns.

Trade-off: Service Bus adds cost and operational complexity. Use when you need guaranteed message delivery and complex routing.

Durable Functions

Durable Functions extends Azure Functions to support complex, multi-step workflows.

Without Durable Functions

// User calls API
[FunctionName("PlaceOrder")]
public static async Task<IActionResult> PlaceOrder(Order order)
{
    try
    {
        await ValidateOrder(order);
        await ProcessPayment(order);
        await ShipOrder(order);
        return Ok("Order placed");
    }
    catch (Exception ex)
    {
        // If ProcessPayment fails after ValidateOrder succeeds,
        // we need to manually rollback or track state.
        // This gets complicated quickly.
        return BadRequest(ex);
    }
}

Challenges:

  • If a step fails, how do you retry?
  • How do you rollback if a later step fails?
  • How do you resume if the process is interrupted?
  • How do you track progress?

With Durable Functions

[FunctionName("PlaceOrderOrchestrator")]
public static async Task RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var order = context.GetInput<Order>();
    
    try
    {
        await context.CallActivityAsync("ValidateOrder", order);
        await context.CallActivityAsync("ProcessPayment", order);
        await context.CallActivityAsync("ShipOrder", order);
    }
    catch (Exception)
    {
        // Automatically retried
        // Or manually retry with await context.CallActivityAsync(...)
    }
}

[FunctionName("ValidateOrder")]
public static async Task ValidateOrder([ActivityTrigger] Order order)
{
    // Validate
}

[FunctionName("ProcessPayment")]
public static async Task ProcessPayment([ActivityTrigger] Order order)
{
    // Process payment
}

[FunctionName("ShipOrder")]
public static async Task ShipOrder([ActivityTrigger] Order order)
{
    // Ship order
}

Durable Functions handles:

  • State tracking (which step are we on?)
  • Retry logic (with backoff)
  • Resumption (if interrupted, pick up where you left off)
  • Compensation (rollback if something fails)

Use cases:

  • Multi-step workflows (order processing, approval chains)
  • Long-running operations (data pipelines, bulk operations)
  • Complex orchestration (fan-out/fan-in patterns)

Human interaction patterns

Durable Functions can pause and wait for human input:

[FunctionName("ApprovalOrchestrator")]
public static async Task RunOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var request = context.GetInput<Request>();
    
    // Wait for approval (with timeout)
    var approvalTask = context.WaitForExternalEvent<bool>("Approved");
    var timeoutTask = context.CreateTimer(
        context.CurrentUtcDateTime.AddDays(1),
        false);
    
    var approved = await Task.WhenAny(approvalTask, timeoutTask);
    
    if (approved)
    {
        await context.CallActivityAsync("ProcessRequest", request);
    }
    else
    {
        await context.CallActivityAsync("RejectRequest", request);
    }
}

This is powerful for workflows that require human approval or decisions.

Best Practices for Azure Functions

1. Keep functions focused and small

A function should do one thing well.

Bad: A function that validates an order, processes payment, ships the order, sends confirmation email.

Good: Separate functions for each step, orchestrated with Durable Functions.

Why: Smaller functions are easier to test, debug, and scale independently.

2. Use dependency injection

Functions need dependencies (database connection, HTTP client, logger). Use dependency injection.

public class OrderProcessor
{
    private readonly IOrderRepository _repository;
    
    public OrderProcessor(IOrderRepository repository)
    {
        _repository = repository;
    }
    
    public async Task Process(Order order)
    {
        await _repository.SaveAsync(order);
    }
}

// In Startup
public override void Configure(IFunctionsHostBuilder builder)
{
    builder.Services.AddScoped<IOrderRepository, OrderRepository>();
    builder.Services.AddScoped<OrderProcessor>();
}

3. Implement idempotency

Functions may be triggered multiple times for the same event (retry, duplicate message). Make them safe to run twice.

Bad:

[FunctionName("AddCredit")]
public static void AddCredit([QueueTrigger("credit-requests")] CreditRequest req)
{
    database.AddCredit(req.UserId, req.Amount);  // If called twice, credit added twice
}

Good:

[FunctionName("AddCredit")]
public static void AddCredit(
    [QueueTrigger("credit-requests")] CreditRequest req,
    [Blob("processed-credits/{id}")] CloudBlockBlob processedBlob)
{
    if (processedBlob.Exists())
    {
        return;  // Already processed
    }
    
    database.AddCredit(req.UserId, req.Amount);
    processedBlob.UploadTextAsync("processed");
}

4. Handle errors and retries

Configure retry policies for triggers:

{
  "version": "2.0",
  "functionTimeout": "00:05:00",
  "extensions": {
    "queues": {
      "maxDequeueCount": 3,
      "visibilityTimeout": "00:00:30"
    }
  }
}

This retries failed queue messages 3 times before moving to dead-letter queue.

5. Monitor and log

Use Application Insights for monitoring:

[FunctionName("ProcessOrder")]
public static async Task Run(
    [QueueTrigger("orders")] Order order,
    ILogger log,
    ExecutionContext context)
{
    log.LogInformation($"Processing order {order.Id}");
    
    try
    {
        // Process order
    }
    catch (Exception ex)
    {
        log.LogError($"Error processing order {order.Id}: {ex.Message}");
        throw;
    }
}

6. Secure your functions

Use authorization levels:

[HttpTrigger(AuthorizationLevel.Anonymous, ...)]  // Public
[HttpTrigger(AuthorizationLevel.Function, ...)]   // Requires function key
[HttpTrigger(AuthorizationLevel.Admin, ...)]      // Requires master key

Store sensitive data in Key Vault:

public static IActionResult Run(
    HttpRequest req,
    [ServiceBus("...")] IAsyncCollector<string> queue,
    ILogger log)
{
    // ServiceBus connection string is managed automatically
}

7. Test functions locally

Use Azure Functions Core Tools to run functions locally:

func start

This lets you test triggers and bindings before deploying.

8. Use managed identities

Instead of connection strings in config, use managed identity:

var credential = new DefaultAzureCredential();
var client = new BlobContainerClient(
    new Uri("https://myaccount.blob.core.windows.net/mycontainer"),
    credential);

The function authenticates as itself. No secrets in config.

Where to Start with Azure Functions

If you are considering Azure Functions, here are good starting points:

1. Scheduled jobs

Timer-triggered functions are perfect for:

  • Nightly data exports
  • Periodic cleanup
  • Health checks
  • Report generation

These are almost always better than managing a separate job scheduler.

2. Webhook receivers

Use HTTP-triggered functions to receive webhooks from third-party services (GitHub, Stripe, Slack).

Much simpler than running a full web server.

3. Event-driven data processing

When something happens (file uploaded, message arrives), process it with a function.

Examples:

  • Image resizing on upload
  • Log analysis on new logs
  • Data transformation on queue message

4. API endpoints for lightweight operations

Simple CRUD APIs can run on Functions instead of App Service.

Better economics if traffic is low or bursty.

5. Background job processing

Move async work to queue-triggered functions.

Example: User submits a report request via web UI. A queue message is added. A function processes the report asynchronously. Result is emailed to user.

Anti-patterns (When NOT to use Functions)

Long-running processes

If a function needs to run for > 10 minutes regularly, do not use Functions.

Cold starts, timeout limits, and execution costs make this inefficient.

Use: App Service, Container Instances, or a traditional job runner.

High-frequency APIs

If you have thousands of requests per second, Functions is probably not the right choice.

The overhead of cold starts and container management adds up.

Use: App Service, Container Instances, or a dedicated API platform.

Always-on processes

If you need a process running 24/7, Functions is inefficient.

Use: App Service with Always On enabled, or a traditional VM.

Complex, stateful logic

If you have complex interdependencies and state management, Functions becomes painful.

Use: App Service with a database, or a microservices framework.

Cost Management

Estimate your costs

Use the Azure pricing calculator:

Function executions: 10M/month
Memory: 256 MB
Duration: 1 second average

Cost = (256/1024) * 1 * 10M * $0.000016 + (10M/1M) * $0.20
     = 2.5M * $0.000016 + 10 * $0.20
     = $40 + $2
     = $42/month

Compare to alternatives:

  • App Service B1: $10-15/month (but always running, even idle)
  • Container Instance: $0.0015/sec = ~$4.3K/month if always running
  • VM: $15-50/month depending on size

Optimize spending

  • Use lower memory if possible (128-256 MB often sufficient)
  • Optimize code to reduce execution time
  • Use Durable Functions to consolidate related operations
  • Set function timeout appropriately (default 5 min, can increase)

Organizational Benefits

When you use Azure Functions effectively:

Reduced operational burden

No servers to patch, scale, or manage. Your ops team focuses on monitoring and alerting.

Faster time to market

No infrastructure to provision. Deploy a function and it is live.

Automatic scaling

Traffic spikes? Azure scales automatically. No capacity planning needed.

Cost efficiency

Pay for what you use. Idle time costs nothing.

Developer focus

Developers write business logic, not infrastructure code.

Common Pitfalls

Pitfall 1: Cold starts in user-facing APIs

Deploying a user-facing HTTP API to Functions without addressing cold starts causes latency issues.

Solution: Use Premium plan or App Service Plan with minimum instances.

Pitfall 2: No error handling

Functions fail silently if you do not implement error handling.

Solution: Use Application Insights, log everything, set up alerts.

Pitfall 3: Over-complex Durable Functions

Durable Functions is powerful but can become hard to debug.

Solution: Keep orchestrations simple. Use simple activity functions.

Pitfall 4: Not testing locally

Deploying to Azure without testing locally leads to surprises.

Solution: Use Azure Functions Core Tools and test locally first.

Pitfall 5: Ignoring cold starts

Cold starts matter for latency-sensitive workloads.

Solution: Understand your SLA. If latency < 1s is required, use Premium plan.

Conclusion

Azure Functions is a powerful tool for specific workloads. It is not appropriate for everything, but for scheduled jobs, event-driven processing, and lightweight APIs, it is genuinely superior to traditional approaches.

The key to using Functions successfully:

  1. Choose the right problems: Scheduled jobs, webhooks, async processing, lightweight APIs
  2. Keep functions small: One responsibility per function
  3. Handle errors: Retry, log, alert
  4. Test locally: Do not rely on cloud testing
  5. Monitor continuously: Know what is happening in production
  6. Use Durable Functions for complex workflows: It is worth the learning curve

When you get these right, Azure Functions reduces operational complexity, accelerates development, and often reduces costs. That is why it is popular, and why it will continue to be central to Azure deployments.