Advanced

Optimizing Azure Functions for Performance and Cost

Hosting plans, cold-start anatomy, code best practices, scaling, Durable Functions and cost analysis.

Level: Intermediate to Advanced Technologies: Azure Functions v4 / C# / .NET 8 / Application Insights / Azure Monitor


About this guide: This document is a complete reference on optimizing Azure Functions for performance and cost. It covers in depth hosting plans, cold start anatomy, architectural best practices, hardening, scaling configuration, Durable Functions for long-running workflows, and cost monitoring.


Table of Contents

  1. Hosting Plans – Comparison and Optimal Choice
  2. Cold Start – Anatomy and Solutions
  3. Architecture and Code Best Practices
  4. Hardening and Resilience
  5. Scaling Configuration
  6. Durable Functions for Long-Running Workloads
  7. Monitoring and Cost Analysis
  8. Advanced Optimization
  9. Performance Checklist
  10. Glossary

Module 1 – Hosting Plans: Comparison and Optimal Choice {#module-1}

Understanding Azure Functions’ PaaS Context

Azure Functions fits within Azure’s Platform as a Service (PaaS) ecosystem. Unlike VMs (IaaS), with PaaS you don’t manage VMs, the OS, or runtimes — you focus on code.

flowchart TD
    subgraph "Azure PaaS Ecosystem"
        AKS[Azure Kubernetes Service\nOrchestrated containers]
        AppService[Azure App Service\nWeb apps and APIs]
        LogicApps[Azure Logic Apps\nLow-code workflows]
        Functions[Azure Functions\nEvent-driven pro-code]
        ContainerApps[Azure Container Apps\nServerless containers]
    end
    
    subgraph "Serverless (Scale to Zero)"
        Functions
        ContainerApps
        LogicApps
    end
    
    subgraph "Non-Serverless (Scale from Min)"
        AKS
        AppService
    end
    
    style Functions fill:#68217A,color:#fff

The 5 Hosting Plans in Detail

Consumption Plan: True Serverless

Advantages:
✅ Pay only for actual executions
✅ Auto-scale from 0 to 200 instances
✅ 1 million free executions/month
✅ 400,000 GB-seconds free/month
✅ No scaling configuration needed

Disadvantages:
❌ Cold start unavoidable (1–10+ seconds)
❌ Maximum timeout: 10 minutes
❌ No native VNet integration
❌ Unpredictable performance during spikes
❌ Limited memory: 1.5 GB

Flex Consumption Plan: The Best of Both Worlds

Advantages:
✅ Pay-per-use like Consumption
✅ VNet Integration available
✅ Configurable "always ready" instances (reduce cold start)
✅ Configurable memory: 512 MB to 4 GB
✅ Timeout up to 60 minutes
✅ Better scaling options

Disadvantages:
❌ Cost of always-ready instances
❌ Newer = less mature documentation

Premium (Elastic Premium) Plan

Advantages:
✅ NO cold start (pre-warmed instances)
✅ Unlimited timeout
✅ Full VNet Integration
✅ Private Endpoints
✅ Memory 3.5 GB to 14 GB
✅ Customizable scaling

Disadvantages:
❌ Minimum cost even without traffic (~$150-600/month)
❌ Overkill for small workloads

Dedicated (App Service Plan)

Advantages:
✅ Reuses existing App Service infrastructure
✅ No cold start
✅ Unlimited timeout
✅ Same SKU as your existing web apps

Disadvantages:
❌ Pay even when idle
❌ Risk of "noisy neighbor" if shared
❌ Less automatic scaling

Ultimate Comparison Table

FeatureConsumptionFlex ConsumptionPremium EP1Premium EP2Dedicated B2Container Apps
Billing modelPay-per-execPay-per-exec + always-readyHourlyHourlyMonthlyvCPU/Memory
Min cost/month$0$0+~$150~$300~$30$0+
Cold StartYes (1-10s)ConfigurableNoNoNoVariable
Default timeout5 min30 min30 min30 min30 minUnlimited
Maximum timeout10 min60 minUnlimitedUnlimitedUnlimitedUnlimited
VNet Integration
Private Endpoints
vCPU per instanceShared1122Configurable
Memory per instance1.5 GB0.5-4 GB3.5 GB7 GB3.5 GBConfigurable
Scale to Zero
Always Ready✅ (paid)✅ (included)✅ (included)
Max Scale Out200Flexible100100Per SKUUnlimited

Decision Tree for Choosing a Plan

flowchart TD
    A{What is your profile?} --> B{Very unpredictable traffic\nor sporadic loads?}
    B -->|Yes + no VNet| C[🟢 Consumption Plan\nMinimal cost, scale to zero]
    B -->|Yes + need VNet| D[🔵 Flex Consumption\nServerless + networking]
    B -->|No| E{Constant\nor predictable load?}
    E -->|Yes + critical performance| F{Budget available?}
    F -->|Available| G[🔴 Premium EP1/EP2\nGuaranteed performance]
    F -->|Limited| H{Existing App Service?}
    H -->|Yes| I[🟣 Dedicated Plan\nReuse infra]
    H -->|No| J[🔵 Flex Consumption\nGood compromise]
    E -->|No / Kubernetes| K[⚫ Container Apps\nMaximum flexibility]
    
    style C fill:#107C10,color:#fff
    style D fill:#0078D4,color:#fff
    style G fill:#D83B01,color:#fff

Module 2 – Cold Start: Anatomy and Solutions {#module-2}

What is a Cold Start?

When your function is triggered and there is no available instance, Azure must create a new one. This process takes time — that’s the cold start.

sequenceDiagram
    participant Event as Trigger Event
    participant Azure as Azure Platform
    participant Host as Function Host
    participant Runtime as .NET Runtime
    participant Code as Your Code

    Event->>Azure: Trigger (e.g., HTTP request)
    
    Note over Azure,Code: Cold Start (if no instance available)
    Azure->>Azure: Find an available physical server (2-3s)
    Azure->>Host: Allocate and start the Function Host (1-2s)
    Host->>Runtime: Load the .NET runtime (0.5-2s)
    Runtime->>Code: Load your NuGet dependencies (0.5-3s)
    Code-->>Event: Final response (Total: 2-10+ seconds!)
    
    Note over Azure,Code: Warm Start (instance already available)
    Event->>Code: Execute directly (50-200ms)

Factors Influencing Cold Start Duration

FactorImpactOptimization
Number of NuGet packages+++Reduce unnecessary dependencies
Deployed package size++Run-From-Package, avoid large ZIP deployment
.NET runtime+.NET 8 is faster than .NET 6
Hosting plan+++Premium eliminates cold starts
Process isolation+Isolated Worker slightly slower than in-process
Azure region+Choose a region close to users
Number of DI dependencies++Avoid slow initializations at startup

Measuring Cold Starts

// KQL in Application Insights - Identify cold starts
requests
| where timestamp > ago(24h)
| where name contains "yourFunctionName"
| extend isColdStart = customDimensions["ColdStart"] == "True"
| summarize 
    TotalRequests = count(),
    ColdStarts = countif(isColdStart == true),
    ColdStartRate = round(countif(isColdStart == true) * 100.0 / count(), 1),
    AvgDuration_Cold = avgif(duration, isColdStart == true),
    AvgDuration_Warm = avgif(duration, isColdStart == false)
| project TotalRequests, ColdStarts, ColdStartRate, AvgDuration_Cold, AvgDuration_Warm

Solutions to Reduce/Eliminate Cold Starts

Solution 1: Minimize Initialization Time

// Program.cs - Optimizing initialization
var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        // ✅ Use AddDbContextPool instead of AddDbContext
        // Pool reuses DB connections
        services.AddDbContextPool<AppDbContext>(options =>
            options.UseSqlServer(
                Environment.GetEnvironmentVariable("SqlConnection"),
                b => b.EnableRetryOnFailure(3)));
        
        // ✅ Singleton for expensive-to-initialize clients
        services.AddSingleton<CosmosClient>(sp =>
        {
            var options = new CosmosClientOptions
            {
                SerializerOptions = new CosmosSerializationOptions
                {
                    PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
                }
            };
            return new CosmosClient(
                Environment.GetEnvironmentVariable("CosmosDBConnection"),
                options);
        });
        
        // ✅ HttpClient via factory (singleton pool)
        services.AddHttpClient("InventoryAPI", client =>
        {
            client.BaseAddress = new Uri(Environment.GetEnvironmentVariable("InventoryApiUrl")!);
            client.Timeout = TimeSpan.FromSeconds(30);
        });
        
        // ❌ AVOID: slow initialization in ConfigureServices
        // services.AddSingleton<ISlow>(new SlowInitializationService()); // Slows ALL cold starts
    })
    .Build();

