Skip to main content

Performance Tuning

This guide covers performance optimization for event sourcing, outbox processing, and projections.

Before You Start

Performance Benchmarks

Dispatch Framework Overhead (Mar 11, 2026)

The Dispatch messaging layer adds minimal overhead to your application:

OperationLatencyNotes
Single command dispatch55.9 nsHot-path, no middleware
Query with response75.2 nsHot-path, no middleware
Command with 3 middleware~414 nsTypical production pipeline
100K dispatches allocation64.88 MBLong-run GC pressure

Application-Level Baseline Expectations

OperationTarget LatencyTarget Throughput
Aggregate load (with snapshot)<10ms1,000/s
Aggregate load (100 events)<50ms200/s
Aggregate save (single event)<20ms500/s
Outbox message processing<5ms/message10,000/s
Projection update<10ms5,000/s

Event Store Optimization

Snapshot Tuning

The most impactful optimization for read performance:

es.UseCompositeSnapshotStrategy(composite =>
{
// Snapshot every 100 events OR every hour
composite.AddInterval(100)
.AddTimeInterval(TimeSpan.FromHours(1));
});

Measuring Snapshot Effectiveness

Monitor snapshot effectiveness by querying the event store database directly:

-- SQL Server: Find aggregates with many events since last snapshot
SELECT e.StreamId, COUNT(*) AS EventsSinceSnapshot
FROM [dbo].[Events] e
LEFT JOIN [dbo].[Snapshots] s ON e.StreamId = s.StreamId
WHERE e.Version > ISNULL(s.Version, 0)
GROUP BY e.StreamId
HAVING COUNT(*) > 100
ORDER BY COUNT(*) DESC;

Index Optimization

SQL Server

-- Most important: stream lookup
CREATE NONCLUSTERED INDEX IX_Events_StreamId_Version
ON EventSourcing.Events (StreamId, Version)
INCLUDE (EventType, Data, Timestamp);

-- For global sequence queries (CDC, projections)
CREATE NONCLUSTERED INDEX IX_Events_SequenceNumber
ON EventSourcing.Events (SequenceNumber);

-- For time-based queries
CREATE NONCLUSTERED INDEX IX_Events_Timestamp
ON EventSourcing.Events (Timestamp)
WHERE Timestamp > DATEADD(DAY, -30, GETDATE()); -- Filtered for recent data

PostgreSQL

-- Stream lookup with covering index
CREATE INDEX idx_events_stream ON event_sourcing.events (stream_id, version)
INCLUDE (event_type, data, timestamp);

-- BRIN index for time-series (space efficient)
CREATE INDEX idx_events_timestamp_brin
ON event_sourcing.events USING BRIN (timestamp);

-- Partial index for recent events
CREATE INDEX idx_events_recent
ON event_sourcing.events (stream_id, version)
WHERE timestamp > NOW() - INTERVAL '30 days';

Query Optimization

Batch Loading

// DON'T: Load aggregates one by one
foreach (var id in orderIds)
{
var order = await repository.GetByIdAsync(id, ct); // N queries
}

// DO: Use projections for read-heavy scenarios
var orders = await orderProjection.GetByIdsAsync(orderIds, ct); // Single query

Projection Queries

// DON'T: Load from event store for read-heavy scenarios
var events = await _eventStore.LoadAsync("customer-123", "Order", ct);

// DO: Query projections directly
var orders = await _orderProjection.GetByCustomerAsync("customer-123", ct);

Outbox Optimization

Batch Size Tuning

outbox.WithProcessing(p =>
{
p.BatchSize(batchSize); // Tune based on workload
});
ScenarioRecommended Batch Size
Low latency10-50
Balanced100-200
High throughput500-1000
Bulk processing1000+

Parallel Processing

outbox.WithProcessing(p =>
{
p.EnableParallelProcessing(parallelism);
});
CPU CoresRecommended Parallelism
22
44
8+6-8 (leave headroom)

Polling Interval

outbox.WithProcessing(p =>
{
p.PollingInterval(TimeSpan.FromMilliseconds(interval));
});
Latency RequirementPolling Interval
Real-time (<100ms)50-100ms
Near real-time (<1s)200-500ms
Standard1-5s
Relaxed10-30s

Message Size

Keep messages small for better throughput:

// DON'T: Include full aggregate state
public record OrderCreated(
Guid OrderId,
string CustomerId,
List<OrderItem> Items, // Avoid large payloads
string FullJsonState // Never do this
);

// DO: Include only necessary data
public record OrderCreated(
Guid OrderId,
string CustomerId
);

Projection Optimization

Using Inline Projection Handlers

Excalibur provides two approaches for inline projections. For simple cases, use When<T> lambdas. For complex logic needing DI, implement IProjectionEventHandler<TProjection, TEvent>:

// Tier 1: Simple lambda (zero allocation, fastest)
builder.AddProjection<OrderProjection>(p => p
.Inline()
.When<OrderCreated>((proj, e) =>
{
proj.OrderId = e.OrderId;
proj.CustomerId = e.CustomerId;
proj.Status = "Created";
}));

