Azure Functions — Performance: Singleton Clients & Prefetch

Avoid Cold Allocation, Reuse SDK Clients in DI


Introduction

Performance optimization in Azure Functions is crucial for production workloads. One of the most impactful optimizations is properly managing client connections to Azure services. Creating new clients for every function invocation causes:

  • Connection overhead — Establishing TCP connections takes time
  • Cold starts — New clients contribute to function cold start times
  • Resource exhaustion — Too many connections can exhaust available sockets
  • Increased latency — Each request waits for connection establishment

This guide covers how to properly implement singleton clients and configure prefetching for optimal performance.


The Problem: Per-Invocation Clients

What Happens by Default

// ❌ BAD: Creating clients inside function
[FunctionName("ProcessOrder")]
public async Task ProcessOrder(
    [ServiceBusTrigger("orders")] Message message,
    ILogger log)
{
    // Creates NEW ServiceBusClient EVERY invocation
    var client = new ServiceBusClient("connection-string");
    
    // Creates NEW sender for each message
    var sender = client.CreateSender("processed-orders");
    
    await sender.SendMessageAsync(message);
    
    // Client is disposed - connection closed
}

Why This Is Problematic

Invocation 1: Create client (50ms) → Send (10ms) → Close (5ms)
Invocation 2: Create client (50ms) → Send (10ms) → Close (5ms)
Invocation 3: Create client (50ms) → Send (10ms) → Close (5ms)

Total per invocation: ~65ms overhead from client management

Solution: Singleton Clients with Dependency Injection

Step 1: Configure in Startup/Program.cs

using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Azure.Storage.Queues;
using Azure.Data.Tables;
using Azure.Messaging.ServiceBus;

[assembly: FunctionsStartup(typeof(MyFunctionApp.Startup))]

namespace MyFunctionApp
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            // ========== Service Bus ==========
            builder.Services.AddSingleton<ServiceBusClient>(serviceProvider =>
            {
                var configuration = serviceProvider.GetService<IConfiguration>();
                
                var clientOptions = new ServiceBusClientOptions
                {
                    // Connection idle timeout - keep connection alive
                    ConnectionIdleTimeout = TimeSpan.FromMinutes(10),
                    
                    // Enable retry for transient failures
                    RetryOptions = new ServiceBusRetryOptions
                    {
                        MaxRetries = 3,
                        Delay = TimeSpan.FromMilliseconds(800),
                        MaxDelay = TimeSpan.FromSeconds(30)
                    }
                };
                
                return new ServiceBusClient(
                    configuration["ServiceBus:ConnectionString"],
                    clientOptions);
            });
            
            // Register processor factory for cleaner usage
            builder.Services.AddSingleton<ServiceBusProcessorFactory>();
            
            // ========== Blob Storage ==========
            builder.Services.AddSingleton<BlobServiceClient>(serviceProvider =>
            {
                var configuration = serviceProvider.GetService<IConfiguration>();
                var connectionString = configuration["AzureWebJobsStorage"];
                
                var options = new BlobClientOptions
                {
                    // Retry options
                    Retry = {
                        MaxRetries = 3,
                        Delay = TimeSpan.FromSeconds(2)
                    }
                };
                
                return new BlobServiceClient(connectionString, options);
            });
            
            // ========== Table Storage ==========
            builder.Services.AddSingleton<TableServiceClient>(serviceProvider =>
            {
                var configuration = serviceProvider.GetService<IConfiguration>();
                return new TableServiceClient(
                    configuration["AzureWebJobsStorage"]);
            });
            
            // ========== HTTP Client ==========
            builder.Services.AddHttpClient("external-api", client =>
            {
                client.BaseAddress = new Uri("https://api.example.com/");
                client.Timeout = TimeSpan.FromSeconds(30);
            });
            
            // ========== Cosmos DB ==========
            builder.Services.AddSingleton<CosmosClient>(serviceProvider =>
            {
                var configuration = serviceProvider.GetService<IConfiguration>();
                
                var options = new CosmosClientOptions
                {
                    // Optimize for high throughput
                    AllowBulkExecution = true,
                    
                    // Consistency level based on requirements
                    ConsistencyLevel = ConsistencyLevel.Session,
                    
                    // Connection modes
                    ConnectionMode = ConnectionMode.Direct,
                    
                    // Retry options
                    MaxRetryAttemptsOnRequest = 3,
                    MaxRetryWaitTimeOnRequest = TimeSpan.FromSeconds(30)
                };
                
                return new CosmosClient(
                    configuration["CosmosDB:ConnectionString"],
                    options);
            });
            
            // ========== Key Vault (using Managed Identity) ==========
            builder.Services.AddSingleton<SecretClient>(serviceProvider =>
            {
                var vaultUri = new Uri("https://my-keyvault.vault.azure.net/");
                var credential = new DefaultAzureCredential();
                
                return new SecretClient(vaultUri, credential);
            });
        }
    }
}

Step 2: Use Injected Clients in Functions

