Multi-Level Caching in .NET With FusionCache

Multi-Level Caching in .NET With FusionCache

8 min read··Updated ·

cachingfusioncacheperformanceredis

Multi-level caching combines a fast in-memory L1 cache in each application instance with a shared distributed L2 cache like Redis: reads hit L1 first, fall back to L2, and only then hit the database. FusionCache packages that model for .NET, with the L1, the L2, a backplane for cross-instance invalidation, stampede protection, and fail-safe built in.

Every caching setup I have inherited followed the same arc.

Someone added IMemoryCache because reads were slow. Then the app scaled to two instances, values went stale on one of them, and someone bolted Redis on. And then the bugs got interesting.

The problem is not that hand-rolling IMemoryCache plus Redis is hard to write. It is that the naive version silently skips the three hard parts, and you only find out during an incident.

The Progression Everyone Follows

Step one: no cache. The database handles everything until it does not.

Step two: in-memory caching. Blazing fast reads (we are talking nanoseconds), but the cache is per-instance. With one instance, this is genuinely great.

Step three: you scale out, and each instance has its own private view of "the truth". So you add Redis as a shared cache. Now everyone reads the same values, but every read pays a network round trip, so you keep the memory cache in front of it.

Congratulations, you have built a two-level cache. And you now own three problems you did not budget for.

The Three Hard Parts

1. Cross-Instance Invalidation

Instance A updates a product and refreshes its own memory cache and Redis. Instance B still has the old product in its memory cache and will happily serve it until the entry expires.

Users see different data depending on which instance the load balancer picks. Support tickets say "it works when I refresh twice".

The fix is a backplane: a pub/sub channel that broadcasts "key X changed" to every instance so they evict their local copies. This is the piece hand-rolled caches forget, and I wrote about the underlying problem in solving distributed cache invalidation.

2. Cache Stampede

A popular key expires at peak traffic. Fifty concurrent requests all miss the cache at the same instant, and all fifty execute the same expensive database query at once. The database, which the cache existed to protect, gets hammered precisely when it is busiest.

The fix is request coalescing: one caller runs the factory, the other forty-nine wait for its result. Getting this right yourself means a lock striping scheme and careful async coordination, and most hand-rolled implementations get it subtly wrong. I dug into the failure mode in cache stampede prevention.

3. The Source Is Down

The database goes away for thirty seconds during a failover. Every cache miss now throws, and your "optimization layer" turns into an outage amplifier.

The question nobody asked at design time: when the factory fails, do you want a 500, or do you want to serve the value you had five minutes ago? For most read paths, slightly stale beats down. That behavior is called fail-safe, and it has to be designed in.

What Is FusionCache?

FusionCache is a mature open-source caching library that packages exactly this model:

  • L1: an in-memory cache, always on.
  • L2: an optional distributed cache (Redis via IDistributedCache).
  • Backplane: eviction notifications across instances, typically over Redis pub/sub.
  • Stampede protection and fail-safe built in.
Two app instances each holding their own L1 in-memory cache, both reading through a shared Redis L2 cache to the database, with a Redis pub/sub backplane broadcasting evictions between the two L1 caches

It sits in the same space as Microsoft's HybridCache, but it is more feature-complete today (fail-safe, soft timeouts, adaptive caching), and it can even act as a HybridCache implementation if you want the abstraction.

Install the packages:

dotnet add package ZiggyCreatures.FusionCache
dotnet add package ZiggyCreatures.FusionCache.Serialization.SystemTextJson
dotnet add package ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

The Whole Setup

Here is the complete registration: L1 always on, and the Redis L2 plus backplane added only when a connection string is configured.

public static IServiceCollection AddAppCache(
    this IServiceCollection services, IConfiguration configuration)
{
    var options = ResolveOptions(configuration);

    var fusion = services.AddFusionCache()
        .WithDefaultEntryOptions(new FusionCacheEntryOptions
        {
            Duration = options.DefaultDuration,
            IsFailSafeEnabled = true,                       // serve stale if the factory errors
            FailSafeMaxDuration = options.FailSafeMaxDuration
        });

    // Only add the L2 when Redis is configured. With none, the cache is L1-only:
    // fully functional, just per-instance.
    if (!string.IsNullOrWhiteSpace(options.RedisConnectionString))
    {
        services.AddStackExchangeRedisCache(redis =>
        {
            redis.Configuration = options.RedisConnectionString;
            redis.InstanceName = options.InstanceName + ":";
        });

        fusion
            .WithSerializer(new FusionCacheSystemTextJsonSerializer())
            .WithRegisteredDistributedCache()               // the Redis L2
            .WithBackplane(new RedisBackplane(new RedisBackplaneOptions
            {
                Configuration = options.RedisConnectionString // evict L1 across instances
            }));
    }

    return services;
}

And the read side. This one call gives you L1 lookup, L2 fallback, stampede-protected factory execution, and fail-safe:

var product = await cache.GetOrSetAsync(
    $"product:{id}",
    async ct => await db.Products.FindAsync([id], ct),
    token: ct);

The call sites stay this simple no matter which levels are configured. That is the point.

A single GetOrSetAsync read checking L1 first, falling back to L2 on a miss, and only running the coalesced factory against the database when both miss, then populating L1 and L2 before returning

