Caching
Summary
Zeeq uses HybridCache as the single application cache API. This gives us the speed of local memory with the consistency of a shared distributed cache while keeping feature code insulated from the details of the backing provider.
The default distributed provider is PostgreSQL because Zeeq already runs on Postgres and cache data is disposable. This is intentionally pragmatic: we get most of the value of a shared cache without introducing Redis or another service on day one, while keeping the design open for Redis later if the workload calls for it.
How it works
Conceptually, the cache has two layers:
Application code
-> HybridCache
-> L1: local memory inside the current process
-> L2: distributed cache provider
-> PostgreSQL today
-> Redis later if needed
The L1 cache is fast because it lives inside the current process. The L2 cache is shared because it lives outside the process and can be used by the web server and worker processes at the same time.
When a value is missing, HybridCache runs the factory, stores the result, and serves later callers from cache. It also helps avoid the classic cache stampede problem where many callers all try to recompute the same missing value at the same time.
The important rule is simple: feature code should depend on HybridCache, not on Postgres, Redis, or IDistributedCache directly. That keeps call sites stable even if the L2 provider changes.
Why Postgres first?
Postgres is already part of the Zeeq runtime, so using it as the first distributed cache keeps the system easier to run locally and easier to operate in the first production shape. There is no extra service to install, wire into Aspire, monitor, or provision.
The cache table is created by Microsoft.Extensions.Caching.Postgres, not by EF Core migrations. This is different from application data because cache data is not part of the durable domain model.
Zeeq uses an unlogged Postgres table for cache entries by default. That means Postgres skips write-ahead logging for cache writes, which reduces write overhead and WAL volume for data that can be regenerated. The trade-off is acceptable here: cache entries may be lost after an unclean database shutdown, but the application can rebuild them.
Key pieces
| Piece | What it does |
|---|---|
HybridCache | The API application code should use. |
IDistributedCache | The L2 abstraction used underneath HybridCache. |
Microsoft.Extensions.Caching.Postgres | The current distributed cache provider. |
AppSettings:Cache | Provider, expiration, payload, and table configuration. |
SetupCache.cs | Runtime setup for the L2 provider and HybridCache. |
ConnectionStrings__zeeq-cache | Optional dedicated cache connection string. |
Configuration
The main configuration section is AppSettings:Cache.
| Setting | Default | Notes |
|---|---|---|
Provider | Postgres | The distributed cache provider. |
SchemaName | cache | The Postgres schema for the cache table. |
TableName | hybrid_cache | The Postgres cache table name. |
CreateIfNotExists | true | Lets the Postgres provider create the table at startup. |
UseWAL | false | Uses an unlogged table by default. |
ExpiredItemsDeletionInterval | 00:30:00 | How often expired Postgres cache rows are cleaned up. |
DefaultSlidingExpiration | 00:20:00 | L2 sliding expiration. |
DefaultEntryExpiration | 00:10:00 | HybridCache absolute expiration. |
LocalCacheExpiration | 00:02:00 | L1 in-memory expiration. |
MaximumPayloadBytes | 1048576 | Maximum serialized payload size. |
MaximumKeyLength | 1024 | Maximum cache key length. |
The cache connection string comes from ConnectionStrings__zeeq-cache. If that is not set, Zeeq falls back to ConnectionStrings__zeeq-db. This lets development use the main database while leaving a clear path to a separate cache database later.
Common scenarios
Use the default Postgres cache
This is the normal setup and should be enough for local development and the first production deployment.
{
"AppSettings": {
"Cache": {
// Registers Microsoft.Extensions.Caching.Postgres as the L2 provider.
"Provider": "Postgres"
}
}
}
Keep local cache entries a bit longer
This can be useful in local development when you want fewer repeated reads while testing flows.
{
"AppSettings": {
"Cache": {
"Provider": "Postgres",
// L2 entries are shared across processes, so they can usually live longer.
"DefaultEntryExpiration": "00:20:00",
// L1 entries are process-local, so keep them shorter to reduce stale reads.
"LocalCacheExpiration": "00:05:00"
}
}
}
Use a dedicated cache database
Set the dedicated cache connection string when the cache should point at a different Postgres database or instance.
ConnectionStrings__zeeq-cache=Host=localhost;Port=5432;Database=zeeq_cache;Username=zeeq;Password=...
Turn on WAL for the cache table
Most environments should not need this. Use it only when an environment has an operational reason to make cache rows WAL logged.
{
"AppSettings": {
"Cache": {
"Provider": "Postgres",
// This trades cache-write speed for WAL durability.
"UseWAL": true
}
}
}
Future Redis profile
The settings model already has a Redis provider value, but Redis is not wired as the active runtime package today. When Zeeq moves to Redis, application code should continue to use HybridCache; only provider registration and configuration should change.
{
"AppSettings": {
"Cache": {
// This profile is reserved for the future Redis provider registration.
"Provider": "Redis",
"DefaultEntryExpiration": "00:10:00",
"LocalCacheExpiration": "00:02:00"
}
}
}
Usage
Inject HybridCache and use stable keys.
public sealed class OrganizationReader(
HybridCache cache, // 👈 Injected reference to HybridCache
OrganizationStore store
)
{
public async ValueTask<OrganizationSummary> GetSummaryAsync(
string organizationId,
CancellationToken cancellationToken
)
{
return await cache.GetOrCreateAsync(
// Keys should name exactly what value is being cached.
$"organization:summary:{organizationId}",
// This runs only on a miss; HybridCache coalesces concurrent misses for the same key.
async ct => await store.GetSummaryAsync(organizationId, ct),
cancellationToken: cancellationToken
);
}
}
Good cache keys are specific, stable, and scoped to the data being cached. Cache data that is expensive to compute or frequently reused. Do not cache data that must be immediately consistent unless the write path also invalidates or bypasses the cache.
Operational notes
UseZeeqCacheAsync performs a small startup cache write so the Postgres cache table exists before the app starts serving traffic.
Cache data is disposable. It is safe to clear the cache table when troubleshooting stale values. Shorter LocalCacheExpiration values reduce stale data inside one process, while longer L2 expiration values reduce repeated database work across processes.