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:
FusionCache checks L1.
If the entry exists, it returns the in-memory value.
If L1 misses, it checks Redis (L2).
If Redis has the value, it retrieves and deserializes it.
If both levels miss, the factory executes and retrieves data from the original source.
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.

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:
Registering FusionCache and Redis.
Configuring MessagePack serialization with Gzip compression.
Implementing a reusable cache provider.
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.StackExchangeRedis2. 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.
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
