Building a Production-Ready Cache Provider in .NET Using FusionCache, Redis, MessagePack and Gzip


1. Introduction

In modern .NET applications, caching helps reduce database queries, improve response times and minimize unnecessary network communication.

We can build a reusable cache provider using four technologies:

  • FusionCache: Hybrid caching with in-memory and distributed caching.

  • Redis: Shared distributed cache.

  • MessagePack: Efficient binary serialization.

  • Gzip: Optional compression to reduce distributed cache payload sizes.

Our goal is to expose a simple ICacheProvider interface while keeping all caching implementation details inside the infrastructure layer.

2. How the caching process works

The caching process has two levels:




Level

Technology

Purpose

L1

In-memory cache

Fast access within the application instance

L2

Redis

Shared cache across application instances

When an application requests data:

  1. FusionCache checks L1.

  2. If the entry exists, it returns the in-memory value.

  3. If L1 misses, it checks Redis (L2).

  4. If Redis has the value, it retrieves and deserializes it.

  5. If both levels miss, the factory executes and retrieves data from the original source.

  6. FusionCache updates the cache levels.

The important point is that MessagePack and Gzip are primarily used for L2 serialization, not for every L1 cache access.

3. Cache configuration

We should centralize caching settings rather than hardcoding expiration, compression and resiliency policies.

CacheOptions.cs

public sealed class CacheOptions
{
    public const string SectionName = "Cache";

    public string KeyPrefix { get; set; } = "Application";

    public TimeSpan DefaultExpiration { get; set; }
        = TimeSpan.FromMinutes(5);

    public bool EnableFailSafe { get; set; } = true;

    public TimeSpan FailSafeMaxDuration { get; set; }
        = TimeSpan.FromHours(2);

    public TimeSpan FailSafeThrottleDuration { get; set; }
        = TimeSpan.FromSeconds(30);

    public bool EnableRedis { get; set; } = true;

    public bool EnableBackplane { get; set; } = true;

    public CompressionOptions Compression { get; set; } = new();

    public sealed class CompressionOptions
    {
        public bool Enabled { get; set; } = true;

        public int MinimumPayloadSize { get; set; } = 1024;

        public int MinimumSavingsBytes { get; set; } = 64;

        public CompressionLevel Level { get; set; }
            = CompressionLevel.Fastest;
    }
}

Configuration explanation

Setting

Default

Explanation

DefaultExpiration

5 minutes

Default logical cache lifetime

EnableFailSafe

true

Allows stale values during temporary failures

FailSafeMaxDuration

2 hours

Maximum stale-value retention

FailSafeThrottleDuration

30 seconds

Prevents repeatedly executing failing factories

EnableRedis

true

Enables distributed caching

EnableBackplane

true

Synchronizes cache changes across instances

Compression.Enabled

true

Enables conditional Gzip compression

MinimumPayloadSize

1 KB

Minimum payload size before attempting compression

MinimumSavingsBytes

64 bytes

Minimum storage reduction required

Compression.Level

Fastest

Prioritizes compression speed over maximum compression

These are starting values. Actual settings should depend on workload characteristics.

4. MessagePack and Gzip serialization

This is where we can reduce Redis storage consumption and network traffic.

The serialization pipeline is:

Original cache entry

Application object + FusionCache metadata

MessagePack serialization

Converts object into binary data

Gzip compression

Compresses only when worthwhile

Redis

Stores the resulting binary payload

Why MessagePack?

JSON serialization is convenient, but it produces textual data containing property names, quotes, delimiters and other formatting characters.

MessagePack uses a compact binary representation.

Benefits:

  • Smaller serialized payloads in many workloads.

  • Less parsing overhead in suitable workloads.

  • Binary representation suitable for Redis.

  • Strong support for typed object serialization.

However, the actual benefit depends on the object structure and serializer configuration.

Why Gzip?

MessagePack reduces the payload size through binary encoding. Gzip can reduce it further by compressing repetitive patterns.

For example, consider this illustrative payload:

Stage

Size

Original JSON

100 KB

MessagePack

65 KB

MessagePack + Gzip

18 KB

Illustrative numbers only, not benchmark results.

In this example, the final payload is 82% smaller than the original JSON.

This means:

  • Less Redis memory consumption.

  • Less network traffic between the application and Redis.

  • Potentially lower Redis bandwidth costs.

  • Better utilization of network capacity.

Does compression introduce overhead?

Yes. Gzip requires CPU time to compress and decompress data.

Operation

Additional cost

Serialization

MessagePack CPU processing

Compression

Gzip CPU processing

Redis write

Network transfer of compressed bytes

Redis read

