High Performance Distributed Caching in .NET with Postgres and HybridCache

Caching is one of those topics that sounds simple until you have to use it in a real system.
At first it looks easy. Put the thing in memory. Read it back later. Save a database call. Job done.
Then reality turns up.
You deploy more than one instance of the application. Each instance has its own memory. One node has fresh data. Another node does not. A restart wipes the cache. A cold deployment causes every instance to hammer the same database table at the same time. Someone adds a cache entry with the wrong expiry and now users are looking at stale data. Then the production logs start telling a story you didnt want to read.
Thats why the recent work around Microsoft.Extensions.Caching.Postgres and Microsoft.Extensions.Caching.Hybrid is interesting. It gives .NET applications a cleaner way to combine fast local memory with a shared distributed cache backed by Postgres.
This is not about making every app use Postgres as a cache. Redis still makes sense in plenty of systems. If you already have Redis, need very low latency across nodes, and your team knows how to operate it, keep using it.
But if your system already runs on Postgres, especially on Azure Database for PostgreSQL, this option is worth paying attention to. It can reduce infrastructure sprawl while still giving you a proper distributed cache story.
I was reminded of this recently in a much less technical setting. My daughter has started asking for the same thing again and again, usually with the urgency of a production incident. If I answer slowly once, that is apparently unacceptable. If I answer slowly every time, I have designed the wrong system. The same applies to software. If your app keeps asking the same expensive question and getting the same answer, you should probably stop making the full journey every time.
The problem caching is trying to solve
Most applications have data that is expensive to fetch but safe to reuse for a short period.
That might be lookup data, feature configuration, exchange rates, product metadata, tenant settings, permissions, pricing rules, or an expensive response from another internal service.
Without caching, every request goes back to the source.
That design is simple, but it does not scale well when the source call is slow, expensive, rate limited, or under load.
The first obvious improvement is in-memory caching.
That works well for a single process. The problem starts when the application scales out.
Each instance has its own private cache. If one instance warms its cache, the others do not benefit. If an instance restarts, its cache is gone. If you deploy five instances at the same time, you can easily create a thundering herd against the source.
This is where a distributed cache helps.
The two-level cache model
HybridCache gives you a two-level model.
The first level is local memory. This is the fastest path because the data is already inside the running process.
The second level is a distributed cache. In this case, that distributed cache is backed by Postgres.
The value of this model is that most hot reads stay local, while multiple app instances still share a common cache behind the scenes.
The application does not need to manually check memory, then check Postgres, then call the source, then write into both caches. HybridCache gives you a single API for the common case.
That is the part I like. The API pushes you towards the right shape.
Installing the packages
For a basic demo, you need the hosting package, HybridCache, and the Postgres distributed cache provider.
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Caching.Hybrid
dotnet add package Microsoft.Extensions.Caching.Postgres
You can wire this into a console app, worker service, API, or background process. The important part is the service registration.
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
builder.Services.AddDistributedPostgresCache(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("PostgresCache");
options.SchemaName = builder.Configuration.GetValue<string>("PostgresCache:SchemaName", "public");
options.TableName = builder.Configuration.GetValue<string>("PostgresCache:TableName", "cache");
options.CreateIfNotExists = builder.Configuration.GetValue<bool>("PostgresCache:CreateIfNotExists", true);
options.UseWAL = builder.Configuration.GetValue<bool>("PostgresCache:UseWAL", false);
});
builder.Services.AddHybridCache();
builder.Services.AddHostedService<CacheDemoWorker>();
await builder.Build().RunAsync();
The config can live in appsettings.json, with the connection string supplied through user secrets locally and environment variables or Key Vault in production.
{
"PostgresCache": {
"SchemaName": "public",
"TableName": "cache",
"CreateIfNotExists": true,
"UseWAL": false,
"ExpiredItemsDeletionInterval": "00:30:00",
"DefaultSlidingExpiration": "00:20:00"
},
"ConnectionStrings": {
"PostgresCache": ""
}
}
Remember dont put the real connection string in source control. Locally, use user secrets.
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:PostgresCache" "Host=your-server.postgres.database.azure.com;Port=5432;Username=your-user;Password=your-password;Database=your-database;Pooling=true;"
In Azure, use app settings or Key Vault references. The application should not care where the value came from.
Using HybridCache in a service
The central API is GetOrCreateAsync.
You provide a cache key, a factory function, expiry options, and a cancellation token. HybridCache handles the lookup path.
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
internal sealed class CacheDemoWorker(
HybridCache cache,
ILogger<CacheDemoWorker> logger) : BackgroundService
{
private static readonly HybridCacheEntryOptions CacheOptions = new()
{
LocalCacheExpiration = TimeSpan.FromSeconds(10),
Expiration = TimeSpan.FromMinutes(2)
};
protected override async Task ExecuteAsync(CancellationToken stopToken)
{
while (!stopToken.IsCancellationRequested)
{
var timer = System.Diagnostics.Stopwatch.StartNew();
var forecast = await cache.GetOrCreateAsync(
key: "weather:forecast:next-day",
factory: async cancel =>
{
logger.LogInformation("Cache miss. Fetching forecast from source.");
return await GetForecastFromSource(cancel);
},
options: CacheOptions,
cancellationToken: stopToken);
timer.Stop();
logger.LogInformation(
"Returned forecast {Summary} in {ElapsedMs} ms",
forecast.Summary,
timer.Elapsed.TotalMilliseconds);
await Task.Delay(TimeSpan.FromSeconds(1), stopToken);
}
}
private static async Task<WeatherForecast> GetForecastFromSource(CancellationToken stopToken)
{
await Task.Delay(TimeSpan.FromSeconds(2), stopToken);
return new WeatherForecast(
DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)),
Random.Shared.Next(-5, 25),
"Mild");
}
}
internal sealed record WeatherForecast(
DateOnly Date,
int TemperatureC,
string Summary);
The first call is slow because the source is called.
The next call should be much faster because the value is in local memory.
When the local cache expires, the app can still fall back to the distributed Postgres cache.
When the distributed cache also expires, the source is called again.
That is the design in one diagram.
Why this is better than only using IMemoryCache
IMemoryCache is great when you have one application instance or when the cached data is genuinely local to a process. Its not enough when the application is scaled horizontally and cache misses across instances matter.
Imagine a permissions service that loads role rules for a tenant. If you run one instance, memory caching is probably fine. If you run ten instances, each one has to warm itself independently. A restart, deployment, or scale-out event can cause repeated source calls.
HybridCache gives you local speed without giving up the shared cache layer.
Each instance can still serve hot data from memory, but the distributed cache becomes the shared fallback.
That is a much better production shape.
Why use Postgres as the distributed cache?
The usual answer for distributed caching is Redis. That answer is still valid.
Postgres becomes interesting when you already operate it, already monitor it, already back it up, and already understand its failure modes. A dedicated cache server is another moving part. Another private endpoint. Another bill. Another thing to patch, secure, scale, and explain during an incident.
Using Postgres as the distributed cache can be a good match when the cached data is useful but not so latency-sensitive that it demands Redis. Its especially appealing for line-of-business systems where the goal is not extreme throughput, but fewer repeated source calls, simpler infrastructure, and good enough distributed cache performance.
This is the trade-off.
Redis is usually the better pure cache.
Postgres may be the better system design when operational simplicity matters more than shaving off every possible millisecond.
The mistake would be treating Postgres as a universal Redis replacement. Its not. The better framing is this, if Postgres is already part of your platform, it can now do a credible job as a distributed cache for many .NET workloads.
Cache keys matter
The cache key is part of your contract.
This is not a small detail. Bad keys create bad caches.
var key = $"tenant:{tenantId}:pricing-rules:v1";
A good cache key should include the thing being cached, the scope of the data, and often a version.
Versioning is important because the shape of cached data changes over time. If you cache a PricingRulesResponse today and then add fields next month, using a :v2 suffix lets you move safely without trying to deserialise old data into a new shape.
For user-specific data, include the user or tenant boundary. For global data, do not accidentally include request-specific noise that destroys cache reuse.
This is where I see teams make subtle mistakes. The caching code looks fine, but the keys are either too broad or too specific.
Too broad means users can see the wrong data.
Too specific means the cache never gets hit.
Neither is good.
Expiration needs to match the data
The demo uses short expiry times so you can see the behaviour quickly. Production values should be based on the volatility of the data. Lookup data might survive for hours. Feature configuration might survive for seconds or minutes. User permissions might need careful invalidation. Exchange rates might align to a known refresh schedule.
The key question is simple, how wrong can this data be, and for how long?
private static readonly HybridCacheEntryOptions CacheOptions = new()
{
LocalCacheExpiration = TimeSpan.FromSeconds(30),
Expiration = TimeSpan.FromMinutes(5)
};
The local cache should usually be shorter than the distributed cache. That gives each instance a very fast path while still letting the distributed layer carry the value for longer.
timeline
title Example cache lifetime
0 seconds : Source called
: Value stored in memory
: Value stored in Postgres
0 to 30 seconds : Local memory hit
: Fastest path
30 seconds to 5 minutes : Local memory expired
: Postgres cache hit
: Local memory refreshed
After 5 minutes : Distributed cache expired
: Source called again
This is the part you should tune with production telemetry, not guesswork.
Stampede protection matters
One underrated part of HybridCache is stampede protection.
A cache stampede happens when many callers ask for the same missing key at the same time. Without protection, they all call the source together. That can turn a harmless cache miss into a production problem.
With stampede protection, concurrent callers for the same key can be combined so that one factory call populates the value and the others reuse the result.
That is important because the worst time to discover your cache strategy is weak is during a restart, deployment, or traffic spike.
This is the sort of feature that looks minor until it saves you during an incident.
Where this fits in a real .NET application
The Microsoft sample uses a console app, but the same idea fits naturally into APIs and worker services.
For example, an endpoint can depend on an application service, and the application service can hide the caching detail.
app.MapGet(
"/tenants/{tenantId:long}/pricing-rules",
async (
long tenantId,
PricingRulesService service,
CancellationToken stopToken) =>
{
var rules = await service.GetPricingRules(tenantId, stopToken);
return Results.Ok(rules);
});
The service owns the key, the expiry, and the source lookup.
using Microsoft.Extensions.Caching.Hybrid;
internal sealed class PricingRulesService(
HybridCache cache,
PricingRulesClient client)
{
private static readonly HybridCacheEntryOptions CacheOptions = new()
{
LocalCacheExpiration = TimeSpan.FromSeconds(30),
Expiration = TimeSpan.FromMinutes(10)
};
public async ValueTask<PricingRulesResponse> GetPricingRules(
long tenantId,
CancellationToken stopToken)
{
var key = $"tenant:{tenantId}:pricing-rules:v1";
return await cache.GetOrCreateAsync(
key,
async cancel => await client.GetPricingRules(tenantId, cancel),
options: CacheOptions,
cancellationToken: stopToken);
}
}
That is the shape I would normally want. Dont scatter cache keys across controllers. Dont let every endpoint invent its own expiry. Do not make caching a random implementation detail hidden inside unrelated code.
Put it close to the application operation that owns the data.
What about invalidation?
Expiration is the easiest invalidation strategy. It is also the bluntest.
For many systems, time-based expiry is enough. If the data can be stale for 30 seconds or five minutes, keep it simple.
For data that changes immediately and must be reflected immediately, you need an invalidation path.
That might mean removing a key when an admin changes configuration. It might mean publishing a domain event and letting a handler evict the affected cache entries. It might mean using versioned keys so new reads move onto a new cache entry without needing to delete the old one immediately.
The main thing is to decide this deliberately. If stale data is acceptable, use expiry. If stale data is dangerous, add invalidation. If the cached object shape changes, use versioned keys.
Observability is not optional
Caching without observability is guesswork. You should be able to answer basic questions.
What is the cache hit rate?
How often does the factory execute?
How long does the source call take?
How long does a distributed cache hit take?
Which keys are hot?
Which keys are never reused?
At minimum, log cache misses around expensive operations. In a serious production system, you want metrics.
var result = await cache.GetOrCreateAsync(
key,
async cancel =>
{
logger.LogInformation("Cache miss for {CacheKey}", key);
return await client.GetPricingRules(tenantId, cancel);
},
options: CacheOptions,
cancellationToken: stopToken);
Be careful with logging cache keys if they contain sensitive identifiers. Tenant IDs might be fine in your environment. User IDs, emails, policy numbers, or customer references might not be.
When I would use this
I would consider HybridCache with Postgres when the application already uses Postgres, the team wants fewer moving parts, the cached data does not require Redis-level latency, and the main problem is reducing repeated source calls across scaled-out .NET services.
I would be more cautious if the cache is extremely hot, the system needs very high write throughput to the cache, the cache is being used as a coordination mechanism, or low millisecond cross-node performance is critical.
That last point is important. A cache is not a message bus. It is not a lock manager. It is not a database replacement. It is a performance and resilience tool, and it should be treated as one.
The bigger point
The interesting thing here is not just Postgres. The interesting thing is the direction .NET caching is moving in.
For years, .NET developers had to choose between simple local memory and a separate distributed cache abstraction. HybridCache gives you a better default. You get a single API, local memory for speed, a distributed layer for scale-out scenarios, and protection against common cache stampede problems.
Postgres support makes that even more practical for teams already standardising on Postgres.
The architecture becomes easier to reason about.
Thats a good shape, simple enough to explain, useful enough for production. Flexible enough to swap the distributed cache later if your needs change.
Caching should not be something you bolt on randomly after the system gets slow.
It is a design decision.
The best caching code isnt flash. The keys are predictable. The expiry policy is intentional. The source call is wrapped in one place. The logs tell you when the cache misses. The application still behaves correctly when the cache is empty.
HybridCache with Postgres gives .NET developers another strong option for that kind of design.
Not always the fastest option. Not always the right option. But for a lot of real business applications, it may be the practical option.
Source:
https://devblogs.microsoft.com/dotnet/high-performance-distributed-caching-dotnet-postgres-azure/





