Skip to content

Caching ​

This document explains how our caching works with FusionCache without reading the codebase.

TL;DR

  • Two-tier cache: in-memory (Level-1) + Redis (Level-2).
  • Redis Backplane keeps nodes in sync (pub/sub invalidations).
  • JSON serialization for distributed payloads.
  • Time-based expirations + fail-safe + soft/hard timeouts prevent stampedes.
  • Best practice: also cache missing data (negative caching) briefly to protect the database.

1) Registration (what we wire up) ​

csharp
services.AddFusionCache()
    .WithSerializer(new FusionCacheNewtonsoftJsonSerializer())
    .WithDistributedCache(new RedisCache(
        new RedisCacheOptions { Configuration = "CONNECTION STRING" }
    ))
    .WithBackplane(new RedisBackplane(
        new RedisBackplaneOptions { Configuration = "CONNECTION STRING" }
    ));

Implications

  • Serializer: payloads stored in Redis are serialized with Newtonsoft.Json. Only the distributed tier is serialized; in-memory keeps raw objects.
  • Distributed cache: Redis backs Level-2 (L2). All instances share the same L2.
  • Backplane: a Redis pub/sub channel used for coherent invalidations across app instances. When one node updates/evicts, others are notified.

2) Architecture at a Glance ​

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
Request ───────▢ β”‚ FusionCache (per app node)  β”‚
                 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
                 β”‚ L1:     β”‚ In-Memory Cache   β”‚  (fastest, per-process)
                 β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
                 β”‚ L2:     β”‚ Redis Cache       β”‚  (shared, serialized)
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β–²          β–²
                            β”‚          β”‚
                     Redis Backplane  (invalidations/broadcast)
  • L1 hit β†’ return immediately.
  • L1 miss β†’ check L2 (Redis).
  • L2 hit β†’ deserialize, populate L1, return.
  • L2 miss β†’ execute the factory (your data loader), then set in L1 and L2, and broadcast change via the Backplane.

3) Expiration & Resilience ​

FusionCache supports multiple timers per entry:

  • Duration (Absolute Expiration) β€” fixed lifetime.
  • Jitter β€” small randomization to spread expirations and prevent synchronized reloads.
  • Fail-Safe Max Duration β€” how long a stale value can be served if the factory fails.
  • Soft vs Hard Expiration β€” soft lets reads continue (possibly with stale) while a background refresh happens; hard means the entry is considered dead.

Recommended defaults

  • Absolute: 5–30 minutes for read-heavy lists; 1–5 minutes for volatile entities.
  • Jitter: 10–20% of the absolute TTL.
  • Fail-Safe: 1–5 minutes for critical paths (enough to ride out brief outages).

Example:

csharp
var options = new FusionCacheEntryOptions()
    .SetDuration(TimeSpan.FromMinutes(10))
    .SetJitter(TimeSpan.FromMinutes(1))
    .SetFailSafe(true)
    .SetFailSafeMaxDuration(TimeSpan.FromMinutes(3))
    .SetSoftTimeout(TimeSpan.FromMilliseconds(150))
    .SetHardTimeout(TimeSpan.FromSeconds(2));

4) Negative Caching (caching β€œmissing”) ​

Why If a key often results in β€œnot found” (e.g., an Item that doesn’t exist), repeated misses can hammer the database (N calls β†’ N queries). Best practice: cache the β€œmissing” result for a short time (e.g., 15–60 seconds).

How Store a sentinel or allow null and distinguish at the boundary.

csharp
// Example: cache "not found" for 30s to avoid DB hammering
var entity = await cache.GetOrSetAsync<MyEntity?>(
    $"items:by-id:{id}",
    async _ => await repository.GetByIdAsync(id), // returns null if missing
    new FusionCacheEntryOptions()
        .SetDuration(TimeSpan.FromSeconds(30))     // short TTL for negative cache
        .SetJitter(TimeSpan.FromSeconds(5))
        .SetFailSafe(true)
);

// Caller decides how to handle null (404, etc.)
if (entity is null)
    return Results.NotFound();

Notes

  • Keep negative TTLs short to avoid masking eventual consistency (e.g., a record added right after a miss).
  • Use a different policy or key suffix if you need different durations for β€œexists” vs β€œmissing”.

5) FAQ ​

  • Should we cache huge lists? No, caching huge lists changes the source of truth instead of just reducing the load on the database.

  • What about per-user cache? If needed, include the user or tenant in the key (e.g., tenant:{t}:items:...). Watch cardinality.

  • Can we store nulls? Yes. Use short TTL and document that null means β€œnot found (recently).”

  • When do we bypass cache? For strongly consistent reads after a critical write, read-through after ExpireAsync, or use a β€œno-cache” path for admin tasks.