Consuming an Asset Manifest in Server-Rendered Templates
A server-rendered application writes <script src="…"> at request time, long after the bundler has finished renaming files. The helper that bridges that gap — one function that takes main.js and returns /static/main.3f8a2c1d.js — is a few lines of code with three decisions hiding inside it: when the manifest is read, what happens when the lookup misses, and what the helper does on a developer’s laptop where no manifest exists at all. Getting those three wrong produces a page that renders perfectly in CI and 404s in production.
The Request Path
The manifest lookup sits between routing and template rendering. Everything about its performance follows from where the JSON parse happens relative to the request boundary.
Boot-Time Load vs Per-Request Read
| Strategy | Per-render cost | Picks up a new manifest | Correct for |
|---|---|---|---|
| Parse at module load, keep in a constant | ~0.2 µs per lookup | Only on process restart | Production, immutable containers |
| Parse at boot, invalidate on SIGHUP | ~0.2 µs per lookup | On signal, no restart | Long-running servers with hot config |
stat per request, re-parse on mtime change |
15–40 µs per render | Within one request | Development only |
readFileSync + JSON.parse per request |
200 µs–3 ms per render | Immediately | Nothing — this is the bug |
| Fetch over HTTP at boot with a TTL refresh | ~0.2 µs, plus a background fetch | Within the TTL | App deployed separately from assets |
The last row is worth calling out. When the application container ships independently of the frontend build — a common split once the backend has its own release cadence — the manifest cannot be baked into the image, because the image would then pin a frontend version. Fetching https://cdn.example.com/manifests/current.json at boot and refreshing it on a 60-second timer keeps the two decoupled and makes a frontend rollback take effect without touching the backend deployment.
The failure to avoid is the fourth row. A readFileSync inside a template filter looks harmless in a synthetic benchmark because the page cache makes it fast, but it serialises every render on the event loop in Node and holds the GIL in Python. Under load it shows up as a p99 latency cliff that is very hard to attribute back to a template helper.
A Node and Express Helper
// lib/assets.js
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const MANIFEST_PATH = resolve(process.cwd(), 'dist/asset-manifest.json');
const CDN_HOST = process.env.CDN_HOST || '';
const IS_DEV = process.env.NODE_ENV !== 'production';
let cache = null;
let cacheStamp = 0;
function load() {
const parsed = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
return parsed.assets ?? parsed;
}
function manifest() {
if (!IS_DEV) {
if (cache === null) cache = load();
return cache;
}
// Development: re-read at most once per second so a rebuild is picked up
// without paying for a parse on every single render.
const now = Date.now();
if (cache === null || now - cacheStamp > 1000) {
try {
cache = load();
} catch {
cache = {};
}
cacheStamp = now;
}
return cache;
}
export function assetUrl(key) {
const record = manifest()[key];
if (!record) {
if (IS_DEV) return `/src/${key}`;
throw new Error(`asset "${key}" is not in the manifest`);
}
const url = typeof record === 'string' ? record : record.url;
return CDN_HOST ? CDN_HOST + url : url;
}
export function assetIntegrity(key) {
const record = manifest()[key];
return record && typeof record === 'object' ? record.integrity : null;
}
export function scriptTag(key) {
const src = assetUrl(key);
const digest = assetIntegrity(key);
const attrs = digest ? ` integrity="${digest}" crossorigin="anonymous"` : '';
return `<script type="module" src="${src}"${attrs}></script>`;
}
Wire it into Express so templates can call it directly:
// server.js
import express from 'express';
import { assetUrl, scriptTag } from './lib/assets.js';
const app = express();
app.set('view engine', 'ejs');
app.locals.assetUrl = assetUrl;
app.locals.scriptTag = scriptTag;
app.get('/healthz/assets', (_req, res) => {
try {
assetUrl('main.js');
res.status(200).json({ manifest: 'ok' });
} catch (err) {
res.status(503).json({ manifest: 'broken', error: err.message });
}
});
app.get('/', (_req, res) => {
res.render('home', { title: 'Home' });
});
app.listen(3000, () => console.log('listening on 3000'));
The /healthz/assets route is the piece most implementations skip. It turns “the manifest is missing or malformed” from a 500 on a user-facing page into a failed health check that stops the rollout before traffic arrives.
Two behaviours in assetUrl are deliberate. In production a missing key throws, because rendering a page with a broken src is worse than failing loudly during a canary. In development it falls back to the source path, because Vite’s dev server serves /src/main.ts directly and no manifest exists on disk at all.
Development, Where There Is No Manifest
Three rules keep the development path from rotting:
Never let templates branch. If a template contains {% if debug %} around a script tag, the two branches will drift and the production one will be exercised only in production. Put the condition in the helper.
Re-read on a short interval, not on every render. A one-second staleness window is invisible to a human pressing reload and removes the per-render parse. If you want zero staleness, watch the file with fs.watch and invalidate on change instead of polling.
Emit the dev-server module preamble. With Vite, development also needs <script type="module" src="/@vite/client"></script> before the entry so hot module replacement connects. That is another thing the helper should own rather than the template.
A Django and Jinja-Style Helper
The same three decisions in Python. This version loads at first use, holds the parsed dict in a module-level variable, and re-reads when DEBUG is on.
# assets/manifest.py
import json
import os
import threading
from pathlib import Path
from django.conf import settings
_LOCK = threading.Lock()
_CACHE = None
_STAMP = 0.0
MANIFEST_PATH = Path(settings.BASE_DIR) / "dist" / "asset-manifest.json"
CDN_HOST = os.environ.get("CDN_HOST", "")
class AssetNotInManifest(Exception):
pass
def _read():
with MANIFEST_PATH.open(encoding="utf-8") as fh:
parsed = json.load(fh)
return parsed.get("assets", parsed)
def _manifest():
global _CACHE, _STAMP
if not settings.DEBUG:
if _CACHE is None:
with _LOCK:
if _CACHE is None:
_CACHE = _read()
return _CACHE
try:
mtime = MANIFEST_PATH.stat().st_mtime
except FileNotFoundError:
return {}
if _CACHE is None or mtime > _STAMP:
with _LOCK:
_CACHE = _read()
_STAMP = mtime
return _CACHE
def asset_url(key: str) -> str:
record = _manifest().get(key)
if record is None:
if settings.DEBUG:
return f"/src/{key}"
raise AssetNotInManifest(f'asset "{key}" is not in the manifest')
url = record if isinstance(record, str) else record["url"]
return CDN_HOST + url if CDN_HOST else url
def asset_integrity(key: str):
record = _manifest().get(key)
return record.get("integrity") if isinstance(record, dict) else None
Register it as a template tag and call it from a template:
# assets/templatetags/assets.py
from django import template
from django.utils.safestring import mark_safe
from assets.manifest import asset_integrity, asset_url
register = template.Library()
register.simple_tag(asset_url, name="asset_url")
@register.simple_tag
def asset_script(key):
src = asset_url(key)
digest = asset_integrity(key)
extra = f' integrity="{digest}" crossorigin="anonymous"' if digest else ""
return mark_safe(f'<script type="module" src="{src}"{extra}></script>')
{% load assets %}
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="{% asset_url 'main.css' %}">
<link rel="modulepreload" href="{% asset_url 'vendor.js' %}">
</head>
<body>
<div id="app"></div>
{% asset_script 'main.js' %}
</body>
</html>
The mtime check in _manifest() is stat-only in the common case — roughly two microseconds — and re-parses solely when the bundler has actually written a new file. Under DEBUG = False the stat disappears entirely and the double-checked lock guarantees exactly one parse across all worker threads.
When the Manifest and the Deployed Files Disagree
Disagreement between the manifest and the deployed file set has exactly one safe direction. Files may exist that the manifest does not name — those are simply the previous release, still live, still costing nothing. The reverse is an outage: the manifest names a file that is not there, and every page render emits a URL that 404s.
That asymmetry gives you the operating rule: upload assets first, publish the manifest last, and make the manifest publish a single atomic object write. With that ordering, a partial upload leaves the old manifest in place and users never see the broken release.
When it goes wrong anyway, the recovery is a manifest rewind rather than a redeploy, because the previous release’s files were never deleted:
# Which manifest is live, and does everything in it actually resolve?
curl -s https://cdn.example.com/manifests/current.json | jq -r '.version'
curl -s https://cdn.example.com/manifests/current.json \
| jq -r '.assets[].url' \
| while read -r u; do
code=$(curl -o /dev/null -s -w '%{http_code}' "https://cdn.example.com$u")
[ "$code" = 200 ] || echo "$code $u"
done
# Rewind to the last known-good manifest, then restart app processes
aws s3 cp "s3://$BUCKET/manifests/$LAST_GOOD_SHA.json" "s3://$BUCKET/manifests/current.json"
kubectl rollout restart deployment/web
The kubectl rollout restart is only needed if your helper caches for the process lifetime with no refresh. A helper that re-fetches on a TTL picks up the rewind on its own, which is a strong argument for the TTL variant in any environment where a fast rollback matters.
When to Reconsider This Approach
Skip the server-side helper entirely if your application is a pure single-page app whose index.html the bundler already rewrites. There is nothing for a helper to do, and the manifest exists only for the service worker and CI.
Move the lookup to the edge if you serve HTML from a CDN worker rather than an origin. The worker can hold the parsed manifest in module scope across invocations, which gives you boot-time semantics with edge latency — and it lets you swap manifests without restarting anything.
Prefer bundler-driven HTML injection when the template is genuinely static and the only dynamic part is the asset URL. A build step that rewrites the template is simpler to reason about than a runtime lookup, and it removes a class of production-only failures. The moment the page needs per-request data, though, you are back to the helper, and the check that keeps it honest is a manifest validation gate in CI that proves every key a template can ask for actually exists.
Related
- Asset manifest generation — parent guide covering what a manifest holds and how each bundler emits one
- Choosing a Webpack manifest plugin — which shape your helper will be reading
- Failing CI on a broken manifest — proving keys resolve before the release ships
- Build-time SRI digest generation — where the digest the helper renders comes from
- Pipeline wiring for asset builds — upload ordering and the atomic manifest publish