Solution 2: Reduce Package Size

# Check published package size
dotnet publish --configuration Release --output ./publish-check
du -sh ./publish-check  # Show size

# Identify large dependencies
find ./publish-check -name "*.dll" | xargs ls -la | sort -k5 -rn | head -20
<!-- .csproj - Publication optimization -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <!-- Enable trimming (removes unused code) -->
    <PublishTrimmed>true</PublishTrimmed>
    <!-- Further reduce with AOT compilation if compatible -->
    <SelfContained>false</SelfContained>
    <!-- Disable XML generation (saves space) -->
    <GenerateDocumentationFile>false</GenerateDocumentationFile>
  </PropertyGroup>
</Project>

Solution 3: Always-Ready Instances (Flex Consumption)

# Configure always-ready instances to reduce cold starts
az functionapp config set \
  --name func-myapp-prod \
  --resource-group rg-myapp \
  --settings "functionsAlwaysReadyConfig=[{\"name\":\"*\",\"instanceCount\":2}]"

Solution 4: Switch to Premium Plan

# Migrate from Consumption to Premium (zero cold start)
az functionapp plan create \
  --name asp-myapp-premium \
  --resource-group rg-myapp \
  --sku EP1 \
  --is-linux false \
  --min-instances 1  # At least 1 pre-warmed instance

az functionapp update \
  --name func-myapp-prod \
  --resource-group rg-myapp \
  --plan asp-myapp-premium