Network transfer of compressed bytes

Decompression

Gzip CPU processing

Deserialization

MessagePack CPU processing

For small objects, compression may actually make caching slower because the CPU overhead exceeds the network and storage savings.

That is why we use a minimum payload size and minimum compression savings threshold.

Recommended compression policy

Payload size

Recommendation

Below 1 KB

No compression

1–10 KB

Compress only if beneficial

10–100 KB

Usually worth benchmarking

Above 100 KB

Strong candidate for compression

Already compressed files

Usually avoid compression

These thresholds are workload-dependent, not strict rules.

Important: Gzip is not encryption. Never treat compressed cache values as protected or confidential.

5. How much storage can we save?

Suppose an application caches 10,000 objects, each with an average JSON size of 100 KB.

Illustrative Redis payload comparison

976.6 MB

Original JSON payload storage, excluding Redis overhead

Illustrative payload sizes for 10,000 objects.

Chart
82% illustrative reduction

Compared with JSON, the compressed example uses approximately 800 MB less payload storage.

Actual Redis memory consumption will differ because of keys, object metadata, internal allocation, expiry information, replication and fragmentation.

Also, L1 memory is a separate cost: it generally retains deserialized objects and does not receive the same compression benefit.

6. Recommended FusionCache settings

FusionCache provides additional settings that are worth configuring for production.

Setting

Suggested starting value

Purpose

Duration

5 minutes

Normal cache expiration

FailSafe

Enabled selectively

Serve stale data during failures

FailSafeMaxDuration

2 hours

Maximum stale fallback period

FailSafeThrottleDuration

30 seconds

Avoid repeated failed refresh attempts

EagerRefreshThreshold

0.9

Refresh eligible entries before expiry

DistributedCacheSoftTimeout

1 second

Allow a short distributed-cache operation window

DistributedCacheHardTimeout

2 seconds

Bound distributed-cache operations

FactoryHardTimeout

Workload-specific

Bound factory execution

JitterMaxDuration

5 seconds

Spread expirations across instances

These are starting recommendations, not universal production values. In particular, factory timeouts should be based on database and external API latency.

FusionCache supports fail-safe, eager refresh, cache stampede protection, timeouts and backplane synchronization.

7. Cache invalidation across multiple instances

Consider an application running three API instances.

Each instance has its own L1 memory cache, while all instances share Redis.

If API instance 1 updates a product, the other instances may still hold an older copy in their local memory.

A Redis backplane solves this by broadcasting cache change notifications.

The backplane is useful when:

  • Running multiple API instances.

  • Scaling horizontally.

  • Updating shared reference data.

  • Requiring timely L1 invalidation.

It does introduce additional network messaging and coordination overhead. It is not a distributed transaction mechanism, so applications must still account for consistency requirements.

8. Final recommendation

For a production application, I recommend the following combination:

Area

Decision

Cache abstraction

Custom ICacheProvider

Cache engine

FusionCache

L1

In-memory cache

L2

Redis

Serialization

MessagePack

Compression

Conditional Gzip

Default expiration

5 minutes

Fail-safe

Selectively enabled

Backplane

Redis

Compression threshold

1 KB initially

Minimum compression savings

64 bytes

The most important performance consideration is to benchmark the entire pipeline rather than measuring compression alone.

Measure:

  • Cache hit latency for L1 and L2 separately.

  • Serialization and deserialization time.

  • Compression and decompression time.

  • Redis memory consumption.

  • Network bytes transferred.

  • CPU utilization under concurrent load.

Final takeaway: MessagePack and Gzip can substantially reduce distributed cache storage and network traffic, but they do not automatically improve response time. For frequently accessed small objects, avoiding compression may be faster. For large, repetitive objects, conditional compression can provide substantial infrastructure savings.


Let's complete this with a small, practical .NET example using FusionCache + Redis + MessagePack + conditional Gzip.

The example will demonstrate:

  1. Registering FusionCache and Redis.

  2. Configuring MessagePack serialization with Gzip compression.

  3. Implementing a reusable cache provider.

  4. Reading and writing a sample product.

The example keeps L1 uncompressed and applies serialization/compression only to the distributed-cache pipeline.

1. Install NuGet packages

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

2. Configuration

appsettings.json

{
  "ConnectionStrings": {
    "Redis": "localhost:6379,abortConnect=false"
  },
  "Cache": {
    "KeyPrefix": "MyApplication",
    "DefaultExpiration": "00:05:00",
    "FailSafeMaxDuration": "02:00:00",
    "EnableFailSafe": true,
    "Compression": {
      "Enabled": true,
      "MinimumPayloadSize": 1024,
      "MinimumSavingsBytes": 64
    }
  }
}

