Hi everyone! In this article, I’d like to share my experience implementing client-side image caching in an ASP.NET Core MVC application.
In the SaaS world, reducing resource consumption is an important task, especially as the number of customers served by a single server increases. Image generation and delivery on the backend can be relatively CPU- and memory-intensive operations. Adding client-side caching with the HTTP Cache-Control header can significantly reduce the load on the server.
Suppose we have an ImageController with an action that retrieves an image from a database and dynamically resizes it so that its dimensions do not exceed the specified maxWidth and maxHeight:
public class ImageController : Controller
{
[HttpGet]
public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
{
...
return new ImageResult
{
FileName = fileName,
Content = memoryStream.ToArray()
};
}
}
I won’t go into the image-processing implementation here, since the main topic of this article is caching rather than image manipulation.
Adding client-side caching
We want the server to include a Cache-Control header in the response. There are several ways to do this.
The first option is to use the ResponseCache attribute:
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)] public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
This produces the following header:
Cache-Control: public, max-age=60
Another option is to modify the action itself:
public ActionResult ViewResized(int id, int maxWidth, int maxHeight)
{
Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = 60
};
...
}
However, I prefer not to mix functional and non-functional concerns. Therefore, I chose a third approach: implementing the caching logic as middleware.
This keeps the controller action clean and also allows the cache lifetime to be changed dynamically—for example, by reading it from a database or configuration file.
Implementing the middleware
The middleware can look like this:
internal class ImageCacheMiddleware : MiddlewareWithService
{
private readonly RequestDelegate next;
public ImageCacheMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task Invoke(HttpContext context)
{
var settingsProvider =
context.RequestServices.GetService<ISettingsProvider>();
int imageCacheIntervalInSeconds =
settingsProvider.Get("ImageCacheIntervalInSeconds");
if (
> 0)
{
context.Response.OnStarting(() =>
{
// add the header only if the controller hasn't set it already
if (!context.Response.Headers.ContainsKey("Cache-Control"))
{
context.Response.Headers.Append(
"Cache-Control",
$"public, max-age={imageCacheIntervalInSeconds}");
}
return Task.CompletedTask;
});
}
await next.Invoke(context);
}
}
First, we obtain an ISettingsProvider, an application-specific abstraction responsible for accessing configuration settings.
Its implementation is outside the scope of this article. Depending on the application, it could read settings from a database, configuration file, environment variables, or another source.
We then retrieve the value of ImageCacheIntervalInSeconds.
If the value is greater than zero, we register a Response.OnStarting callback that adds the following header:
Cache-Control: public, max-age={imageCacheIntervalInSeconds}
The public directive means that the response can be cached not only by the browser but also by intermediate proxies and CDNs.
If this behavior isn't appropriate for your scenario, consider using other directives such as no-cache, no-store, or private.
Registering the middleware
Next, we need an extension method that makes it easier to register the middleware during application startup:
internal static class ImageCacheMiddlewareExtension
{
public static IApplicationBuilder UseImageCacheMiddleware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<ImageCacheMiddleware>();
}
}
We can then add it to Program.cs:
var builder = WebApplication.CreateBuilder(args);
...
var app = builder.Build();
...
app.UseWhen(
context => context.Request.Path
.ToString()
.ToLower()
.Contains("/image/view"),
appBranch =>
{
appBranch.UseImageCacheMiddleware();
});
That's it—the caching middleware is now in place.
You can verify that it works using the browser's Network tab. The first response should contain the Cache-Control header, while subsequent requests should be served from the browser cache.
Unit testing
The remaining step is to cover the middleware with unit tests.
For this example, we can use NUnit together with the popular open-source mocking library Moq.
There are two tricky parts we need to handle:
Mocking the retrieval of the cache lifetime from the database.
Triggering the
Response.OnStartingcallback.
Mocking the settings provider
The first problem comes from the following call chain:
HttpContext.RequestServices.GetService<T>()
To make the middleware easier to test, we can extract service resolution into a separate virtual method:
protected virtual T GetService<T>(HttpContext ctx)
{
return ctx.RequestServices.GetService<T>();
}
We can then create a test-specific subclass and override this method:
internal class ImageCacheMiddlewareForTesting : ImageCacheMiddleware
{
private readonly ISettingsProvider settingsProvider;
public ImageCacheMiddlewareForTesting(
RequestDelegate next,
ISettingsProvider settingsProvider)
: base(next)
{
this.settingsProvider = settingsProvider;
}
protected override T GetService<T>(HttpContext context)
{
if (typeof(T) == typeof(ISettingsProvider))
return (T)this.settingsProvider;
throw new NotSupportedException();
}
}
The mock ISettingsProvider can now be passed directly to the test middleware.
Testing Response.OnStarting
For the second problem, we can use Moq to capture the callback passed to OnStarting.
The callback is stored in a local variable, and the test can then simulate the beginning of the response by invoking it directly:
[TestFixture]
public class TestImageCacheMiddleware
{
[Test]
public void Test_WhenCacheLifetimeSpecified_ThenItIsAddedToHeaders()
{
// Configure a one-second cache lifetime
var settingsProvider = new Mock<ISettingsProvider>();
settingsProvider
.Setup(x => x.Get(It.IsAny<string>()))
.Returns(1);
var headers = new HeaderDictionary();
var response = new Mock<HttpResponse>();
response
.Setup(x => x.Headers)
.Returns(headers);
// Capture the callback
Func<Task> capturedCallback = null;
response
.Setup(r => r.OnStarting(It.IsAny<Func<Task>>()))
.Callback<Func<Task>>(callback =>
capturedCallback = callback);
var ctx = new Mock<HttpContext>();
ctx
.Setup(x => x.Response)
.Returns(response.Object);
var requestDelegate = new Mock<RequestDelegate>();
var middleware = new ImageCacheMiddlewareForTesting(
requestDelegate.Object,
settingsProvider.Object);
middleware.Invoke(ctx.Object)
.GetAwaiter()
.GetResult();
// Simulate the beginning of the server response
if (capturedCallback != null)
{
capturedCallback()
.GetAwaiter()
.GetResult();
}
// Verify that the cache header was added
ClassicAssert.AreEqual(1, headers.Count);
ClassicAssert.AreEqual(
"public, max-age=1",
headers["Cache-Control"]);
}
[Test]
public void Test_WhenCacheLifetimeNotSpecified_ThenItIsNotAddedToHeaders()
{
// Cache lifetime is disabled
var settingsProvider = new Mock<ISettingsProvider>();
settingsProvider
.Setup(x => x.Get(It.IsAny<string>()))
.Returns(0);
var headers = new HeaderDictionary();
var response = new Mock<HttpResponse>();
response
.Setup(x => x.Headers)
.Returns(headers);
// Capture the callback
Func<Task> capturedCallback = null;
response
.Setup(r => r.OnStarting(It.IsAny<Func<Task>>()))
.Callback<Func<Task>>(callback =>
capturedCallback = callback);
var ctx = new Mock<HttpContext>();
ctx
.Setup(x => x.Response)
.Returns(response.Object);
var requestDelegate = new Mock<RequestDelegate>();
var middleware = new ImageCacheMiddlewareForTesting(
requestDelegate.Object,
settingsProvider.Object);
middleware.Invoke(ctx.Object)
.GetAwaiter()
.GetResult();
// Simulate the beginning of the server response
if (capturedCallback != null)
{
capturedCallback()
.GetAwaiter()
.GetResult();
}
// Verify that no cache header was added
ClassicAssert.AreEqual(0, headers.Count);
}
}
Conclusion
We have added client-side caching to the image-serving endpoint, reducing the amount of work performed by the server.
By moving the caching logic into middleware, we also kept the controller action focused on its primary responsibility while retaining the ability to configure the cache lifetime dynamically.
Finally, the middleware is covered by unit tests, helping prevent regressions as the application evolves.
