PDFPipe

Operations / Correct under repetition

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.

Why the default answer is wrong here

Rendering is the expensive step in most document pipelines and the output is usually deterministic given its input, which makes caching obviously attractive. The difficulty is entirely in knowing whether the input changed, and document inputs are wider than they look: the data, the template, the stylesheet, the fonts, the logo, the options and the code that assembled all of it. A cache keyed on the data alone serves a stale document after a template change, and does it silently, because a stale document is a perfectly valid document.

The decisions

Reasons rather than a description of the code. Each has a default that is defensible in general and wrong for documents specifically.

  • Cache the rendered bytes, not the markup. Rendering is the cost; assembling the markup rarely is.
  • Include every input in the key, which is the subject of its own page and the part people get wrong.
  • Prefer storing the document to caching it where the document has an identity. A document that was issued should be retrievable because it was issued, not because it happens to still be in a cache.
  • Never cache a document whose content depends on who is asking. That is not a cache key problem, it is a design problem, and the fix is to include the viewer in the identity or not to cache.
  • Set an expiry that matches the document's meaning rather than a default. A quote valid for thirty days and a dashboard export valid for an hour are different objects.
  • Make a cache hit visible in the response or the logs, so somebody debugging a stale document can tell in one step that it was served rather than made.

In practice

A fragment, with the thing that goes wrong kept in a comment where it is the more instructive half.

js
async function renderCached(spec) {
  const key = cacheKey(spec);          // see the cache key page

  const hit = await cache.get(key);
  if (hit) {
    metrics.increment("render.cache.hit");
    return { pdf: hit, cached: true };  // visible to whoever is debugging
  }

  const res = await pdf.post("/v1/pdf", {
    html: spec.html,
    options: spec.options,
  });

  metrics.increment("render.cache.miss");
  await cache.set(key, res.body, { ttl: ttlFor(spec.kind) });
  return { pdf: res.body, cached: false };
}

// The expiry follows the document's meaning, not a default.
function ttlFor(kind) {
  switch (kind) {
    case "quote":     return 30 * 86_400;   // valid for thirty days
    case "statement": return 7 * 86_400;
    case "export":    return 3_600;         // a view of live data
    default:          return 3_600;
  }
}

/* Not cacheable at all: anything whose content depends on the viewer.
   That is a design problem rather than a key problem, and the answer is
   either to put the viewer in the identity or not to cache it. */

What people do instead

Caching on the data and forgetting the template. The next template deployment serves the old design to every customer whose data did not change, which is most of them, and there is no error and no obvious symptom until somebody notices two customers received different-looking invoices in the same week.

What the symptom looks like

A cache hit rate that is very high is worth checking rather than celebrating: it can mean the key is too coarse and stale documents are being served. A rate that collapses after a deploy is correct behaviour and worth expecting.

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.

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.