I have an Azure Functions backend whose job, among other things, is serving a web app’s static files out of Blob Storage: JS bundles, fonts, icons, an index.html. Same files, same users, many times a day.
And for a while, every single request did the same dumb thing: download the blob from storage, push the bytes to the browser. The file hadn’t changed since five seconds ago. The user’s browser literally had an identical copy already. Didn’t matter - full download from storage, full response to the client, every time. I was paying latency and moving bytes just to deliver files nobody actually needed re-delivered.
The fix was already sitting in every storage response I’d been ignoring: the ETag.
TL;DR
Azure Blob Storage maintains an ETag for every blob - a version fingerprint that changes on every write. Take the If-None-Match header the browser sends you, forward it into the SDK call as a BlobRequestConditions, and Azure itself answers 304 when nothing changed. No file body from storage to your function, no file body from your function to the browser, no server-side cache to maintain. Your function becomes a pipe that forwards one header.
Thirty Seconds on ETags
Every blob response (and every properties call) includes an ETag header, something like "0x8DC5F3A2B1E4D70". Storage changes it whenever the blob’s content or metadata changes. It costs nothing, it’s always there.
On the HTTP side, the handshake is old and boring and great: the server sends ETag with a response; the browser saves it; next time the browser asks for the same URL it includes If-None-Match: <that etag>; if the server still has the same version, it answers 304 Not Modified with no body, and the browser uses its cached copy.
Boring. Reliable. Built into every browser since forever.
The Naive Version
My first pass was the obvious one:
var response = await blob.DownloadStreamingAsync(cancellationToken: ct);
return new FileStreamResult(response.Value.Content, contentType);
Works fine. But look at what the function actually is in this setup: a photocopier standing between storage and the browser, dutifully copying files that both sides already agree on. The browser has the file. Storage knows the file hasn’t changed. And my function in the middle is the only one who never asked.
The Trick: Be a Pipe, Not a Cache
The Azure SDK supports conditional requests natively. So instead of checking anything myself, I just hand the browser’s ETag straight to storage:
public async Task<AssetResponse?> GetAsync(string path, string? ifNoneMatch, CancellationToken ct)
{
var blob = _container.GetBlobClient(path);
var options = new BlobDownloadOptions();
// Pass the client's ETag to the storage service as a conditional request.
// Azure Blob Storage will short-circuit at the network level and return a 304,
// meaning we never transfer the file body when the client is up to date.
if (!string.IsNullOrEmpty(ifNoneMatch))
options.Conditions = new BlobRequestConditions { IfNoneMatch = new ETag(ifNoneMatch) };
try
{
var response = await blob.DownloadStreamingAsync(options, ct);
if (response.GetRawResponse().Status == 304)
{
// Client's copy is current - signal it without a body.
return new AssetResponse { ETag = ifNoneMatch!, IsNotModified = true };
}
var details = response.Value.Details;
return new AssetResponse
{
ETag = details.ETag.ToString(),
Content = response.Value.Content,
ContentType = details.ContentType,
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
What’s happening here?
- The browser’s
If-None-Matchvalue goes intoBlobRequestConditions.IfNoneMatch. That turns the download into a conditional request, the same ETag handshake, just one hop deeper. - The comparison happens inside Azure Storage, not in my code. On a match, storage answers 304 and the response has no body. The file bytes never even reach my function.
- On a miss (file changed, or first visit), it’s a normal download, and
DownloadStreamingAsyncgives me a live stream I pass straight through without buffering the whole file in memory. - Missing blob → the
RequestFailedExceptionfilter turns a 404 into anull, which the endpoint maps to a proper NotFound.
There is no dictionary of ETags, no memory cache, no invalidation logic. I didn’t build a cache, I connected two caches that already existed: the browser’s and storage’s own knowledge of its blobs.
Finishing the Loop Toward the Browser
The function endpoint relays the result:
req.HttpContext.Response.Headers[HeaderNames.ETag] = asset.ETag;
if (asset.IsNotModified)
return new StatusCodeResult(StatusCodes.Status304NotModified);
req.HttpContext.Response.Headers[HeaderNames.CacheControl] = asset.IsImmutable
? "public, max-age=31536000, immutable"
: "no-cache";
return new FileStreamResult(asset.Content!, asset.ContentType!);
Two details worth pausing on:
no-cachedoesn’t mean “don’t cache”. It means “cache it, but revalidate before using it”. That revalidation is exactly theIf-None-Matchround trip, which the pass-through just made nearly free - a header-only 304 instead of a file download. This is whatindex.htmlgets.- Content-hashed files skip the conversation entirely. My build tool outputs filenames like
app.ByJ3R0Az.js- the hash is the version. Those getmax-age=31536000, immutable, so the browser never revalidates them at all. A new deploy produces a new filename, which is simply a different URL. The ETag dance is only for files whose names stay stable while their content changes.
Why I Like This Better Than a Memory Cache
My first instinct was an IMemoryCache of blob contents in the function. I’m glad I resisted:
- Nothing to size. No “how many MB of blobs do I keep in memory” question.
- Nothing to invalidate. The ETag comparison is against live storage, so a deploy is visible on the very next request. Stale-cache bugs can’t exist because there’s no cache to go stale.
- Scale-out safe. Ten function instances behave identically because none of them hold state. A per-instance memory cache would give ten different answers for 60 seconds after every deploy.
The thing that owns the data does the validation. Everyone else just forwards headers. 📮
Gotchas
- Round-trip the ETag string untouched. The quotes are part of the value. Trim them, “clean them up”, or re-wrap them and the comparison silently never matches again - everything still works, you just download every file every time and never notice.
- No ETag out, no
If-None-Matchback. Browsers only revalidate if your response included theETagheader in the first place. Forget it on one branch (error paths are a classic) and that file is a full download forever. no-cachestill costs one round trip per file per load. That’s the deal: a header-only 304 instead of a body. For files that never change, don’t negotiate - content-hash the filename and goimmutable.
Wrapping Up
The cheapest download is the one that never happens. Blob Storage already fingerprints every blob and already speaks conditional requests - most backends just never pass the browser’s question along. Forward If-None-Match, relay the 304, and let the two parties who actually know the answer talk to each other.