public class OrderProcessingFunction
{
    private readonly ServiceBusClient _serviceBusClient;
    private readonly BlobServiceClient _blobClient;
    private readonly TableServiceClient _tableClient;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly SecretClient _keyVaultClient;
    private readonly ILogger<OrderProcessingFunction> _log;
    
    // Clients injected via constructor - same instance for all invocations
    public OrderProcessingFunction(
        ServiceBusClient serviceBusClient,
        BlobServiceClient blobClient,
        TableServiceClient tableClient,
        IHttpClientFactory httpClientFactory,
        SecretClient keyVaultClient,
        ILogger<OrderProcessingFunction> log)
    {
        _serviceBusClient = serviceBusClient;
        _blobClient = blobClient;
        _tableClient = tableClient;
        _httpClientFactory = httpClientFactory;
        _keyVaultClient = keyVaultClient;
        _log = log;
    }
    
    [FunctionName("ProcessOrder")]
    public async Task ProcessOrder(
        [ServiceBusTrigger("orders", Connection = "ServiceBusConnection")] 
        Message message,
        CancellationToken cancellationToken)
    {
        _log.LogInformation("Processing order message");
        
        // Deserialize order
        var order = JsonSerializer.Deserialize<Order>(message.Body);
        
        // Get secret from Key Vault - reuse client
        var secret = await _keyVaultClient.GetSecretAsync("api-key", cancellationToken);
        
        // Upload to Blob - reuse client
        var containerClient = _blobClient.GetBlobContainerClient("orders");
        var blobClient = containerClient.GetBlobClient($"{order.OrderId}.json");
        
        var orderJson = JsonSerializer.Serialize(order);
        using var stream = new MemoryStream(Encoding.UTF8.GetBytes(orderJson));
        await blobClient.UploadAsync(stream, cancellationToken);
        
        // Store in Table - reuse client
        var tableClient = _tableClient.GetTableClient("OrdersTable");
        await tableClient.UpsertEntityAsync(new TableEntity
        {
            PartitionKey = order.CustomerId,
            RowKey = order.OrderId,
            ["OrderDate"] = order.OrderDate,
            ["TotalAmount"] = order.TotalAmount,
            ["Status"] = "Processed"
        }, cancellationToken);
        
        // Send to another queue - reuse client
        var sender = _serviceBusClient.CreateSender("processed-orders");
        var responseMessage = new ServiceBusMessage
        {
            Body = BinaryData.FromString(JsonSerializer.Serialize(new 
            { 
                OrderId = order.OrderId, 
                ProcessedAt = DateTime.UtcNow 
            })),
            ContentType = "application/json"
        };
        await sender.SendMessageAsync(responseMessage, cancellationToken);
        
        _log.LogInformation("Order {OrderId} processed successfully", order.OrderId);
    }
}

Step 3: Proper HTTP Client Usage

public class ExternalApiFunction
{
    private readonly IHttpClientFactory _httpClientFactory;
    
    public ExternalApiFunction(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }
    
    [FunctionName("FetchExternalData")]
    public async Task<IActionResult> FetchExternalData(
        [HttpTrigger] HttpRequest req,
        ILogger log)
    {
        // Create client from factory - handles pooling
        var httpClient = _httpClientFactory.CreateClient("external-api");
        
        // All requests use the same connection pool
        var response = await httpClient.GetAsync("endpoint/data");
        
        var content = await response.Content.ReadAsStringAsync();
        
        return new OkObjectResult(content);
    }
}

Configuring Prefetch for Service Bus

Prefetching allows the processor to fetch multiple messages at once, reducing round trips:

host.json Configuration

{
  "version": "2.0",
  "extensions": {
    "serviceBus": {
      "prefetchCount": 100,
      "messageHandlerOptions": {
        "autoComplete": false,
        "maxConcurrentCalls": 16,
        "maxAutoRenewDuration": "00:05:00"
      },
      "batchOptions": {
        "maxBatchSize": 100,
        "maxWaitTimeInSeconds": 30
      }
    }
  }
}

Configuration Details

SettingRecommended ValueDescription
prefetchCount100-200Number of messages to prefetch
maxConcurrentCalls16-32Parallel processing of messages
maxAutoRenewDuration5 minutesAuto-renew message lock
autoCompletefalseManual completion for reliability
maxBatchSize100Messages per function invocation
maxWaitTimeInSeconds30Max time to wait for batch

Programmatic Prefetch Configuration

builder.Services.AddSingleton<ServiceBusProcessor>(serviceProvider =>
{
    var client = serviceProvider.GetRequiredService<ServiceBusClient>();
    var options = new ServiceBusProcessorOptions
    {
        // High prefetch for high throughput
        PrefetchCount = 200,
        
        // Process in batches
        MaxConcurrentCalls = 32,
        
        // Auto-complete after successful processing
        AutoComplete = false,
        
        // Handle errors gracefully
        MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5)
    };
    
    var processor = client.CreateProcessor("orders", options);
    
    processor.ProcessMessageAsync += async args =>
    {
        try
        {
            // Process message
            var message = args.Message;
            
            // Complete if successful
            await args.CompleteMessageAsync(args.Message);
        }
        catch (Exception ex)
        {
            // Abandon on failure - will be retried
            await args.AbandonMessageAsync(args.Message);
        }
    };
    
    processor.ProcessErrorAsync += args =>
    {
        // Log error
        return Task.CompletedTask;
    };
    
    return processor;
});