What Each Piece Buys You

The Backplane Fixes the Two-Instance Bug

Walk through the failure without it. Instance A runs SetAsync("product:42", updated). A's memory cache is fresh, Redis is fresh, and B's memory cache still holds the old value for however long its L1 duration is.

With the backplane, A's write publishes a notification, B receives it and evicts product:42 from its L1, and B's next read pulls the fresh value from Redis. The stale window shrinks from "the full cache duration" to "one pub/sub hop", which is milliseconds.

You write no code for this. Every Set, Remove, and Expire participates automatically.

Stampede Protection Is Automatic

When 50 concurrent callers request the same expired key, FusionCache lets exactly one of them per instance execute the factory. The other 49 wait on the in-flight operation and get the same result.

Your database sees one query instead of fifty. There is no configuration for this because it is not optional behavior, it is just how GetOrSetAsync works.

Fail-Safe Is a Product Decision in Config

With IsFailSafeEnabled = true, FusionCache keeps logically expired entries around (up to FailSafeMaxDuration) instead of discarding them. When a factory throws (database down, timeout, transient failure), it returns the stale value instead of propagating the exception.

Read that again: it is a product decision, encoded in configuration. "For product listings, serving data up to one hour stale is better than an error page." Different keys can make different calls via per-entry options.

You can pair it with soft timeouts so a slow database does not even get the chance to make users wait:

var product = await cache.GetOrSetAsync(
    $"product:{id}",
    async ct => await db.Products.FindAsync([id], ct),
    opt => opt.SetFailSafe(true).SetFactoryTimeouts(TimeSpan.FromMilliseconds(200)),
    token: ct);

If a stale value exists and the factory takes longer than 200 ms, the caller gets the stale value now and the factory completes in the background and refreshes the cache.

Writes, Removes, and the Eager Refresh Trick

Reads are half the story. The write side matters just as much for consistency:

// After updating the product in the database:
await cache.SetAsync($"product:{id}", updated, token: ct);
// Or, if you'd rather let the next read repopulate:
await cache.RemoveAsync($"product:{id}", token: ct);

Both propagate through every level: L1 on this instance, L2 in Redis, and (via the backplane) an eviction notification to every other instance's L1. One call, all levels consistent. Compare that with the hand-rolled version, where each write site has to remember three explicit steps, and one forgotten backplane publish reintroduces the stale-instance bug.

One more option worth knowing: eager refresh. Set EagerRefreshThreshold = 0.9f and when a request arrives after 90% of an entry's lifetime has passed, FusionCache serves the still-valid cached value instantly and refreshes it in the background. Hot keys effectively never expire in front of a user, which removes the latency spike that otherwise hits whoever draws the short straw at expiration time.

And the standing rule that no library can decide for you: cache things that are read often and can tolerate brief staleness, and leave the rest alone. I go deeper on that decision in when to cache.

Graceful Degradation Comes Free

Look back at the registration code. If no Redis connection string is configured, the app still has a fully functional L1 cache. Slower cross-instance consistency, yes, but working.

That conditional is not just developer convenience for local dev (though it is great for that too). It is an operational property: the caching layer degrades instead of becoming a hard dependency. If Redis has an outage, FusionCache can keep serving from L1 and skip the distributed layer, because caching is an optimization, never the source of truth.

This "work with what infrastructure is present" shape applies well beyond caching, and I wrote it up as a standalone pattern in designing for graceful degradation.

Summary

Hand-rolling IMemoryCache plus Redis is a fine learning exercise and a risky production strategy, because the failure modes are invisible until you have multiple instances under load.

  • The backplane is the piece everyone forgets. Without it, every update leaves stale L1 copies on other instances.
  • Stampede protection is the second forgotten piece. Expiring hot keys must coalesce, not fan out to the database.
  • Fail-safe turns "database blipped" from an outage into bounded staleness. Decide per key what staleness you can afford.
  • FusionCache gives you all three, plus an L1-only degraded mode that keeps working when Redis is absent or down.

If you already have the hand-rolled version, count the incidents it has caused, then count the lines of code GetOrSetAsync replaces. The migration usually pays for itself the first busy day.

Frequently Asked Questions

What is multi-level caching in .NET?

Multi-level caching combines a fast in-memory L1 cache in each application instance with a shared distributed L2 cache like Redis. Reads hit L1 first for speed, fall back to L2 to stay consistent across instances, and only then hit the database.

What is a cache backplane and why do I need one?

A backplane is a notification channel, typically Redis pub/sub, that broadcasts cache changes to every application instance. Without it, updating a value on one instance leaves stale copies in the in-memory caches of all other instances until they expire.

How does FusionCache prevent cache stampede?

FusionCache coalesces concurrent requests for the same key. When an entry expires under load, only one caller per instance executes the factory against the database while the rest wait for that result. This is built in and requires no configuration.

What happens in FusionCache when the database is down?

With fail-safe enabled, FusionCache keeps expired entries around for a configurable window and serves the stale value instead of throwing when the factory fails. You trade a bounded amount of staleness for staying up during an outage.

Do I need Redis to use FusionCache?

No. Without a distributed cache configured, FusionCache runs as a fully functional L1 in-memory cache. You can add the Redis L2 and backplane later without changing any call sites.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.