CacheOptions.cs

public sealed class CacheOptions
{
    public string KeyPrefix { get; set; } = "MyApplication";
    public TimeSpan DefaultExpiration { get; set; }
        = TimeSpan.FromMinutes(5);

    public bool EnableFailSafe { get; set; } = true;

    public TimeSpan FailSafeMaxDuration { get; set; }
        = TimeSpan.FromHours(2);

    public CompressionOptions Compression { get; set; } = new();

    public sealed class CompressionOptions
    {
        public bool Enabled { get; set; } = true;
        public int MinimumPayloadSize { get; set; } = 1024;
        public int MinimumSavingsBytes { get; set; } = 64;
    }
}

3. Gzip MessagePack serializer

This decorator uses FusionCache's existing MessagePack serializer rather than implementing MessagePack serialization again.

using System.IO.Compression;
using ZiggyCreatures.Caching.Fusion.Serialization;
using ZiggyCreatures.Caching.Fusion.Serialization.NeueccMessagePack;

public sealed class GzipMessagePackSerializer : IFusionCacheSerializer
{
    private const byte Raw = 0;
    private const byte Gzip = 1;

    private readonly FusionCacheNeueccMessagePackSerializer _inner = new();
    private readonly CacheOptions.CompressionOptions _options;

    public GzipMessagePackSerializer(
        CacheOptions.CompressionOptions options)
    {
        _options = options;
    }

    public byte[] Serialize<T>(T? value)
        => Compress(_inner.Serialize(value));

    public T? Deserialize<T>(byte[] data)
        => _inner.Deserialize<T>(Decompress(data));

    public async ValueTask<byte[]> SerializeAsync<T>(
        T? value, CancellationToken token = default)
    {
        var data = await _inner.SerializeAsync(value, token);
        return await CompressAsync(data, token);
    }

    public async ValueTask<T?> DeserializeAsync<T>(
        byte[] data, CancellationToken token = default)
    {
        var payload = await DecompressAsync(data, token);
        return await _inner.DeserializeAsync<T>(payload, token);
    }

    private byte[] Compress(byte[] data)
    {
        if (!_options.Enabled ||
            data.Length < _options.MinimumPayloadSize)
            return Mark(Raw, data);

        using var output = new MemoryStream();

        using (var gzip = new GZipStream(
            output, CompressionLevel.Fastest, true))
        {
            gzip.Write(data);
        }

        var compressed = output.ToArray();

        return compressed.Length + _options.MinimumSavingsBytes
            < data.Length
            ? Mark(Gzip, compressed)
            : Mark(Raw, data);
    }

    private async ValueTask<byte[]> CompressAsync(
        byte[] data, CancellationToken token)
    {
        if (!_options.Enabled ||
            data.Length < _options.MinimumPayloadSize)
            return Mark(Raw, data);

        using var output = new MemoryStream();

        await using (var gzip = new GZipStream(
            output, CompressionLevel.Fastest, true))
        {
            await gzip.WriteAsync(data, token);
        }

        var compressed = output.ToArray();

        return compressed.Length + _options.MinimumSavingsBytes
            < data.Length
            ? Mark(Gzip, compressed)
            : Mark(Raw, data);
    }

    private static byte[] Decompress(byte[] data)
    {
        Validate(data);

        if (data[0] == Raw)
            return data[1..];

        using var input = new MemoryStream(data, 1, data.Length - 1);
        using var gzip = new GZipStream(input, CompressionMode.Decompress);
        using var output = new MemoryStream();

        gzip.CopyTo(output);
        return output.ToArray();
    }

    private static async ValueTask<byte[]> DecompressAsync(
        byte[] data, CancellationToken token)
    {
        Validate(data);

        if (data[0] == Raw)
            return data[1..];

        using var input = new MemoryStream(data, 1, data.Length - 1);
        await using var gzip = new GZipStream(input, CompressionMode.Decompress);
        using var output = new MemoryStream();

        await gzip.CopyToAsync(output, token);
        return output.ToArray();
    }

    private static byte[] Mark(byte marker, byte[] data)
    {
        var result = new byte[data.Length + 1];
        result[0] = marker;
        Buffer.BlockCopy(data, 0, result, 1, data.Length);
        return result;
    }

    private static void Validate(byte[] data)
    {
        if (data.Length == 0 || data[0] > Gzip)
            throw new InvalidDataException("Invalid cache payload.");
    }
}

For production, add a maximum decompressed payload limit and corruption tests. The example keeps the serializer focused on the compression mechanism.

4. Register FusionCache and Redis