Module 3 – Architecture and Code Best Practices {#module-3}

Core Principle: Small and Focused Functions

“Keep your functions small and single-purpose”

Why?

flowchart LR
    subgraph "❌ Bad Architecture"
        Big[1 large function\n500 lines\nDoes everything\n1000ms execution]
        Big --> DB[DB]
        Big --> Email[Email]
        Big --> Storage[Storage]
        Big --> API[External API]
    end
    
    subgraph "✅ Good Architecture"
        HTTP[HTTP Function\n50ms\nValidate + Route] -->|Queue| Q[Azure Queue]
        Q --> DB_F[DB Function\n100ms\nSave]
        Q --> Email_F[Email Function\n200ms\nNotify]
        DB_F --> Storage_F[Storage Function\n150ms\nArchive]
    end
    
    style Big fill:#D83B01,color:#fff
    style HTTP fill:#107C10,color:#fff

Execution Lifecycle and Instance Pools

stateDiagram-v2
    [*] --> WarmPool: Instance available in pool
    WarmPool --> Running: Invocation received
    Running --> WarmPool: Execution complete
    Running --> Timeout: Duration > configured timeout
    Timeout --> [*]: Instance terminated
    
    [*] --> ColdStart: No instance available
    ColdStart --> Allocating: Find a server
    Allocating --> Starting: Start the runtime
    Starting --> Loading: Load dependencies
    Loading --> Running: Code ready to execute

Code Best Practices for Performance

Reusing Expensive Resources

using Azure.Storage.Blobs;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace OrderService.Optimized;

/// <summary>
/// ✅ CORRECT PATTERN: Clients reused via DI (Singleton)
/// HTTP, Cosmos DB, Storage clients are created ONCE
/// and reused for all invocations
/// </summary>
public class OptimizedFunction
{
    // All these dependencies are Singletons - created only once
    private readonly ILogger<OptimizedFunction> _logger;
    private readonly CosmosClient _cosmosClient;
    private readonly BlobServiceClient _blobClient;
    private readonly HttpClient _httpClient;

    public OptimizedFunction(
        ILogger<OptimizedFunction> logger,
        CosmosClient cosmosClient,           // Singleton ✅
        BlobServiceClient blobClient,         // Singleton ✅  
        IHttpClientFactory httpClientFactory)  // Singleton Factory ✅
    {
        _logger = logger;
        _cosmosClient = cosmosClient;
        _blobClient = blobClient;
        _httpClient = httpClientFactory.CreateClient("default");
    }

    [Function("ProcessWithOptimizedResources")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        // Use pre-initialized clients (no internal cold start)
        var container = _cosmosClient.GetContainer("OrdersDB", "Items");
        var order = await req.ReadFromJsonAsync<OrderRequest>();
        
        await container.CreateItemAsync(order, new PartitionKey(order!.OrderId.ToString()));
        
        _logger.LogInformation("Order {OrderId} created", order.OrderId);
        
        var response = req.CreateResponse(System.Net.HttpStatusCode.Created);
        return response;
    }
}

Optimization with JSON Source Generators (C# 11+)

// Pre-compile JSON serialization for better performance
using System.Text.Json.Serialization;

// Declare the serialization context
[JsonSerializable(typeof(OrderRequest))]
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(List<OrderRequest>))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal partial class AppJsonContext : JsonSerializerContext { }

// Program.cs - Register the context
var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults(builder =>
    {
        builder.UseNewtonsoftJson(); // Or System.Text.Json
    })
    .ConfigureServices(services =>
    {
        services.Configure<JsonSerializerOptions>(options =>
        {
            // Use pre-compiled context = 2-3x faster!
            options.TypeInfoResolver = AppJsonContext.Default;
        });
    })
    .Build();

Async/Await: Golden Rules

// ❌ Bad: Blocks thread with .Result or .Wait()
public HttpResponseData BadAsyncExample(HttpRequestData req)
{
    var data = GetDataAsync().Result;  // ❌ BLOCKS! Potential deadlock!
    var more = GetMoreDataAsync().GetAwaiter().GetResult(); // ❌ ALSO BAD!
    return req.CreateResponse(System.Net.HttpStatusCode.OK);
}

// ❌ Bad: Fire and forget without error handling
public async Task<HttpResponseData> BadFireAndForget(HttpRequestData req)
{
    _ = DoWorkAsync(); // ❌ Exception silently lost!
    return req.CreateResponse(System.Net.HttpStatusCode.OK);
}

// ✅ Correct: Properly await
public async Task<HttpResponseData> GoodAsyncExample(HttpRequestData req)
{
    var data = await GetDataAsync(); // ✅ Non-blocking
    var more = await GetMoreDataAsync(); // ✅ Non-blocking
    return req.CreateResponse(System.Net.HttpStatusCode.OK);
}

// ✅ Correct: Controlled parallelism
public async Task<HttpResponseData> GoodParallelExample(HttpRequestData req)
{
    // Launch in parallel when operations are independent
    var task1 = GetDataAsync();
    var task2 = GetMoreDataAsync();
    
    await Task.WhenAll(task1, task2); // ✅ Parallel and non-blocking!
    
    var result = new { data = task1.Result, more = task2.Result };
    var response = req.CreateResponse(System.Net.HttpStatusCode.OK);
    await response.WriteAsJsonAsync(result);
    return response;
}

Avoiding Long-Running Functions

// ❌ PROBLEM: Processing 10,000 records in a single function
// → Timeout on Consumption Plan (10 min max)
// → Consumes a lot of memory
[Function("BadBulkProcessor")]
public async Task BadBulkProcessor(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
    var allRecords = await GetAll10000RecordsAsync(); // 10,000 records
    
    foreach (var record in allRecords) // Could take 15+ minutes!
    {
        await ProcessRecord(record);
    }
}

// ✅ SOLUTION: Split into small batches via Queue
[Function("SmartBulkInitiator")]
public async Task<HttpResponseData> SmartBulkInitiator(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
    var recordIds = await GetAll10000RecordIdsAsync(); // Get only IDs
    
    // Send each ID to a queue for async processing
    var queueClient = new QueueClient(
        Environment.GetEnvironmentVariable("AzureWebJobsStorage"),
        "bulk-process-queue");
    
    // Send in batches of 100 to optimize API calls
    foreach (var batch in recordIds.Chunk(100))
    {
        await queueClient.SendMessageAsync(
            System.Text.Json.JsonSerializer.Serialize(batch));
    }
    
    var response = req.CreateResponse(System.Net.HttpStatusCode.Accepted);
    await response.WriteStringAsync($"{recordIds.Count} records queued");
    return response;
}

// Each batch is processed by a separate function (fast and scalable)
[Function("ProcessBatch")]
public async Task ProcessBatch(
    [QueueTrigger("bulk-process-queue")] Guid[] recordIds)
{
    // Process only 100 records (fast!)
    foreach (var id in recordIds)
    {
        await ProcessRecord(id);
    }
}

Module 4 – Hardening and Resilience {#module-4}

Designing for Resilience

flowchart TD
    Invoke[Function Invocation] --> Retry{Retry Logic}
    Retry -->|Success| Success[✅ OK Result]
    Retry -->|Transient failure| Wait[Wait\nExponential Backoff]
    Wait --> Retry
    Retry -->|Permanent failure| DeadLetter[📬 Dead Letter Queue]
    DeadLetter --> Alert[🔔 Ops team alert]
    Alert --> Manual[👤 Manual review]
    
    Invoke -->|Concurrent invocations| Lock{Shared resource?}
    Lock -->|Yes| Mutex[Use lock/semaphore]
    Lock -->|No| Parallel[Parallel processing]
    
    style Success fill:#107C10,color:#fff
    style DeadLetter fill:#D83B01,color:#fff

Retry with Polly

using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;

// Program.cs - Configure retry policies with Polly
var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        // Retry policy for external APIs
        var retryPolicy = HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(msg => (int)msg.StatusCode >= 500)
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: retryAttempt =>
                    TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))  // 2s, 4s, 8s
                    + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)),  // Jitter
                onRetry: (outcome, timespan, retryAttempt, context) =>
                {
                    Console.WriteLine($"Retry {retryAttempt} after {timespan.TotalSeconds}s. Reason: {outcome.Exception?.Message}");
                });

        // Circuit Breaker: cuts the service after 5 consecutive failures
        var circuitBreakerPolicy = HttpPolicyExtensions
            .HandleTransientHttpError()
            .CircuitBreakerAsync(
                handledEventsAllowedBeforeBreaking: 5,
                durationOfBreak: TimeSpan.FromSeconds(30),
                onBreak: (outcome, duration) => Console.WriteLine($"Circuit breaker OPEN for {duration.TotalSeconds}s"),
                onReset: () => Console.WriteLine("Circuit breaker CLOSED - Service restored"),
                onHalfOpen: () => Console.WriteLine("Circuit breaker testing..."));

        // Combine both policies
        var combinedPolicy = Policy.WrapAsync(retryPolicy, circuitBreakerPolicy);
        
        services.AddHttpClient("ExternalPaymentAPI", client =>
        {
            client.BaseAddress = new Uri("https://api.payment.com/");
            client.Timeout = TimeSpan.FromSeconds(30);
        })
        .AddPolicyHandler(combinedPolicy);
    })
    .Build();