// Tier 3: DI-resolved handler (when you need logging, async, or custom IDs)
public sealed class OrderCreatedHandler
: IProjectionEventHandler<OrderProjection, OrderCreated>
{
private readonly ILogger<OrderCreatedHandler> _logger;

public OrderCreatedHandler(ILogger<OrderCreatedHandler> logger)
=> _logger = logger;

public Task HandleAsync(
OrderProjection projection,
OrderCreated @event,
ProjectionHandlerContext context,
CancellationToken cancellationToken)
{
projection.OrderId = @event.OrderId;
projection.CustomerId = @event.CustomerId;
projection.Status = "Created";
_logger.LogDebug("Projected order {OrderId}", @event.OrderId);
return Task.CompletedTask;
}
}

// Registration -- handler is auto-registered in DI as Transient
builder.AddProjection<OrderProjection>(p => p
.Inline()
.WhenHandledBy<OrderCreated, OrderCreatedHandler>());

Batch Projection Updates

For high-throughput scenarios, batch your projection writes:

// Use async projections (GlobalStreamProjectionHost) with CDC for high-throughput batch processing.
// The inline projection pipeline handles events per-aggregate during SaveAsync().
// For cross-aggregate batch scenarios, configure CDC-based async projections:
services.AddCdcProcessor(cdc =>
{
cdc.UseSqlServer(sql => sql.ConnectionString(connectionString))
.TrackTable("dbo.Orders", table =>
{
table.MapInsert<OrderCreatedEvent>()
.MapUpdate<OrderUpdatedEvent>();
})
.EnableBackgroundProcessing();
});

Denormalization

Trade storage for query speed:

// Denormalized projection for fast queries
public record OrderProjection
{
public Guid OrderId { get; init; }
public string CustomerId { get; init; }
public string CustomerName { get; init; } // Denormalized
public string CustomerEmail { get; init; } // Denormalized
public List<OrderItemProjection> Items { get; init; }
public decimal TotalAmount { get; init; } // Pre-computed
}

Indexing Projections

-- Query patterns drive index design
CREATE INDEX idx_orders_customer ON OrderProjections (CustomerId);
CREATE INDEX idx_orders_status_date ON OrderProjections (Status, CreatedAt DESC);
CREATE INDEX idx_orders_total ON OrderProjections (TotalAmount DESC);

Connection Pool Tuning

SQL Server

var connectionString = "...;Max Pool Size=100;Min Pool Size=10;";

PostgreSQL with PgBouncer

# pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 50

Connection Health

services.AddHealthChecks()
.AddCheck("connection-pool", () =>
{
var stats = SqlConnection.GetPoolStatistics(connectionString);
var utilization = (double)stats.CurrentFree / stats.MaxPoolSize;

return utilization > 0.1
? HealthCheckResult.Healthy($"Pool {utilization:P0} free")
: HealthCheckResult.Degraded($"Pool exhausted: {utilization:P0} free");
});

Caching

Aggregate Caching

Use IDistributedCache or IMemoryCache to cache loaded aggregates and reduce event store reads:

services.AddMemoryCache();
services.AddDistributedMemoryCache();

// Register a caching decorator around your repository
services.Decorate<IEventSourcedRepository<OrderAggregate, Guid>>(
(inner, sp) => new CachingRepository<OrderAggregate, Guid>(
inner,
sp.GetRequiredService<IMemoryCache>(),
slidingExpiration: TimeSpan.FromMinutes(5)));

Snapshot Caching

Snapshot stores benefit from caching since snapshots change infrequently:

services.AddMemoryCache();

// Cache snapshots with absolute expiration to ensure freshness
services.Decorate<ISnapshotStore>(
(inner, sp) => new CachingSnapshotStore(
inner,
sp.GetRequiredService<IMemoryCache>(),
absoluteExpiration: TimeSpan.FromMinutes(10)));

Profiling

Enable Detailed Metrics

services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("Excalibur.EventSourcing.*");
metrics.AddMeter("Excalibur.Dispatch.*");
});

SQL Query Analysis

-- SQL Server: Enable Query Store
ALTER DATABASE EventStore SET QUERY_STORE = ON;

-- Find slow queries
SELECT TOP 10
qsq.query_id,
qsqt.query_sql_text,
qsp.avg_duration / 1000.0 AS avg_ms
FROM sys.query_store_query qsq
JOIN sys.query_store_query_text qsqt ON qsq.query_text_id = qsqt.query_text_id
JOIN sys.query_store_plan qsp ON qsq.query_id = qsp.query_id
ORDER BY avg_duration DESC;

Performance Checklist

Event Store

  • Snapshots configured with appropriate interval
  • Indexes created for common access patterns
  • Connection pool sized appropriately
  • Batch operations used where possible

Outbox

  • Batch size optimized for workload
  • Parallel processing enabled
  • Polling interval matches latency needs
  • Message payloads kept small

Projections

  • Batch processing implemented
  • Appropriate indexes on projection stores
  • Denormalization used where beneficial
  • Caching enabled for hot data

General

  • OpenTelemetry metrics enabled
  • Query profiling active
  • Health checks monitoring resources
  • Load testing performed

Quick Wins

OptimizationImpactEffort
Enable snapshotsHighLow
Add missing indexesHighLow
Increase batch sizesMediumLow
Enable cachingMediumLow
Optimize message sizeMediumMedium
Add parallelismMediumLow
Denormalize projectionsHighHigh
Connection pool tuningMediumLow

See Also

  • Caching — Distributed and in-memory caching strategies
  • Auto-Freeze — Automatic aggregate freezing for performance optimization
  • Metrics Reference — Complete list of available performance metrics
  • Resilience with Polly — Retry policies and circuit breakers for resilient operations