Program.cs

using Microsoft.Extensions.Caching.StackExchangeRedis;
using ZiggyCreatures.Caching.Fusion;
using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;

var cacheOptions = builder.Configuration
    .GetSection("Cache")
    .Get<CacheOptions>() ?? new CacheOptions();

var redisConnection = builder.Configuration
    .GetConnectionString("Redis")!;

builder.Services.AddSingleton(cacheOptions);

builder.Services.AddFusionCache()
    .WithDefaultEntryOptions(
        new FusionCacheEntryOptions()
            .SetDuration(cacheOptions.DefaultExpiration)
            .SetFailSafe(
                cacheOptions.EnableFailSafe,
                cacheOptions.FailSafeMaxDuration))
    .WithSerializer(
        new GzipMessagePackSerializer(cacheOptions.Compression))
    .WithDistributedCache(
        new RedisCache(new RedisCacheOptions
        {
            Configuration = redisConnection,
            InstanceName = cacheOptions.KeyPrefix + ":"
        }))
    .WithBackplane(
        new RedisBackplane(new RedisBackplaneOptions
        {
            Configuration = redisConnection
        }));

This follows FusionCache's documented distributed-cache registration approach.

GitHub+1

5. Create the cache provider

ICacheProvider.cs

public interface ICacheProvider
{
    Task<T?> GetOrSetAsync<T>(
        string key,
        Func<CancellationToken, Task<T?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default);

    Task RemoveAsync(
        string key,
        CancellationToken cancellationToken = default);
}

FusionCacheProvider.cs

using ZiggyCreatures.Caching.Fusion;

public sealed class FusionCacheProvider : ICacheProvider
{
    private readonly IFusionCache _cache;
    private readonly CacheOptions _options;

    public FusionCacheProvider(
        IFusionCache cache,
        CacheOptions options)
    {
        _cache = cache;
        _options = options;
    }

    public async Task<T?> GetOrSetAsync<T>(
        string key,
        Func<CancellationToken, Task<T?>> factory,
        TimeSpan? expiration = null,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(key);

        return await _cache.GetOrSetAsync<T>(
            $"{_options.KeyPrefix}:{key}",
            (_, token) => factory(token),
            options: _cache.CreateEntryOptions(
                duration: expiration ?? _options.DefaultExpiration),
            token: cancellationToken);
    }

    public async Task RemoveAsync(
        string key,
        CancellationToken cancellationToken = default)
    {
        await _cache.RemoveAsync(
            $"{_options.KeyPrefix}:{key}",
            token: cancellationToken);
    }
}

Register the wrapper:

builder.Services.AddSingleton<ICacheProvider, FusionCacheProvider>();

6. Use the cache provider

Consider a simple product service.

public sealed record Product(
    int Id,
    string Name,
    decimal Price);

public sealed class ProductService
{
    private readonly ICacheProvider _cache;

    public ProductService(ICacheProvider cache)
    {
        _cache = cache;
    }

    public Task<Product?> GetProductAsync(
        int id,
        CancellationToken cancellationToken)
    {
        return _cache.GetOrSetAsync(
            $"products:{id}",
            async token =>
            {
                // Replace with your repository/database call.
                await Task.Delay(100, token);

                return new Product(
                    id,
                    "Laptop",
                    75000);
            },
            TimeSpan.FromMinutes(10),
            cancellationToken);
    }
}

What happens during execution?

Request

Cache operation

First request on API 1

L1 miss → Redis miss → factory executes

Second request on API 1

L1 hit → returns object

First request on API 2

L1 miss → Redis hit → deserializes object

Subsequent request on API 2

L1 hit → returns object

On the Redis write, the serializer converts the FusionCache distributed entry to MessagePack and conditionally compresses it using Gzip.

On a Redis read, the reverse happens.

7. Important production notes

  • The example uses the same Redis connection configuration for storage and backplane. For high-throughput systems, consider sharing a ConnectionMultiplexer.

  • The Gzip format marker is a custom storage format. Coordinate deployments and serializer changes across application instances.

  • Use versioned cache namespaces during incompatible serializer upgrades.

  • {

  •   "Cache": {

        "KeyPrefix": "MyApplication",

        "SchemaVersion": "v2",

        "DefaultExpiration": "00:05:00"

      }

    }

  • Set a maximum decompressed size to protect against unexpectedly large payloads.

  • Benchmark Gzip against uncompressed MessagePack and LZ4 using actual application objects.

.

References

Vikash Chauhan

C# & .NET experienced Software Engineer with a demonstrated history of working in the computer software industry.

Post a Comment

Previous Post Next Post

Contact Form