Event Hubs Prefetch Configuration

{
  "extensions": {
    "eventHubs": {
      "batch": {
        "maxBatchSize": 100,
        "prefetchCount": 200
      },
      "eventProcessorOptions": {
        "maxConcurrentCalls": 16,
        "maxRetryAttempts": 3,
        "checkpointInterval": "00:00:30"
      }
    }
  }
}

Configuring in Code

[FunctionName("EventProcessor")]
public async Task Run(
    [EventHubTrigger("events", Connection = "EventHubConnection")] 
    EventData[] events,
    ILogger log)
{
    // With prefetch, events are already loaded
    foreach (var evt in events)
    {
        var body = Encoding.UTF8.GetString(evt.Body);
        await ProcessEventAsync(body, log);
    }
}

Blob Storage Optimization

builder.Services.AddSingleton<BlobContainerClient>(serviceProvider =>
{
    var config = serviceProvider.GetService<IConfiguration>();
    var client = new BlobServiceClient(config["AzureWebJobsStorage"])
        .GetBlobContainerClient("uploads");
    
    return client;
});

public class UploadFunction
{
    private readonly BlobContainerClient _containerClient;
    
    // Reuse client - connections are pooled
    public UploadFunction(BlobContainerClient containerClient)
    {
        _containerClient = containerClient;
    }
    
    [FunctionName("UploadFile")]
    public async Task UploadFile(
        [BlobTrigger("uploads/{name}")] Stream input,
        string name,
        ILogger log)
    {
        // Client already configured with proper retry options
        var blobClient = _containerClient.GetBlobClient(name);
        
        // Upload with proper options
        await blobClient.UploadAsync(input, new BlobUploadOptions
        {
            TransferOptions = new StorageTransferOptions
            {
                // Parallel transfer for large files
                Concurrency = 4,
                
                // Progress reporting
                ProgressHandler = new Progress<long>(bytes => 
                    log.LogInformation("Uploaded {bytes} bytes", bytes))
            }
        });
    }
}

Performance Comparison

Before: Per-Invocation Clients

[FunctionName("BadPerformance")]
public async Task BadPerformance([ServiceBusTrigger("orders")] Message m)
{
    var client = new ServiceBusClient("conn");  // 50ms
    var sender = client.CreateSender("queue");   // 5ms
    await sender.SendMessageAsync(m);             // 10ms
    // Total: ~65ms overhead per invocation
}

After: Singleton Clients

public class GoodPerformance
{
    private readonly ServiceBusClient _client;  // Injected once
    
    public GoodPerformance(ServiceBusClient client)
    {
        _client = client;  // Reused for all invocations
    }
    
    [FunctionName("GoodPerformance")]
    public async Task Run([ServiceBusTrigger("orders")] Message m)
    {
        var sender = _client.CreateSender("queue");  // ~1ms (from pool)
        await sender.SendMessageAsync(m);             // 10ms (actual work)
        // Total: ~11ms (no client creation overhead)
    }
}

Results

MetricBeforeAfterImprovement
Avg Latency65ms11ms83% faster
Cold Start+500ms+50ms90% reduction
Socket UsageHighLowStable

Monitoring and Diagnostics

Application Insights Configuration

builder.Services.AddApplicationInsightsTelemetry(options =>
{
    options.EnableDependencyTrackingTelemetryModule = true;
    options.EnableServiceBusTracing = true;
    options.EnableEventHubTracing = true;
});

KQL Queries for Performance

// Request latency by function
requests
| where timestamp > ago(1h)
| summarize avg(duration) by name

// Dependency calls performance
dependencies
| where timestamp > ago(1h)
| summarize avg(duration), count() by name, target

// Track connection pooling
customEvents
| where name == "ServiceBusConnection"
| extend isNewConnection = customDimensions.IsNewConnection
| summarize count() by isNewConnection

Best Practices Summary

PracticeImplementation
Use DI for all clientsRegister as singletons in Startup
Configure retry optionsBuilt-in retry handles transient failures
Set prefetch strategicallyHigher prefetch = lower latency
Use HttpClientFactoryProper connection pooling for HTTP
Monitor performanceTrack connection creation overhead
Test under loadVerify connection pool works under load

Common Mistakes to Avoid

  1. Don't create clients inside function body
  2. Don't use new HttpClient() - use IHttpClientFactory
  3. Don't set prefetch too high (causes memory pressure)
  4. Don't forget to dispose if not using DI
  5. Don't share clients across different connection strings

Azure Integration Hub - Advanced Level