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.

Manifest lookup on the request path A sequence diagram with four participants: the browser, the application server, the in-process manifest cache and the CDN edge. The manifest is read once at boot; each request performs an in-memory lookup and the browser then fetches the hashed file from the edge. Browser App server Manifest cache CDN edge boot: parse manifest once GET /product/42 lookup key main.js hashed URL, no I/O HTML with hashed src GET the hashed file, cache hit The only disk read happens before the first request; every lookup after that is a property access.
Parsed once at boot, the manifest turns per-request URL resolution into an in-memory object lookup with no filesystem call on the hot path.

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

Two resolution modes A single helper call branches on environment. In development no manifest exists and the source path is returned for the dev server to transform. In production a manifest parsed at boot returns the hashed, immutable URL. assetUrl("main.js") DEVELOPMENT PRODUCTION manifest is absent by design manifest parsed once at boot helper returns the source path helper returns the hashed URL dev server transforms on demand edge serves it immutably One call site, two branches — the branch belongs in the helper, never in the template.
The environment branch lives inside the helper so templates stay identical between a developer's machine and production.

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

Manifest and file set mismatches Three rows pairing a mismatch condition with the symptom users see and the corrective action: a manifest published before its files, files published before their manifest, and a manifest belonging to a different application. WHEN MANIFEST AND FILES DISAGREE CONDITION SYMPTOM CORRECTION manifest ahead of files upload failed midway 404 on every hashed URL blank page, HTML is fine publish manifest last after upload succeeds files ahead of manifest cutover not done yet previous release served no user-visible error nothing — this is safe it is the desired order manifest from another app shared bucket prefix wrong bundle loads runtime errors, no 404 namespace the key one path per app Only the middle row is benign — and it is benign precisely because the manifest is published last.
Publishing the manifest strictly after the files it names converts the dangerous ordering into the harmless one.

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.