Idempotence: Key to Resilience

/// <summary>
/// Idempotent Queue function: can be called multiple times with the same message
/// without different effect (Azure Queue commands can be delivered multiple times)
/// </summary>
public class IdempotentOrderProcessor
{
    private readonly ILogger<IdempotentOrderProcessor> _logger;
    private readonly AppDbContext _dbContext;

    public IdempotentOrderProcessor(
        ILogger<IdempotentOrderProcessor> logger,
        AppDbContext dbContext)
    {
        _logger = logger;
        _dbContext = dbContext;
    }

    [Function("ProcessOrderIdempotent")]
    public async Task Run(
        [QueueTrigger("orders-queue", Connection = "AzureWebJobsStorage")]
        OrderQueueMessage message,
        FunctionContext context)
    {
        _logger.LogInformation(
            "Processing order {OrderId} (dequeue count: {Count})",
            message.OrderId,
            message.DequeueCount);

        // 1. Check if already processed (IDEMPOTENCE)
        var existing = await _dbContext.ProcessedOrders
            .AsNoTracking()
            .FirstOrDefaultAsync(o => o.OrderId == message.OrderId);

        if (existing is not null)
        {
            _logger.LogInformation(
                "Order {OrderId} already processed on {ProcessedAt}. Skipped.",
                message.OrderId,
                existing.ProcessedAt);
            return; // Idempotent exit
        }

        // 2. Processing with atomic transaction
        await using var transaction = await _dbContext.Database.BeginTransactionAsync();
        try
        {
            // Mark as "processing" first
            _dbContext.ProcessedOrders.Add(new ProcessedOrder
            {
                OrderId = message.OrderId,
                ProcessedAt = DateTime.UtcNow,
                Status = "Processing"
            });
            await _dbContext.SaveChangesAsync();

            // Business processing
            await ProcessOrderBusinessLogic(message);

            // Update final status
            await _dbContext.ProcessedOrders
                .Where(o => o.OrderId == message.OrderId)
                .ExecuteUpdateAsync(s => s
                    .SetProperty(o => o.Status, "Completed")
                    .SetProperty(o => o.CompletedAt, DateTime.UtcNow));

            await transaction.CommitAsync();
            _logger.LogInformation("Order {OrderId} processed successfully", message.OrderId);
        }
        catch
        {
            await transaction.RollbackAsync();
            throw; // Allows automatic retry
        }
    }

    private async Task ProcessOrderBusinessLogic(OrderQueueMessage message)
    {
        // Business logic here
        await Task.Delay(100); // Simulated
    }
}

Thread Safety and Avoiding Race Conditions

// ❌ DANGEROUS PATTERN: potential race condition with concurrent resource access
private static int _counter = 0;

[Function("DangerousCounter")]
public async Task DangerousFunction(
    [QueueTrigger("work-queue")] WorkItem item)
{
    // ❌ Race condition: multiple simultaneous invocations can corrupt _counter
    _counter++;  // NOT THREAD-SAFE!
    await SaveCounterAsync(_counter);
}

// ✅ CORRECT: Use Interlocked for simple atomic operations
private static int _safeCounter = 0;

