Skip to main content

Performance Tuning

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

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • A deployed Excalibur application with observability enabled
  • Familiarity with metrics reference and caching

Performance Benchmarks

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 IProjectionEventProcessor

Excalibur provides IProjectionEventProcessor for projection processing. Implement this interface for your handlers:

public class OrderProjectionHandler : IProjectionEventProcessor
{
private readonly IProjectionStore<OrderProjection> _store;

public OrderProjectionHandler(IProjectionStore<OrderProjection> store)
=> _store = store;

public async Task HandleAsync(object eventData, CancellationToken ct)
{
// Handle events and update projections
if (eventData is OrderCreated e)
{
await _store.UpsertAsync(e.OrderId.ToString(), new OrderProjection
{
OrderId = e.OrderId,
CustomerId = e.CustomerId,
Status = "Created"
}, ct);
}
}
}

// Register projection handler via DI
builder.Services.AddSingleton<IProjectionEventProcessor, OrderProjectionHandler>();

Batch Projection Updates

For high-throughput scenarios, batch your projection writes:

public class BatchingOrderProjectionHandler : IProjectionEventProcessor
{
private readonly IProjectionStore<OrderProjection> _store;
private readonly List<OrderProjection> _batch = new();
private const int BatchSize = 100;

public async Task HandleAsync(object eventData, CancellationToken ct)
{
if (eventData is OrderCreated e)
{
_batch.Add(new OrderProjection { OrderId = e.OrderId });

if (_batch.Count >= BatchSize)
{
await _store.UpsertManyAsync(_batch, ct);
_batch.Clear();
}
}
}
}

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