I implemented ETag in my .NET Minimal API server today. If you don’t know what an Entity Tag is, google has plenty of suggestions. The simple explanation is that
- The browser caches resources (e.g. images)
- Any time the browser wants a resource it checks with the server to see if there’s a different version than what it has
- If the browser has the correct version the server returns status code 304 – not modified.
- The change does not need to be based on the last modified times. There is a different HTTP mechanism for that (the If-Modified-Since header).
There are many good articles showing how to implement them in .NET Core or middleware. But I wanted Minimal API and didn’t want to do it with middleware. In the end I took bits and pieces from multiple articles and got working.
Browser Developer Tools
I depend on browser dev tools any time I’m doing something with HTTP headers. Primarily, the Network tab which shows request and response headers. I usually check “Disable Cache” to make sure the browser always requests the latest resources. But to test cache-related headers, the cache must be enabled.

There are 2 types of requests related to ETag


The request/response on the left above is what should happen the first time a resource is requested. The server responds with an “ETag” header.
The other request/response is what happens when the browser wants a cached resource and checks if there is a better version. If there is a better version, the server returns status code 200 and the contents of the resource. If there is no better version, the server returns status code 304 and the browser can use it’s cached version.
The difference from if-modified-since caching is that the server can decide what makes a better version. In my case I’m using modified date, but I can change that in the future.
ETag Response
I hadn’t managed any headers with .NET Minimal API before and was happy to see that it has an EntityTagHeaderValue class and IResult objects use it.
Results.Stream(photoStream,
"image/jpeg",
null,
file.ModifiedOn,
new EntityTagHeaderValue(etag));
the EntityTagHeaderValue() constructor accepts a string and I see an “ETag” header returned in the response. I made the mistake of not looking carefully. After debugging later I found 2 problems. First, the ETag header value shto be wrapped in quotes
ETag: "638112046756949837"
but EntityTagHeaderValue was leaving them off
ETag: 638112046756949837
So I changed the way I created it
new EntityTagHeaderValue("\""+etag+"\"")
The second problem is that browsers ignore the ETag header unless there is also a header allowing them to cache the resource. For example
Cache-Control: no-cache
For this, I had to (or chose to) use the IHttpContext of the request. Access to the HttpContext is injected into the route handler.
routes.MapGet("/image/{id}", async (IMediaFileService service,
int id,
IHttpContextAccessor context) =>
...
context.HttpContext.Response.Headers.Add("cache-control",
"no-cache");
...
}
Neither of these problems is a big deal, but I expected objects named EntityTagHeaderValue and IResult that accept one to “do the right thing”.
304 Not Modified
I did not have trouble returning the correct status code for an unmodified file
routes.MapGet("/image/{id}", async (IMediaFileService service,
int id,
[FromHeader(Name = "If-None-Match")] string? ifNotMatch,
IHttpContextAccessor context) =>
{
...
var etag = '\"' + getETag(file) + '\"';
if (ifNotMatch == etag)
{
response =
Results.StatusCode(StatusCodes.Status304NotModified);
} else {
...
Minimal API has an easy way to inject the HTTP request header as a parameter to the route. Once I have that I just compare it to the current ETag for the file and return 304 if they match.
Summary
I like Minimal API very much. It’s very possible I missed something in the docs or my searches and this could have been done quicker. In the end I’m happy with the results. The full route handler is
routes.MapGet("/image/{id}", async (IMediaFileService service,
int id,
[FromHeader(Name = "If-None-Match")] string? ifNotMatch,
IHttpContextAccessor context) =>
{
IResult response = null!;
response = Results.StatusCode(StatusCodes.Status404NotFound);
var file = await service.GetMediaFileById(id);
if (file != null)
{
var path = service.GetFilePath(file);
var etag = '\"' + file.ModifiedOn.Ticks.ToString() + '\"';
if (ifNotMatch == etag)
{
response = Results.StatusCode(StatusCodes.Status304NotModified);
}
else
{
using (var img = new MagickImage(path))
{
img.Rotate(file.RotationDegrees);
MemoryStream stream = new MemoryStream();
img.Write(stream, MagickFormat.Jpeg);
stream.Seek(0, SeekOrigin.Begin);
context.HttpContext.Response.Headers.Add("cache-control", "no-cache");
response = Results.Stream(stream, "image/jpeg", null, file.ModifiedOn, new EntityTagHeaderValue(etag));
}
}
}
return response;
});