Viorel Buliga
About me
← All articles
.NETASP.NET CoreWeb APIRedisCaching

How to Make Your ASP.NET Core Web API Stateless: Redis, HybridCache and Shared Storage

July 12, 2026·7 min read
Quick answer: To make an ASP.NET Core Web API stateless, move the state that must be shared out of the web process and into infrastructure built for it. In practice: a distributed cache (Redis or SQL Server) instead of IMemoryCache, HybridCache as the modern front end for it, a Redis-backed output cache, and shared object storage instead of local disk. Careful: AddDistributedMemoryCache sounds distributed but is not - the docs say plainly it "isn't an actual distributed cache".

In the first part of this series I mapped out the state that quietly lives inside a Web API process - the built-in rate limiter, the output cache, IMemoryCache, singletons, local files. Knowing it is there is half the job. This part is the other half: where that state should live instead, what it costs you, and what you can safely leave alone.

ASP.NET Core Web API instances sharing state through a distributed cache

What does a stateless Web API actually mean?

It does not mean the API has no state. It means the web process does not privately own the state that has to survive a restart, be visible to every instance, or coordinate work.

The practical test is brutal and simple: can you kill any instance at any moment without losing anything that matters? If yes, the process is disposable, and you can scale it, redeploy it, and replace it freely. If no, you have found state that needs to move.

How do you share cached data across instances?

A distributed cache is, in the words of the docs, "a cache shared by multiple app servers", maintained as an external service. That gives you two properties that an in-process cache can never have. Cached data:

  • is coherent (consistent) across requests to multiple servers,
  • and survives server restarts and app deployments.

You talk to it through IDistributedCache, regardless of the backing store. Redis is the common choice; SQL Server and Postgres are supported too.

builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("Redis");
});
The trap in the name. AddDistributedMemoryCache is an IDistributedCache implementation, so it compiles and injects like the real thing - but the docs state it "isn't an actual distributed cache. The app instance stores the cached items on the server where the app is running." It is meant for development and testing. Shipping it to a scaled-out production API gives you per-instance caching with a name that says otherwise.

Why is HybridCache the better default?

Raw IDistributedCache works, but it is a byte[] API, you serialize by hand, and every read is a network call - even for data you just fetched a millisecond ago. HybridCache (ASP.NET Core 9 and later) sits on top and solves both problems.

It is a two-level cache. L1 is in-process memory; L2 is the distributed cache. GetOrCreateAsync checks L1, then L2, and only calls your factory if both miss - then populates both.

builder.Services.AddHybridCache();

// If an IDistributedCache (for example Redis) is registered,
// HybridCache automatically uses it as the L2 cache.

public class OrderService(HybridCache cache, IOrderRepository repo)
{
    public Task<Order> GetAsync(int id, CancellationToken ct = default) =>
        cache.GetOrCreateAsync(
            $"order/{id}",
            async token => await repo.LoadAsync(id, token),
            cancellationToken: ct);
}

The feature that earns its place, though, is stampede protection. When a popular key expires under load, a plain cache lets every concurrent request miss and hammer the database at once. HybridCache does not: it "ensures that only one concurrent caller for a given key calls the factory method, and all other callers wait for the result of that call". You get that even with no distributed cache configured at all.

The subtle one, straight from the docs. When you invalidate an entry by key or tag, it is removed on the current server and in the distributed store - but "the in-memory cache in other servers isn't affected". Their L1 copies live on until they expire. If a value must go stale everywhere at once, keep LocalCacheExpiration short, or do not cache it in L1 at all.

Where does the rest of the state go?

Caching is the biggest piece, but the other per-process state from part one has somewhere to go too.

