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);
}
import type { Redis } from "ioredis";
import { computeEtag } from "./etag";
export interface Representation {
etag: string;
body: string;
}
export class RepresentationCache {
constructor(private redis: Redis, private ttlSeconds = 300) {}
private key(id: string): string {
return `repr:product:${id}`;
}
async get(id: string): Promise<Representation | null> {
const raw = await this.redis.get(this.key(id));
return raw ? (JSON.parse(raw) as Representation) : null;
}
async set(id: string, value: unknown): Promise<Representation> {
const body = JSON.stringify(value);
const repr: Representation = { etag: computeEtag(body), body };
await this.redis.set(this.key(id), JSON.stringify(repr), "EX", this.ttlSeconds);
return repr;
}
async invalidate(id: string): Promise<void> {
await this.redis.del(this.key(id));
}
}
import { Request, Response } from "express";
import { sendCached } from "./etag";
import { RepresentationCache } from "./representationCache";
import { productRepo } from "./repository";
export class ProductsController {
constructor(private cache: RepresentationCache) {}
getProduct = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
let repr = await this.cache.get(id);
if (!repr) {
const product = await productRepo.findById(id);
if (!product) {
res.status(404).json({ error: "not_found" });
return;
}
repr = await this.cache.set(id, product);
}
sendCached(req, res, repr.etag, repr.body);
};
updateProduct = async (req: Request, res: Response): Promise<void> => {
const { id } = req.params;
const updated = await productRepo.update(id, req.body);
if (!updated) {
res.status(404).json({ error: "not_found" });
return;
}
await this.cache.invalidate(id);
const repr = await this.cache.set(id, updated);
sendCached(req, res, repr.etag, repr.body);
};
}
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.