Implementing RFC-compliant HTTP caching for HttpClient in .NET

 
 
  • Gérald Barré

HTTP caching is one of the most effective ways to improve application performance by reducing network traffic, minimizing server load, and decreasing response times. While browsers automatically implement HTTP caching, the same isn't true for HttpClient in .NET, which processes every request independently without built-in caching support.

In this post, I'll show you how to add standards-compliant HTTP caching to HttpClient using the Meziantou.Framework.Http.Caching NuGet package, which fully implements RFC 7234 (HTTP Caching) and RFC 8246 (Immutable directive).

#Understanding HTTP caching

Before diving into the implementation, let's understand how HTTP caching works. HTTP caching relies on a set of headers exchanged between clients and servers to determine whether a response can be cached, for how long, and under what conditions it should be revalidated.

##Key HTTP headers for caching

###Response headers

When a server sends a response, it includes headers that tell the client how to cache that response:

  • Cache-Control: The primary directive for caching behavior

    • max-age=<seconds>: How long the response remains fresh
    • no-cache: Response must be validated before reuse
    • no-store: Response must not be cached at all
    • must-revalidate: Must validate when stale
    • immutable: Response will never change (RFC 8246)
    • private: Only for private caches (e.g., browsers)
    • public: Can be cached by shared caches
  • ETag: An entity tag used for conditional validation

  • Last-Modified: The last modification date for conditional requests

  • Expires: Legacy expiration date (superseded by Cache-Control: max-age)

  • Vary: Specifies which request headers affect response variants

  • Age: Time in seconds since the response was generated

###Request headers

Clients can control caching behavior with request headers:

  • Cache-Control: Request-level caching directives

    • no-cache: Force revalidation before using cached response
    • no-store: Don't cache this request/response
    • max-age=<seconds>: Maximum age for cached responses
    • min-fresh=<seconds>: Response must be fresh for at least this duration
    • max-stale[=<seconds>]: Accept stale responses
    • only-if-cached: Return cached response or 504 Gateway Timeout
  • If-None-Match: Conditional request using ETag

  • If-Modified-Since: Conditional request using date

  • Pragma: no-cache for HTTP/1.0 backward compatibility

##The caching workflow

A typical HTTP caching interaction works like this:

  1. First request: Client requests a resource; server responds with caching headers
  2. Storage: Client stores the response based on caching directives
  3. Subsequent requests: Client checks if cached response is still fresh
  4. Freshness check: If fresh, serve from cache; if stale, proceed to validation
  5. Conditional validation: Client sends If-None-Match or If-Modified-Since headers
  6. Server validation: Server responds with 304 Not Modified if unchanged, or 200 OK with new content
  7. Cache update: Update cache metadata and serve the response

#Introducing Meziantou.Framework.Http.Caching

The Meziantou.Framework.Http.Caching package provides a HttpCachingDelegateHandler that implements RFC-compliant HTTP caching for HttpClient. It's a DelegatingHandler that intercepts HTTP requests and responses, automatically managing cache storage, validation, freshness calculation, and invalidation through an IHttpCacheStore implementation.

##Installation

Install the package and an in-memory cache store from NuGet:

Shell
dotnet add package Meziantou.Framework.Http.Caching
dotnet add package Meziantou.Framework.Http.Caching.InMemory

##Basic usage

The simplest way to add caching to your HttpClient is to use an IHttpCacheStore and wrap requests with HttpCachingDelegateHandler:

C#
using Meziantou.Framework.Http.Caching;
using Meziantou.Framework.Http.Caching.InMemory;

// Create the cache store
var cacheStore = new InMemoryHttpCacheStore();

// Create the caching handler
var cachingHandler = new HttpCachingDelegateHandler(new HttpClientHandler(), cacheStore);

// Create HttpClient with the caching handler
using var httpClient = new HttpClient(cachingHandler);

// Make requests - responses will be cached automatically
var response1 = await httpClient.GetAsync("https://api.example.com/data");
var response2 = await httpClient.GetAsync("https://api.example.com/data"); // Served from cache if fresh

##Integration with dependency injection

For ASP.NET Core applications, you can register the caching handler with dependency injection:

C#
services.AddSingleton<IHttpCacheStore, InMemoryHttpCacheStore>();

services.AddHttpClient("MyApi")
    .AddHttpMessageHandler<HttpCachingDelegateHandler>();

// Register the handler
services.AddTransient<HttpCachingDelegateHandler>();

If you want to configure caching options, you can do so during registration:

C#
services.AddSingleton<IHttpCacheStore, InMemoryHttpCacheStore>();

services.AddHttpClient("MyApi")
    .AddHttpMessageHandler(sp =>
    {
        var cacheStore = sp.GetRequiredService<IHttpCacheStore>();
        var options = new HttpCachingOptions
        {
            MaximumResponseSize = 1024 * 1024, // 1 MB limit
            ShouldCacheResponse = response => response.IsSuccessStatusCode,
        };
        return new HttpCachingDelegateHandler(cacheStore, options);
    });

#Support for client cache directives

##Force revalidation

You can force revalidation of cached responses using the no-cache directive:

C#
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data");
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true };
using var response = await httpClient.SendAsync(request);

// Note: If the response has Cache-Control: immutable and is fresh,
// it will still be served from cache without revalidation

##Accept stale responses

In scenarios where you prioritize availability over freshness, you can accept stale responses:

C#
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data");
request.Headers.CacheControl = new CacheControlHeaderValue
{
    MaxStale = true,
    MaxStaleLimit = TimeSpan.FromMinutes(5) // Accept up to 5 minutes stale
};
using var response = await httpClient.SendAsync(request);

##Use only cached responses

The only-if-cached directive returns either a cached response or a 504 Gateway Timeout:

C#
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.example.com/data");
request.Headers.CacheControl = new CacheControlHeaderValue { OnlyIfCached = true };
using var response = await httpClient.SendAsync(request);

if (response.StatusCode == HttpStatusCode.GatewayTimeout)
{
    Console.WriteLine("No cached response available");
}

##Configure caching options

The HttpCachingOptions class allows fine-grained control over caching behavior:

C#
using Meziantou.Framework.Http.Caching;
using Meziantou.Framework.Http.Caching.InMemory;

var cacheStore = new InMemoryHttpCacheStore();
var options = new HttpCachingOptions
{
    // Limit cached response size (in bytes)
    MaximumResponseSize = 1024 * 1024, // 1 MB

    // Custom predicate to filter which responses to cache
    ShouldCacheResponse = response =>
    {
        // Only cache successful responses
        if (!response.IsSuccessStatusCode)
            return false;

        // Don't cache responses with specific headers
        if (response.Headers.Contains("X-No-Cache"))
            return false;

        return true;
    }
};

var cachingHandler = new HttpCachingDelegateHandler(new HttpClientHandler(), cacheStore, options);
using var httpClient = new HttpClient(cachingHandler);

#Thread safety

The HttpCachingDelegateHandler is fully thread-safe and can be used concurrently across multiple threads. Concurrent requests to the same URL are coordinated to avoid cache stampede scenarios, and built-in stores such as InMemoryHttpCacheStore are designed for concurrent access.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?