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.
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 freshno-cache: Response must be validated before reuseno-store: Response must not be cached at allmust-revalidate: Must validate when staleimmutable: 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
Clients can control caching behavior with request headers:
Cache-Control: Request-level caching directives
no-cache: Force revalidation before using cached responseno-store: Don't cache this request/responsemax-age=<seconds>: Maximum age for cached responsesmin-fresh=<seconds>: Response must be fresh for at least this durationmax-stale[=<seconds>]: Accept stale responsesonly-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:
- First request: Client requests a resource; server responds with caching headers
- Storage: Client stores the response based on caching directives
- Subsequent requests: Client checks if cached response is still fresh
- Freshness check: If fresh, serve from cache; if stale, proceed to validation
- Conditional validation: Client sends
If-None-Match or If-Modified-Since headers - Server validation: Server responds with
304 Not Modified if unchanged, or 200 OK with new content - 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");
}
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!