You need distributed caching. Your team reaches for Redis, adds it to the docker-compose file, sets up a managed instance in Azure, configures monitoring, writes failover logic, and suddenly a "simple" caching layer has its own operational budget. Meanwhile, your PostgreSQL database sits there running your application data with spare capacity, wondering why nobody asked it to help.
Microsoft has released Microsoft.Extensions.Caching.Postgres, an official IDistributedCache implementation backed by PostgreSQL. If you already run Postgres for your application data, you can now use that same instance as a distributed cache — no additional infrastructure required. Pair it with HybridCache and you get a tiered caching architecture where in-memory lookups complete in under a millisecond and Postgres handles the distributed backplane.
This isn't a toy. The package uses PostgreSQL's UNLOGGED tables to bypass write-ahead logging for cache data, closing much of the performance gap with Redis for typical workloads.
Why Postgres for caching
The conventional wisdom is straightforward: Redis for caching, Postgres for persistence. That advice holds when you're operating at a scale where microsecond-level cache latency matters and you've already got the team to manage both systems. For everyone else, it's worth questioning whether the operational overhead of a second data store pays for itself.
PostgreSQL brings several things to the table that make it a credible cache backing store:
- You already have it. If Postgres is your primary database, adding a cache table to it requires zero additional infrastructure, monitoring, or failover configuration.
- UNLOGGED tables. PostgreSQL can create tables that skip the write-ahead log entirely. Since cache data is ephemeral by definition, trading crash consistency for write performance is a sensible trade-off.
- Mature connection pooling. Tools like PgBouncer and Npgsql's built-in connection pooling handle high-throughput scenarios well.
- Transactional guarantees where you need them. If you ever need to atomically update cache entries alongside application data, they live in the same database.
The trade-off is real: Redis will always be faster for pure cache operations, particularly at high concurrency with very small payloads. Microsoft's own benchmarks show Postgres comes close to Redis for larger payloads and more intensive operations, but Redis maintains an edge in raw latency. The question is whether that edge justifies the infrastructure cost.
Setting up the Postgres cache
Install the package:
dotnet add package Microsoft.Extensions.Caching.Postgres
Register the cache in your service configuration:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDistributedPostgresCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
options.SchemaName = "public";
options.TableName = "cache";
options.CreateIfNotExists = true;
});
var app = builder.Build();
The CreateIfNotExists option tells the provider to create the cache table automatically on first use. In production, you might prefer to create the table through your normal migration process and leave this set to false.
Your connection string goes in appsettings.json as usual:
{
"ConnectionStrings": {
"PostgresCache": "Host=localhost;Port=5432;Database=myapp;Username=app_user;Password=secret"
}
}
From this point, anything that depends on IDistributedCache will use Postgres. If you're already using IDistributedCache with SQL Server or Redis, switching to Postgres is a one-line registration change.
Configuration options
The AddDistributedPostgresCache method exposes several options worth understanding:
builder.Services.AddDistributedPostgresCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
options.SchemaName = "public";
options.TableName = "cache";
options.CreateIfNotExists = true;
options.UseWAL = false;
options.ExpiredItemsDeletionInterval = TimeSpan.FromMinutes(30);
options.DefaultSlidingExpiration = TimeSpan.FromMinutes(20);
});
UseWAL is the most important performance knob. When set to false (the default), the cache table is created as an UNLOGGED table. PostgreSQL's write-ahead log is the mechanism that ensures data survives a crash — every write goes to the WAL before it's considered committed. For cache data, this durability guarantee is unnecessary overhead. UNLOGGED tables skip the WAL entirely, which significantly reduces write latency and I/O pressure.
The trade-off: if PostgreSQL crashes (not your application — the database engine itself), UNLOGGED tables are truncated on recovery. For cache data, that's fine. The cache was going to expire anyway, and your application should already handle cache misses gracefully.
// WARNING
If you set UseWAL = true, you're paying the full write-ahead logging cost for every cache write. Only do this if you have a specific reason, such as needing cache data to survive a Postgres restart during maintenance windows.
ExpiredItemsDeletionInterval controls how often a background process scans for and removes expired entries. The default of 30 minutes is reasonable for most workloads. Expired entries aren't returned to callers regardless of this setting — this only affects when the physical rows are deleted from the table.
DefaultSlidingExpiration sets the default sliding window for entries that don't specify their own expiration. Each time an entry is accessed, its expiration is extended by this duration.
Tiered caching with HybridCache
Using IDistributedCache directly works, but you're leaving performance on the table. The cache-aside pattern — check cache, miss, fetch from source, store in cache — requires manual orchestration and doesn't protect against cache stampedes.
HybridCache solves both problems. It combines a fast in-memory layer with your distributed cache, handles serialisation automatically, and ensures that only one concurrent caller executes the factory method for a given key.
Install the package:
dotnet add package Microsoft.Extensions.Caching.Hybrid
Register both caches:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDistributedPostgresCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
options.SchemaName = "public";
options.TableName = "cache";
options.CreateIfNotExists = true;
});
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
};
});
var app = builder.Build();
Now use HybridCache in your services:
public class ProductService(HybridCache cache, AppDbContext db)
{
public async Task<ProductDto> GetProductAsync(int productId, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
$"product-{productId}",
async token => await db.Products
.Where(p => p.Id == productId)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstAsync(token),
cancellationToken: ct);
}
}
Here's what happens on each call to GetOrCreateAsync:
- Check in-memory cache. If the entry exists and hasn't expired locally, return it immediately. This takes sub-millisecond time.
- Check Postgres. If the in-memory cache missed but Postgres has the entry, deserialise it, populate the in-memory cache, and return it. This typically takes 30-50ms depending on network latency.
- Execute the factory. If both caches missed, run the delegate, store the result in both caches, and return it. Only one concurrent caller runs the factory — all others wait for the result.
The LocalCacheExpiration and Expiration properties control the two tiers independently. A common pattern is to set a short local expiration (1-5 minutes) and a longer distributed expiration (10-60 minutes). This means each server refreshes its local copy frequently from Postgres, while Postgres itself only falls back to the database on a less frequent cadence.
Tag-based invalidation
HybridCache supports tag-based invalidation, which is particularly useful when cached data has relationships:
public async Task<ProductDto> GetProductAsync(int productId, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
$"product-{productId}",
async token => await db.Products
.Where(p => p.Id == productId)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.FirstAsync(token),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
},
tags: [$"product-{productId}", "products"],
cancellationToken: ct);
}
public async Task InvalidateProductAsync(int productId, CancellationToken ct = default)
{
await cache.RemoveByTagAsync($"product-{productId}", ct);
}
public async Task InvalidateAllProductsAsync(CancellationToken ct = default)
{
await cache.RemoveByTagAsync("products", ct);
}
Tag-based invalidation in HybridCache is a logical operation — it marks entries as stale so subsequent reads treat them as cache misses, rather than physically deleting rows from Postgres immediately. Entries are cleaned up when they naturally expire.
IBufferDistributedCache and zero-allocation reads
The Microsoft.Extensions.Caching.Postgres package implements IBufferDistributedCache, an extended interface that allows HybridCache to read cache data without allocating intermediate byte[] arrays. Instead, data flows through IBufferWriter<byte> and ReadOnlySequence<byte>, reducing GC pressure under high-throughput scenarios.
You don't need to do anything to opt in — if the backing IDistributedCache implementation supports IBufferDistributedCache, HybridCache uses it automatically. This is one advantage the official Microsoft cache providers (Postgres, Redis, SQL Server) have over third-party implementations that only implement the base interface.
When Postgres is the wrong choice
Postgres caching isn't universally better. Stick with Redis when:
- Sub-millisecond distributed latency matters. Redis serves from memory with no disk I/O. Postgres, even with UNLOGGED tables, involves query parsing, planning, and row retrieval.
- You're caching at extreme throughput. Thousands of cache operations per second per server will put measurable load on your Postgres instance. If that instance is already under pressure from application queries, the cache traffic could degrade both workloads.
- You need pub/sub for cache invalidation across nodes. Redis has built-in pub/sub. With Postgres, cross-node invalidation requires a separate mechanism.
- Your deployment already includes Redis. If Redis is already in your stack for other reasons (session state, rate limiting, queuing), there's no point avoiding it for caching.
The sweet spot for Postgres caching is applications that need distributed caching for correctness (multi-instance deployments) but don't need the raw throughput of a dedicated in-memory store. Paired with HybridCache, the in-memory tier handles the hot path, and Postgres only sees traffic on local cache misses.
Common pitfalls
Using the same connection pool for cache and application queries. Under load, cache operations can starve your application queries (or vice versa). Consider using a separate connection string with its own pool size for the cache, or at minimum, ensure your pool size accounts for cache traffic.
// Bad — sharing the application connection string
options.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");
// Good — dedicated cache connection with appropriate pool size
options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
Forgetting that UNLOGGED tables don't replicate. If you're using PostgreSQL streaming replication, UNLOGGED tables are not replicated to standby servers. This is fine for caching — each node should maintain its own cache state anyway — but it's worth knowing if you expect to read cache data from replicas.
Setting expiration too aggressively on the cleanup interval. If ExpiredItemsDeletionInterval is very short (say, a few seconds), the background cleanup process will run frequently and generate unnecessary load. The default 30 minutes is sensible. Expired entries are invisible to callers regardless.
Not handling cache misses in your application logic. This seems obvious, but when you move from Redis (which almost never loses data unexpectedly) to Postgres with UNLOGGED tables (which truncates on crash), your cache-miss path needs to be robust. With HybridCache.GetOrCreateAsync, this is handled naturally — the factory delegate is your miss path. If you're using IDistributedCache directly, make sure every GetAsync call has a fallback.
Ignoring serialisation costs. HybridCache uses System.Text.Json by default. For large or complex objects, serialisation can dominate the cache operation time. Consider using a source-generated JsonSerializerContext for your cached types to reduce overhead, or explore protobuf serialisation for high-throughput scenarios.
Summary
Microsoft.Extensions.Caching.Postgresis a first-partyIDistributedCacheimplementation that uses PostgreSQL as the backing store.- UNLOGGED tables bypass the write-ahead log, trading crash durability for significantly better write performance — a sensible trade-off for cache data.
- Pair it with
HybridCacheto get tiered caching: sub-millisecond in-memory reads with Postgres as the distributed backplane. - The package implements
IBufferDistributedCachefor zero-allocation reads when used withHybridCache. - Choose this over Redis when you want to reduce infrastructure complexity and your cache traffic doesn't warrant a dedicated in-memory store.
- Keep cache and application query traffic on separate connection pools to avoid contention.