typescript 102 lines · 3 tabs

ETag + conditional GET for read-heavy endpoints

Shared by codesnips Jan 2026
3 tabs
import { createHash } from "crypto";
import { Request, Response } from "express";

export function computeEtag(body: string): string {
  const digest = createHash("sha1").update(body).digest("base64");
  return `"${digest}"`;
}

export function ifNoneMatch(req: Request, etag: string): boolean {
  const header = req.header("If-None-Match");
  if (!header) return false;
  if (header.trim() === "*") return true;
  const tags = header.split(",").map((t) => t.trim());
  return tags.includes(etag);
}

export function sendCached(
  req: Request,
  res: Response,
  etag: string,
  body: string
): void {
  res.setHeader("ETag", etag);
  res.setHeader("Cache-Control", "no-cache");

  if (ifNoneMatch(req, etag)) {
    res.removeHeader("Content-Type");
    res.removeHeader("Content-Length");
    res.status(304).end();
    return;
  }

  res.status(200).type("application/json").send(body);
}
3 files · typescript Explain with highlit

This snippet shows how to add strong ETag support and conditional GET handling to read-heavy endpoints in an Express API, so repeat requests can be served with a cheap 304 Not Modified instead of re-serializing and re-shipping a full payload.

The core idea in etag.ts is to derive a content validator from the response body rather than from filesystem stats. computeEtag hashes the serialized payload with SHA-1 and wraps it in quotes to form a strong validator. ifNoneMatch then parses the incoming If-None-Match header, which may carry a comma-separated list of tags or the wildcard *, and reports whether the current representation is still fresh. Treating the entity tag as a function of the body means the API never lies about freshness: any change to the data changes the hash, so stale 304s cannot happen.

sendCached is the response helper that ties it together. It always sets ETag and a Cache-Control of no-cache, which is deliberately counterintuitive — no-cache does not mean "don't cache", it means "cache but revalidate every time". When the client's validator matches, the handler strips the body-related headers and ends the response with 304, saving bandwidth while still letting the client reuse its stored copy. Otherwise it sends the full JSON.

Because hashing large payloads on every request is itself work, ProductsController layers a Redis-backed representation cache in front of the hashing step. getProduct reads a memoized { etag, body } pair keyed by product id; on a miss it loads from the repository, serializes once, computes the tag, and stores both. This makes the common revalidation path a single Redis lookup plus a string compare.

The subtle part is invalidation, handled in updateProduct: after a write it deletes the cached representation via invalidate so the next read recomputes a fresh etag. This avoids the classic pitfall where the body changes but the served validator does not. The trade-offs are worth noting — strong ETags require materializing the body to hash it, and a distributed cache adds a coherence concern, so writes must reliably bust the key. For genuinely read-heavy resources that change infrequently, this pattern turns most traffic into tiny 304 round-trips.


Related snips

Share this code

Here's the card — post it anywhere.

ETag + conditional GET for read-heavy endpoints — share card
Link copied