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
| Setting | Recommended Value | Description |
|---|---|---|
prefetchCount | 100-200 | Number of messages to prefetch |
maxConcurrentCalls | 16-32 | Parallel processing of messages |
maxAutoRenewDuration | 5 minutes | Auto-renew message lock |
autoComplete | false | Manual completion for reliability |
maxBatchSize | 100 | Messages per function invocation |
maxWaitTimeInSeconds | 30 | Max 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
| Metric | Before | After | Improvement |
|---|---|---|---|
| Avg Latency | 65ms | 11ms | 83% faster |
| Cold Start | +500ms | +50ms | 90% reduction |
| Socket Usage | High | Low | Stable |
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
| Practice | Implementation |
|---|---|
| Use DI for all clients | Register as singletons in Startup |
| Configure retry options | Built-in retry handles transient failures |
| Set prefetch strategically | Higher prefetch = lower latency |
| Use HttpClientFactory | Proper connection pooling for HTTP |
| Monitor performance | Track connection creation overhead |
| Test under load | Verify connection pool works under load |
Common Mistakes to Avoid
- ❌ Don't create clients inside function body
- ❌ Don't use
new HttpClient()- use IHttpClientFactory - ❌ Don't set prefetch too high (causes memory pressure)
- ❌ Don't forget to dispose if not using DI
- ❌ Don't share clients across different connection strings
Azure Integration Hub - Advanced Level