Operations / Correct under repetition
What belongs in the cache key for a rendered document
The full list of inputs a rendered document depends on, which is longer than the data and is the reason document caches serve stale output.
Why the default answer is wrong here
A document is a function of more inputs than the obvious one. The data is the input people think of. The template version, the stylesheet, the embedded fonts, the logo, the render options, the locale and the code that assembled the markup are all inputs too, and any of them changing produces a different document from the same data. A key built from the data alone is not a key for the document, it is a key for one of its arguments.
The decisions
Reasons rather than a description of the code. Each has a default that is defensible in general and wrong for documents specifically.
- Hash the assembled markup rather than the data, which folds the template, the stylesheet and the assembling code into one value at no extra effort.
- Include the render options in the key, because the same markup at a different page size is a different document.
- Include a version for the assets the markup references by URL, since a logo or a font served from a stable URL can change underneath a stable key.
- Include the locale, explicitly, even when it is currently always the same. It is the input most likely to be added later and forgotten.
- Prefer a deploy-scoped prefix over trying to enumerate every code input. Adding the build identifier to the key is coarse, and it makes a deploy correct by construction.
- Write down what is deliberately excluded. A key that excludes the requesting user is a decision, and it should be a recorded one rather than an omission.
In practice
A fragment, with the thing that goes wrong kept in a comment where it is the more instructive half.
import { createHash } from "node:crypto";
function cacheKey(spec) {
const h = createHash("sha256");
// Folds the template, the stylesheet and the assembling code into one
// value, which is why hashing the markup beats hashing the data.
h.update(spec.html);
// The same markup at a different page size is a different document.
h.update(JSON.stringify(sortKeys(spec.options ?? {})));
// Assets referenced by a stable URL can change underneath a stable key.
h.update(spec.assetVersion ?? "");
// Explicit even when it is currently constant: it is the input most
// likely to be added later and forgotten.
h.update(spec.locale ?? "en-GB");
// Coarse and correct by construction: a deploy invalidates everything.
return `doc:${process.env.BUILD_ID}:${h.digest("hex")}`;
/* Deliberately excluded, and recorded as a decision rather than an
omission:
the requesting user documents here do not vary by viewer; if one
ever does, it must not use this cache */
}
function sortKeys(o) {
return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
}What people do instead
Serialising the options object without sorting its keys. Two logically identical option sets hash differently depending on insertion order, the cache misses constantly, and it looks like the cache is not working rather than like the key is unstable.
What the symptom looks like
A hit rate near zero usually means an unstable key: a timestamp, an unsorted object, or something per-request that crept into the hash. A hit rate that survives a deploy means the build identifier is missing.
Frequently asked
Is this worth doing for a small volume of documents?
Some of it, and the cheap parts are the ones that matter. Allocating a document's identity before rendering it costs nothing and prevents duplicates forever. Logging the template version costs one field and answers most support questions. Queue design, backpressure and capacity planning are genuinely for scale and can wait until there is some.
Why is duplication treated as more serious than latency here?
Because a document usually carries an identity. A duplicated read is harmless and a duplicated invoice is a second numbered document for one event, which somebody has to reconcile by hand and which may already have been sent. That asymmetry is why the correctness half of this cluster is larger than it would be for most APIs.
How does this relate to the troubleshooting pages?
Those start from a symptom you are looking at right now and work back to a cause. These start from a decision made before the symptom exists. The two meet in the middle: a decision skipped here usually appears there some months later as a problem with no obvious explanation.
Related operational topics
The decisions that depend on each other, then the rest of the same group.
Caching a rendered document, and when it is safe to serve one
Serving a previously rendered document instead of making a new one, which is nearly free when the inputs are unchanged and wrong when they are not.
How long to keep a generated document, and who decides
Deciding what happens to a document after it is delivered, which is a policy question with a cost attached and a legal floor underneath it.
Stopping the same document being requested twice at once
Collapsing concurrent identical requests, which is a different problem from idempotency because both arrive before either has finished.
Idempotency for a document that must not be generated twice
Making a repeated request produce the same document rather than a second one, which matters more here than in most APIs because the artefact is often numbered.
Retrying a render safely, and which failures are safe to retry
Deciding which failures should be retried and which should not, because a blanket retry on a document endpoint is a duplication mechanism.
Every operational topic
The full list, grouped by correctness, visibility and cost.
What this API actually does
The options and endpoints these decisions are built on, one page each.
Most of these decisions are cheaper to make before the first production run than after the first incident, and none of them need a large system to be worth making.