The preview image for a template lives at a single object key, overwritten every time a new version is committed. That is the right storage decision, and it makes a caching decision for you that takes rather longer to notice.
One object per template
The alternative is a versioned key: previews/{templateId}/{versionId}.png. It has the usual
appeal of immutability and is wrong here for a boring reason — nothing reads an old one. The
template list shows the current preview; version history shows metadata, not thumbnails. A
template edited daily for a year leaves 365 objects you pay for and never serve.
It also buys you a cleanup job — a background process that deletes production data based on a rule you wrote once. Given the choice, do not write it.
So: previews/{templateId}.png, overwritten in place. Storage grows with the number of
templates rather than edits, and there is nothing to reap.
The URL never changes, so the TTL is a staleness budget
Because the key is stable the URL is stable, so a browser or CDN cannot tell a new image from an old one by looking at the address. The only thing that makes it fetch again is the freshness information on the previous response.
So the max-age you set is not only "how long can we avoid a request". It is also "how long a
correction stays invisible". Those are the same number, and you cannot move one without moving
the other.
The failure is unhelpfully quiet. A designer notices the GSTIN block on an invoice template sits a line too low, fixes it, commits, and looks at the template list — where the tile still shows the broken layout, because their browser holds a copy with eleven hours left on it. The commit is fine and the PDF generates correctly. All they have is a screen telling them their edit did not take.
There is no correct number
max-age | What you get | What it costs |
|---|---|---|
no-cache | Every commit visible at once | A request per view, and the egress with it |
| A few minutes | Most repeat views served locally | An edit can look ignored for minutes |
| A day | Almost no requests at all | A correction can stay invisible until tomorrow |
Which row is right depends on things we cannot see from here: how often your designers edit, how many people load the list, whether egress is a rounding error or a line item somebody asks about.
So do not pick one and bury it. Make the preview TTL configuration, with a modest default and a name that says what it does.
Clamp zero rather than emitting it
Configurability introduces a new way to be wrong, and this one does the opposite of what was asked.
Somebody sets the TTL to 0, or -1, meaning "do not cache this". Interpolated straight into
the header, that emits Cache-Control: public, max-age=-1. An invalid directive value is not an
error — caches ignore it. And a response with no usable freshness information is eligible for
heuristic caching, where the cache invents a lifetime of its own. "Do not cache" becomes
caching for an interval you did not choose and cannot see. max-age=NaN gets there the same
way, from an environment variable containing 5m.
export function previewCacheControl(ttlSeconds: number): string {
if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
return 'no-cache'
}
return `public, max-age=${Math.floor(ttlSeconds)}`
}
no-cache allows a copy to be stored but forces revalidation before it is reused, so a
repeat view usually costs a conditional request and a 304 rather than the whole image.
Validate the configured value at boot, not only when you build the header. A typo in a deployment variable should stop the deploy, not quietly change caching behaviour everywhere.
Presigned URLs cannot be cached at all
Check this before you spend an afternoon tuning the above.
If previews are served through a presigned URL generated per response, the query string carries a signature and an expiry that change every time, and the browser's cache key is the full URL, query string included. A URL that is never the same twice is never a hit; the previous entry sits in the cache addressed by something nobody will ask for again.
Cache headers on those responses are decoration. Worse, they read like a caching strategy to the next person who opens the file.
Two honest options. Serve previews from a stable, readable URL — a bucket behind a CDN, say —
and control freshness with Cache-Control. Or keep presigning and accept that every view is a
fetch. Rounding the expiry to a fixed boundary makes the URL cacheable again within that window,
but then the signature lifetime is your cache TTL.
The short version
- A stable key is the correct storage choice. It also chooses your invalidation strategy — a separate decision with no separate knob.
- Your cache TTL is the maximum time a corrected template shows the old image. Write that sentence next to the config value.
- Make the TTL configuration, not a constant somebody has to find.
- Clamp zero and negative values to
no-cache. A malformedmax-ageis ignored, and ignored means heuristically cached. - If the URL changes on every response, none of the above does anything.