[Function("SafeCounter")]
public async Task SafeFunction(
    [QueueTrigger("work-queue")] WorkItem item)
{
    // ✅ Thread-safe: atomic operation
    var newCount = Interlocked.Increment(ref _safeCounter);
    await SaveCounterAsync(newCount);
}

// ✅ EVEN BETTER: Use stateless services
// Store state in Azure Storage (not in memory)
[Function("StatelessCounter")]
public async Task StatelessCounterFunction(
    [QueueTrigger("work-queue")] WorkItem item,
    [TableInput("counters", "main", "total", Connection = "AzureWebJobsStorage")]
    CounterEntity? counter)
{
    // State is in Azure Table Storage, not in memory
    // No concurrency issue between instances
}

Module 5 – Scaling Configuration {#module-5}

Understanding Azure Functions Scaling

flowchart LR
    subgraph "Consumption Plan - Dynamic Scaling"
        Low[Queue: 0 messages] -->|No invocations| Zero[0 active instances\nCost: $0]
        Medium[Queue: 50 messages] -->|Moderate invocations| Some[3-5 instances\nCost: per exec]
        High[Queue: 10,000 messages] -->|Spike!| Many[50-100 instances\nCost: per exec]
        Many -->|Queue empty| Zero2[Back to 0 instances\nCost: $0]
    end

Scaling Configuration in host.json

{
  "version": "2.0",
  "functionTimeout": "00:10:00",
  
  "extensions": {
    "queues": {
      "batchSize": 16,
      "newBatchThreshold": 8,
      "maxDequeueCount": 5,
      "visibilityTimeout": "00:00:30",
      "maxPollingInterval": "00:00:02"
    },
    "serviceBus": {
      "maxConcurrentCalls": 16,
      "maxConcurrentSessions": 8,
      "prefetchCount": 0,
      "autoCompleteMessages": true,
      "sessionIdleTimeout": "00:01:00",
      "maxAutoLockRenewalDuration": "00:05:00"
    },
    "eventHubs": {
      "batchCheckpointFrequency": 5,
      "partitionReceiverOptions": {
        "ownerLevel": 0
      },
      "eventProcessorOptions": {
        "maxBatchSize": 100,
        "prefetchCount": 300,
        "loadBalancingUpdateInterval": "00:00:10",
        "partitionOwnershipExpirationInterval": "00:01:00"
      }
    }
  },
  
  "concurrency": {
    "dynamicConcurrencyEnabled": true,
    "snapshotPersistenceEnabled": true
  },
  
  "healthMonitor": {
    "enabled": true,
    "healthCheckInterval": "00:00:10",
    "healthCheckWindow": "00:02:00",
    "healthCheckThreshold": 6,
    "counterThreshold": 0.80
  }
}

Plan-Specific Configuration

# Consumption Plan: limit maximum scalability
az functionapp config appsettings set \
  --name func-myapp \
  --resource-group rg-myapp \
  --settings \
    "WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT=50"  # Max 50 instances

# Premium Plan: configure minimum and maximum instances
az functionapp plan update \
  --name asp-myapp-premium \
  --resource-group rg-myapp \
  --min-instances 2 \
  --max-burst 20

# Flex Consumption: configure "always ready" instances
az functionapp config appsettings set \
  --name func-myapp \
  --resource-group rg-myapp \
  --settings \
    "WEBSITE_ALWAYS_ON=1" \
    "functionsAlwaysReadyConfig=[{\"name\":\"HttpTriggers\",\"instanceCount\":2}]"

Module 6 – Durable Functions for Long-Running Workloads {#module-6}

Why Durable Functions for Performance?

Standard functions have a maximum timeout (10 min on Consumption). For processes that last longer, Durable Functions is the solution — it supports workflows lasting days, weeks, or months.

flowchart TD
    subgraph "Without Durable Functions (Problem)"
        BF[Standard function] -->|Timeout 10 min| TK[💀 Timeout!\nProcessing lost]
        TK --> Restart[Restart from scratch\n⏱ Resource waste]
    end
    
    subgraph "With Durable Functions (Solution)"
        DF[Orchestrator] -->|Persists state| Storage["(Azure Storage)"]
        DF -->|Activity 1\n2 min| A1[✅]
        A1 -->|Persist| Storage
        DF -->|Activity 2\n5 min| A2[✅]
        A2 -->|Persist| Storage
        DF -->|External wait\n3 days| EXT[External Event]
        EXT -->|Resume| DF
        DF -->|Activity 3| A3[✅ Done]
    end
    
    style A1 fill:#107C10,color:#fff
    style A2 fill:#107C10,color:#fff
    style A3 fill:#107C10,color:#fff
    style TK fill:#D83B01,color:#fff

Automatic Retry in Durable Functions

using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;

namespace OrderPlatform.Durable;

public class ResilientWorkflow
{
    private readonly ILogger<ResilientWorkflow> _logger;

    public ResilientWorkflow(ILogger<ResilientWorkflow> logger)
    {
        _logger = logger;
    }

    [Function(nameof(ResilientWorkflow))]
    public async Task<string> RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var orderId = context.GetInput<string>()!;
        var logger = context.CreateReplaySafeLogger(nameof(ResilientWorkflow));

        // Configure retry policies
        var retryOptions = new TaskOptions(
            new TaskRetryOptions(
                new RetryPolicy(
                    maxNumberOfAttempts: 3,
                    firstRetryInterval: TimeSpan.FromSeconds(5),
                    backoffCoefficient: 2.0,          // 5s, 10s, 20s
                    maxRetryInterval: TimeSpan.FromSeconds(30),
                    retryTimeout: TimeSpan.FromMinutes(5))));

        try
        {
            // Activity with automatic retry
            var result = await context.CallActivityAsync<ProcessResult>(
                nameof(ProcessWithExternalAPI),
                orderId,
                retryOptions);  // ← Automatic retry on failure!

            logger.LogInformation(
                "Workflow complete for order {OrderId}: {Result}",
                orderId, result.Message);
            
            return result.Message;
        }
        catch (TaskFailedException ex)
        {
            logger.LogError(
                "Workflow failed after 3 attempts for order {OrderId}: {Error}",
                orderId, ex.Message);
            
            // Compensation
            await context.CallActivityAsync(nameof(CancelOrder), orderId);
            return $"Order {orderId} cancelled after failure";
        }
    }

    [Function(nameof(ProcessWithExternalAPI))]
    public async Task<ProcessResult> ProcessWithExternalAPI(
        [ActivityTrigger] string orderId,
        FunctionContext ctx)
    {
        _logger.LogInformation("Calling external API for order {OrderId}", orderId);
        
        // Simulate an external API that sometimes fails
        if (Random.Shared.Next(3) == 0)
            throw new HttpRequestException("API temporarily unavailable");
        
        return new ProcessResult($"Order {orderId} processed");
    }

    [Function(nameof(CancelOrder))]
    public async Task CancelOrder([ActivityTrigger] string orderId, FunctionContext ctx)
    {
        _logger.LogWarning("Cancelling order {OrderId}", orderId);
        // Cancellation/compensation logic
    }
}

public record ProcessResult(string Message);

Module 7 – Monitoring and Cost Analysis {#module-7}

Cost Analysis with Azure Cost Management

flowchart LR
    FA[Function App\nExecutions] -->|Metrics| AI[Application Insights]
    FA -->|Costs| CM[Azure Cost Management]
    AI -->|Analysis| Dashboard[KQL Dashboard]
    CM -->|Budget Alerts| Alert[🔔 Budget Alerts]
    
    style FA fill:#68217A,color:#fff
    style Alert fill:#FFB900,color:#000

KQL Queries to Optimize Costs

// Analyze the most expensive functions
requests
| where timestamp > ago(30d)
| summarize 
    TotalExecutions = count(),
    TotalDurationMs = sum(duration),
    AvgDurationMs = avg(duration),
    EstimatedCostUSD = sum(duration) * 0.000016 / 1000  // Rough estimate
  by name
| extend TotalDurationSec = TotalDurationMs / 1000
| order by EstimatedCostUSD desc
| take 20

// Identify long-running functions (candidates for Durable Functions)
requests
| where timestamp > ago(7d)
| where duration > 60000  // More than 1 minute
| summarize 
    LongRunCount = count(),
    MaxDurationMin = max(duration) / 60000,
    P99DurationMin = percentile(duration, 99) / 60000
  by name
| order by LongRunCount desc

// Identify functions with many failures (cost + degradation)
requests
| where timestamp > ago(7d)
| summarize 
    FailureCount = countif(success == false),
    SuccessCount = countif(success == true),
    FailureRate = round(countif(success == false) * 100.0 / count(), 2)
  by name
| where FailureCount > 100
| order by FailureRate desc

// Analyze memory usage (for plan sizing)
performanceCounters
| where timestamp > ago(24h)
| where category == "Process" and counter == "Private Bytes"
| summarize 
    AvgMemoryMB = avg(value) / 1048576,
    MaxMemoryMB = max(value) / 1048576,
    P95MemoryMB = percentile(value, 95) / 1048576
| project AvgMemoryMB, MaxMemoryMB, P95MemoryMB

Configuring Cost Alerts

# Create a monthly budget alert
az consumption budget create \
  --budget-name "azure-functions-monthly-budget" \
  --amount 100 \
  --time-grain Monthly \
  --resource-group rg-myapp \
  --notifications \
    "threshold=80,contactEmails=ops@company.com,operator=GreaterThan" \
    "threshold=100,contactEmails=cto@company.com,operator=GreaterThanOrEqualTo"

# Alert on abnormally high Function executions
az monitor metrics alert create \
  --name "FunctionHighExecutionAlert" \
  --resource-group rg-myapp \
  --scopes $(az functionapp show --name func-myapp --resource-group rg-myapp --query id -o tsv) \
  --condition "count requests > 1000000" \
  --window-size 1h \
  --evaluation-frequency 15m \
  --description "More than 1 million executions in 1 hour (anomaly!)"

Custom Cost Dashboard

// Azure Monitor Workbook - Daily cost estimation
let GBSecPrice = 0.000016;  // Price per GB-sec (approximate)
let ExecPrice = 0.0000002;   // Price per execution
let FreeGBSec = 400000.0;    // Monthly free quota
let FreeExec = 1000000.0;    // Monthly free quota

requests
| where timestamp > ago(1d)
| summarize 
    TotalExecutions = count(),
    TotalDurationMs = sum(duration),
    AvgMemoryMB = 256  // Estimate; actual via perf counters
  by bin(timestamp, 1h), name
| extend 
    GBSec = TotalDurationMs / 1000 * (AvgMemoryMB / 1024),
    DailyExecCost = max_of(TotalExecutions - FreeExec/30, 0) * ExecPrice,
    DailyGBSecCost = max_of(GBSec - FreeGBSec/30, 0) * GBSecPrice
| summarize 
    TotalDailyCost = sum(DailyExecCost + DailyGBSecCost)
  by bin(timestamp, 1h)
| render timechart

Module 8 – Advanced Optimization {#module-8}

Optimizing JSON Serialization

// Use System.Text.Json with optimized options
// and streaming deserialization for large payloads

using System.Net;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker.Http;

public class OptimizedJsonFunction
{
    // Reuse JsonSerializerOptions (avoids re-creation on each call)
    private static readonly JsonSerializerOptions CachedOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
        WriteIndented = false  // No indentation in production = fewer bytes
    };

    [Function("OptimizedJson")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        // Deserialization via ReadFromJsonAsync (optimized, streaming)
        var request = await req.ReadFromJsonAsync<OrderRequest>(CachedOptions);
        
        // Serialization with cached options
        var responseData = new OrderResponse { Id = Guid.NewGuid(), Status = "Accepted" };
        
        var response = req.CreateResponse(HttpStatusCode.Created);
        response.Headers.Add("Content-Type", "application/json");
        await response.WriteStringAsync(
            JsonSerializer.Serialize(responseData, CachedOptions));
        return response;
    }
}

Memory Optimization with ArrayPool

using System.Buffers;

public class MemoryOptimizedFunction
{
    [Function("ProcessLargeData")]
    public async Task Run(
        [BlobTrigger("uploads/{name}", Connection = "AzureWebJobsStorage")]
        Stream blobStream, 
        string name)
    {
        // ✅ Use ArrayPool to avoid repeated allocations
        const int bufferSize = 65536; // 64 KB
        byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
        
        try
        {
            int bytesRead;
            long totalBytes = 0;
            
            while ((bytesRead = await blobStream.ReadAsync(buffer, 0, bufferSize)) > 0)
            {
                totalBytes += bytesRead;
                // Process buffer data...
                await ProcessChunkAsync(buffer, bytesRead);
            }
            
            Console.WriteLine($"File {name} processed: {totalBytes} bytes");
        }
        finally
        {
            // IMPORTANT: Return the buffer to the pool!
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    private static Task ProcessChunkAsync(byte[] buffer, int length)
    {
        // Chunk processing...
        return Task.CompletedTask;
    }
}

Module 9 – Performance Checklist {#module-9}

Complete Optimization Checklist

CategoryOptimizationImpactEase
Cold StartReduce NuGet packages⭐⭐⭐Easy
Cold StartAlways-ready instances (Flex/Premium)⭐⭐⭐Medium
Cold StartSingleton for clients (Cosmos, HTTP)⭐⭐⭐Easy
MemoryArrayPool for large buffers⭐⭐Medium
MemoryJSON Source Generators⭐⭐Medium
MemoryAvoid large in-memory collections⭐⭐⭐Easy
CPUasync/await everywhere (avoid .Result)⭐⭐⭐Easy
CPUParallelism with Task.WhenAll⭐⭐⭐Easy
CPUDbContextPool instead of DbContext⭐⭐Easy
NetworkHttpClient via IHttpClientFactory⭐⭐⭐Easy
NetworkConnection pooling (CosmosClient singleton)⭐⭐⭐Easy
ScalingSmall and focused functions⭐⭐⭐Easy
ScalingAvoid functions > 2 min (Consumption)⭐⭐⭐Medium
CostRun-From-Package instead of classic deployment⭐⭐Easy
CostDaily quota (Consumption) to prevent surprises⭐⭐Easy
ResilienceIdempotence on queue functions⭐⭐⭐Medium
ResilienceRetry policies with Polly⭐⭐⭐Medium
ResilienceDead-letter queue monitoring⭐⭐⭐Easy

Quick Diagnostic Commands

# Check health and metrics of a Function App
FUNC_APP="func-myapp-prod"
RG="rg-myapp"

echo "=== General Status ==="
az functionapp show \
  --name $FUNC_APP \
  --resource-group $RG \
  --query '{status:state, plan:serverFarmId, tier:sku.tier}' \
  --output table

echo "=== Recent Errors ==="
az monitor activity-log list \
  --resource-group $RG \
  --status Failed \
  --offset 1h \
  --query '[].{time:eventTimestamp, operation:operationName.localizedValue, status:status.localizedValue}' \
  --output table

echo "=== Metrics for Last 30 Minutes ==="
az monitor metrics list \
  --resource $(az functionapp show --name $FUNC_APP --resource-group $RG --query id -o tsv) \
  --metric "FunctionExecutionCount" "FunctionExecutionUnits" "Http5xx" \
  --interval PT5M \
  --aggregation Total \
  --output table

echo "=== App Settings (without secret values) ==="
az functionapp config appsettings list \
  --name $FUNC_APP \
  --resource-group $RG \
  --query '[].name' \
  --output table

Glossary {#glossary}

TermDefinition
Always-Ready InstancePre-warmed instance that avoids cold starts (Flex Consumption, Premium)
ArrayPool.NET array pool to reduce memory allocations (GC pressure)
Exponential BackoffIncreasing wait between retries (2s, 4s, 8s) to avoid overload
Circuit BreakerPolly pattern that cuts calls to a failing service
Cold StartStartup delay of a new Function instance with no recent activity
Dynamic ConcurrencyMode where Azure automatically adjusts concurrency based on CPU/memory load
Consumption PlanAzure Functions serverless plan, strictly usage-based billing
DB Context PoolEF Core DbContext pool reducing the cost of connection creation
Dedicated PlanTraditional App Service plan for Azure Functions
Dead-Letter QueueQueue receiving messages that cannot be processed after N attempts
Durable FunctionsAzure Functions extension for stateful and long-running workflows
Flex ConsumptionHybrid plan: serverless + VNet + configurable memory
GB-SecBilling unit: 1 GB of RAM used for 1 second
HttpClientFactory.NET factory managing an HttpClient pool to avoid socket exhaustion
IdempotenceProperty: same operation N times = same result as 1 time
ILogger.NET standard interface for structured logging
JitterRandom variation in retry delays to avoid “thundering herd”
JSON Source GeneratorCompile-time generation of JSON serialization code (faster)
KQLKusto Query Language - Azure Monitor / Application Insights query language
Polly.NET resilience library (retry, circuit breaker, timeout, bulkhead)
Premium PlanAzure Functions plan with pre-warmed instances and VNet
Request Unit (RU)Cosmos DB throughput unit
Scale to ZeroAbility to reduce to 0 active instances (Consumption, Flex)
SingletonDI pattern: one instance created and reused
Thundering HerdScenario where many instances retry simultaneously, overloading a service
TimeoutMaximum execution duration of a function
Warm PoolPool of Azure pre-allocated instances to reduce cold starts

Supplementary Reference

Hosting Plans — Detailed Comparison

5 hosting options and their characteristics:

PlanCold StartMax timeoutMemoryVNetCost
ConsumptionYes10 min1.5 GBPay per exec
Flex ConsumptionReduced60 min512 MB - 4 GBPay per exec + always-ready
Premium❌ (pre-warmed)Unlimited3.5 GB - 14 GBPer hour (active instance)
Dedicated (App Service)UnlimitedPer SKUFixed App Service Plan
Container AppsPer KEDA configUnlimitedConfigurablePer vCPU/memory

Cold Start — Anatomy and Solutions

What is a cold start?

When a function is called after a period of inactivity, Azure must create a new instance.

The 4 stages of a cold start:

Stage 1: Find an available physical host
    ↓ (100-500ms)
Stage 2: Create the Azure Function resource (container/VM)
    ↓ (200-1000ms)
Stage 3: Load the Function runtime + dependencies
    (the .NET runtime, your NuGet packages)
    ↓ (500ms - several seconds)
Stage 4: Execute your code
    ↓ (your business logic)
Total cold start: 1 to 10+ seconds

Cold start reduction strategies:

StrategyPlanDescription
Always-ready instancesFlex ConsumptionKeep N instances always active
Pre-warmed workersPremiumPre-configured instances in the pool
Ping timerConsumptionTimer trigger every 5-10 min to stay warm
Dedicated planDedicatedAlways active, never cold start

Architecture Best Practices

Small and specific functions

❌ Bad — one function that does everything:
OrderProcessingFunction:
  → Validate the order
  → Check inventory
  → Take payment
  → Create shipment
  → Send confirmation
  → Update analytics

✅ Good — separate functions:
ValidateOrderFunction
CheckInventoryFunction
ProcessPaymentFunction
CreateShipmentFunction
SendConfirmationFunction
UpdateAnalyticsFunction

Stateless design

// ❌ Bad — mutable global state
private static int _counter = 0;  // Shared between invocations!

[FunctionName("BadFunction")]
public static void Run(...)
{
    _counter++;  // Race condition with scale-out
}

// ✅ Good — stateless, state in external storage
[FunctionName("GoodFunction")]
public static async Task Run(
    [HttpTrigger] HttpRequest req,
    [CosmosDB("db", "counters")] IAsyncCollector<Counter> outputCounters)
{
    await outputCounters.AddAsync(new Counter { Value = 1, Timestamp = DateTime.UtcNow });
}

Hardening and Resilience Reference

Retry Logic

// host.json — configure retry policies
{
  "extensions": {
    "queues": {
      "maxDequeueCount": 5,        // Retry 5 times before DLQ
      "visibilityTimeout": "00:05:00"  // Delay between retries
    }
  }
}

Exponential backoff retry:

[FunctionName("ResilientFunction")]
[FixedDelayRetry(5, "00:00:30")]  // 5 retries, 30s between each
public static async Task Run(
    [QueueTrigger("orders")] string order,
    ILogger log)
{
    // If exception → automatic retry
    await ProcessOrder(order);
}

Timeout on external calls:

public static async Task Run(...)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    
    try
    {
        var result = await externalService.GetDataAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        log.LogWarning("External call timed out after 30s");
        // Handle gracefully (retry, fallback, etc.)
    }
}

Durable Functions for Long-Running Reference

Why Durable Functions for long-running processes:

Problem with normal functions for long-running:
❌ Timeout (max 10 min in Consumption)
❌ If scale-down, state is lost
❌ No resume after crash

Solution — Durable Functions:
✅ State persisted in Azure Storage (Table + Blobs)
✅ Automatic resume after crash (replay pattern)
✅ Unlimited timeout (orchestration can last days)
✅ State visibility via Azure Portal

Cost estimation for Consumption:

Price: $0.20 / million executions
       $0.000016 / GB-s (memory × duration)

Example:
1 million executions / month
× 256 MB × 0.5 second average = 128,000 GB-s / month
→ Cost = $0.20 + ($0.000016 × 128,000) = $0.20 + $2.05 = $2.25 / month

Optimization Checklist

Performance:
□ Choose the right plan based on needs (is cold start acceptable?)
□ Configure always-ready if using Flex Consumption
□ Reduce NuGet dependencies (cold start)
□ Small and specific functions
□ Avoid long-running operations (use Durable)

Cost:
□ Consumption for sporadic workload
□ Dedicated for constant workload
□ Configure max scale to avoid unexpected overages
□ Monitor costs with Cost Management + budgets/alerts

Resilience:
□ Retry logic with exponential backoff
□ Timeouts on all external calls
□ Dead Letter Queue for failed messages
□ Stateless design (state in Cosmos DB / Storage)

Search Terms

optimizing · azure · functions · performance · cost · serverless · microsoft · cold · plan · optimization · durable · resilience · checklist · comparison · configuration · consumption · hosting · long-running · plans · reference · retry · scaling · analysis · anatomy

Interested in this course?

Contact us to book it or get a custom training plan for your team.