Per-process stateMove it toHow
In-memory cacheDistributed cacheHybridCache over a Redis or SQL Server IDistributedCache.
Output cacheRedis-backed output cacheAddStackExchangeRedisOutputCache instead of the default in-process store.
Rate limiterA shared store, or the gatewayThere is no built-in distributed limiter. Use a Redis-based limiter, or enforce global limits at the API gateway or ingress. (Only needed if the limit is meant to be global - see part one.)
Local filesObject storageBlob storage or an equivalent, so any instance can read what another wrote.
Background job stateDatabase or queueWorkers must claim work atomically. That is the subject of part three.
// output cache, shared across instances
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
    options.Configuration =
        builder.Configuration.GetConnectionString("Redis");
});

What does moving state out cost you?

This is not free, and pretending otherwise leads to bad designs. Be honest about the bill:

  • A network hop. A memory read becomes a call to another machine. That is microseconds turning into milliseconds. HybridCache's L1 is what softens this.
  • Serialization. Anything going to L2 must be serialized - System.Text.Json by default. Your cached type needs to survive a round trip.
  • Size limits. HybridCache defaults to a 1 MB maximum payload. Larger entries are logged and simply not cached, which is easy to miss until you wonder why your cache hit rate is zero.
  • A new dependency. Redis is now something that can be slow, be down, or fail over. Your API's behaviour when the cache is unavailable is a design decision, not an accident.

What should you move, and what should you leave alone?

Do not externalize everything reflexively. Apply the test from the top of the article.

  • Leave it local when the instance can rebuild it cheaply and nobody is harmed by two instances disagreeing for a few seconds. A read-through cache over a database is the classic case - the database is still the source of truth.
  • Move it out when the state must be coherent across instances, must survive a restart, or must coordinate work: a global rate limit, an output cache you rely on for consistency, records like receipts, and any job that must run exactly once.

Get that split right and the process becomes disposable. You can add an instance, remove one, or deploy over one, and the system carries on - because nothing important was living inside it.

Frequently asked questions

Does AddDistributedMemoryCache actually distribute anything?

No. The docs are explicit: the distributed memory cache "isn't an actual distributed cache. The app instance stores the cached items on the server where the app is running." It implements IDistributedCache so you can swap in a real provider later, and it is useful for development and testing, but in a scaled-out deployment it gives you a per-instance cache.

Should I use HybridCache or IDistributedCache directly?

Prefer HybridCache for new code. It gives you a two-level cache (in-process L1 plus your distributed L2), handles serialization, and adds stampede protection. It uses whatever IDistributedCache you registered as its secondary store, so you still choose Redis, SQL Server, or Postgres underneath.

What is a cache stampede, and how does HybridCache prevent it?

A stampede happens when a popular cache entry expires and many concurrent requests all miss at once, so they all hit the database to rebuild the same value. HybridCache ensures that only one concurrent caller for a given key calls the factory method, and all other callers wait for that result. You get this even without a distributed cache configured.

If I invalidate a cache entry, do all instances see it immediately?

Not entirely. Removing by key or tag invalidates the entry on the current server and in the distributed store, but the docs note that the in-memory cache on other servers is not affected. Their L1 copies survive until they expire. If a value must go stale everywhere at once, use a short LocalCacheExpiration or avoid caching it locally.

Do I need Redis just to run more than one instance?

Not necessarily. It depends on which state must actually be shared. If your API is token-based and its caches are read-through over a database, several instances can run happily with only local caches. You need a shared store when correctness depends on instances agreeing - global rate limits, shared output cache, coordinated background work.

Is there a size limit on what I can cache?

Yes. HybridCache has a MaximumPayloadBytes option that defaults to 1 MB, and a MaximumKeyLength that defaults to 1024 characters. Attempts to store values above the limit are logged and the value is not cached - so a silently empty cache is often an oversized payload.

Official resources

  • HybridCache library in ASP.NET Core - Microsoft Learn
  • Distributed caching in ASP.NET Core - Microsoft Learn
  • Output caching middleware in ASP.NET Core - Microsoft Learn
  • Rate limiting middleware in ASP.NET Core - Microsoft Learn
Share on LinkedIn