Skip to main content

Auto-Freeze

Dispatch automatically optimizes internal caches on application startup using FrozenDictionary. This provides lock-free, high-performance lookups in production without any configuration.

Before You Start​

  • .NET 10.0
  • Install the required package:
    dotnet add package Excalibur.Dispatch
  • Auto-freeze is enabled by default — no additional setup required

How It Works​

When your application starts, Dispatch listens for IHostApplicationLifetime.ApplicationStarted and freezes all internal caches:

// Automatic - happens on startup (default behavior)
var host = builder.Build();
await host.RunAsync();

// Caches freeze automatically when ApplicationStarted fires
// Zero configuration required!

What Gets Frozen​

CachePurposeBenefit
Handler invocationCompiled handler delegatesLock-free lookups
Handler registryManual handler registrationsNo synchronization overhead
Handler activationHandler context setupFaster activation
Result factoryMessage result creationOptimized result creation
Middleware evaluationMiddleware applicability metadataFaster middleware filtering

Performance Impact​

MetricMeasuredNotes
Handler registry lookup (warm hit)4.09-4.12 ns, 0 BConsistent across transient, scoped and singleton handlers
Handler registry lookup (cold miss)6.35-6.41 ns, 0 BA miss costs more than a hit
Resolve action handler (singleton)5.50 ns, 0 B
Resolve action handler (transient)7.05 ns
Resolve action handler (scoped)69.76 nsScope creation dominates; the registry is not the cost here
Memory overheadSynchronization locks removedReduced GC pressure
CPU overheadLock-free after freezeBetter scalability

Measured 2026-09-04, HandlerResolutionBenchmarks, BenchmarkDotNet 0.15.8 in-process on .NET 10.0.11 (i9-14900K).

Historical profile-selection measurements

Earlier versions cached profile selection by message type. Measurements recorded on 2026-09-05 compared that implementation's concurrent and frozen dictionaries. They do not describe the current PipelineProfileRegistry: it evaluates compatibility against each message and publishes an immutable snapshot of profile membership. This preserves message-dependent matching and observes completed registration/removal changes. There is no per-message-type profile-selection cache to freeze.

Do not disable auto-freeze to work around historical profile-cache behavior. Handler registries and invoker registrations are separate mechanisms; register those before freezing. The handler lookup measurements above are steady-state results, not a measured before/after-freeze speedup.

Configuration​

Auto-freeze is enabled by default. No configuration needed:

builder.Services.AddDispatch();

Opt-out for Development​

If you need to register handlers at runtime (rare), disable auto-freeze:

builder.Services.Configure<DispatchOptions>(options =>
{
options.CrossCutting.Performance.AutoFreezeOnStart = false;
});
Runtime Registration

Disabling auto-freeze means caches remain mutable, using ConcurrentDictionary with synchronization overhead. Only disable if you have a specific need for runtime handler registration.

Hot Reload Detection​

Auto-freeze is automatically disabled when hot reload is detected:

  • dotnet watch sets DOTNET_WATCH=1
  • Edit & Continue sets DOTNET_MODIFIABLE_ASSEMBLIES=debug

This ensures handler discovery works correctly during development without any configuration.

Manual Cache Control​

For advanced scenarios, use IDispatchCacheManager directly:

public class WarmupService : IHostedService
{
private readonly IDispatchCacheManager _cacheManager;

public WarmupService(IDispatchCacheManager cacheManager)
{
_cacheManager = cacheManager;
}

public Task StartAsync(CancellationToken cancellationToken)
{
// Optionally trigger freeze after custom warmup
_cacheManager.FreezeAll();
return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken)
=> Task.CompletedTask;
}

Cache Status Diagnostics​

Check the freeze status programmatically:

public class DiagnosticsController : ControllerBase
{
private readonly IDispatchCacheManager _cacheManager;

public DiagnosticsController(IDispatchCacheManager cacheManager)
{
_cacheManager = cacheManager;
}

[HttpGet("cache-status")]
public IActionResult GetCacheStatus()
{
var status = _cacheManager.GetStatus();

return Ok(new
{
AllFrozen = status.AllFrozen,
FrozenAt = status.FrozenAt,
HandlerInvoker = status.HandlerInvokerFrozen,
HandlerRegistry = status.HandlerRegistryFrozen,
HandlerActivator = status.HandlerActivatorFrozen,
ResultFactory = status.ResultFactoryFrozen,
MiddlewareEvaluator = status.MiddlewareEvaluatorFrozen
});
}
}

CacheFreezeStatus Properties​

PropertyTypeDescription
AllFrozenboolTrue if all caches are frozen
FrozenAtDateTimeOffset?Timestamp when freeze occurred
HandlerInvokerFrozenboolHandler invocation cache status
HandlerRegistryFrozenboolHandler registry cache status
HandlerActivatorFrozenboolHandler activation cache status
ResultFactoryFrozenboolResult factory cache status
MiddlewareEvaluatorFrozenboolMiddleware evaluator cache status

Health Check Integration​

Add a health check to monitor cache status:

public class CacheHealthCheck : IHealthCheck
{
private readonly IDispatchCacheManager _cacheManager;

public CacheHealthCheck(IDispatchCacheManager cacheManager)
{
_cacheManager = cacheManager;
}

public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken)
{
var status = _cacheManager.GetStatus();

if (status.AllFrozen)
{
return Task.FromResult(HealthCheckResult.Healthy(
$"All caches frozen at {status.FrozenAt}"));
}

// Degraded is OK for development mode
return Task.FromResult(HealthCheckResult.Degraded(
"Caches not frozen (development mode or late registration)"));
}
}

// Register the health check
builder.Services.AddHealthChecks()
.AddCheck<CacheHealthCheck>("dispatch-caches");

Freeze Timing​

The freeze occurs at ApplicationStarted, not ApplicationStarting. This ensures:

  1. DI container is fully built
  2. All handlers have been registered
  3. Application is ready to serve requests
Host.Build() -> ConfigureServices -> ApplicationStarting -> ApplicationStarted -> FreezeAll()
^
Caches freeze here

Troubleshooting​

Caches Not Freezing​

Symptom: GetStatus().AllFrozen returns false in production

Causes:

  1. AutoFreezeOnStart disabled in configuration
  2. Hot reload environment variables set
  3. Application not using generic host

Solution:

// Verify configuration
builder.Services.Configure<PerformanceOptions>(perf =>
{
perf.AutoFreezeOnStart = true; // Explicit enable
});

// Or freeze manually after startup
app.Lifetime.ApplicationStarted.Register(() =>
{
var cacheManager = app.Services.GetRequiredService<IDispatchCacheManager>();
cacheManager.FreezeAll();
});

Performance Degradation​

Symptom: Handler lookups slower than expected

Diagnosis:

var status = cacheManager.GetStatus();
if (!status.AllFrozen)
{
_logger.LogWarning("Caches not frozen - using ConcurrentDictionary fallback");
}

Solution: Ensure caches are frozen before handling production traffic.

Best Practices​

  1. Let it happen automatically - The default configuration is optimal for most applications

  2. Don't disable without reason - Only disable auto-freeze if you have a specific need for runtime handler registration

  3. Monitor in production - Add a health check to verify caches are frozen

  4. Warmup before freeze - If you have lazy-loaded handlers, ensure they're registered before ApplicationStarted

See Also​

Next Steps​