Pug Compilation Architecture: Compile Once, Render Fast with Safe Data Boundaries
Pug turns templates into cached JavaScript functions. This architecture note covers the minimal render design, escaping trust boundaries, cache operations, failure modes, and when to add file watchers for dynamic templates.
07 Sept 2025, 16:49 UTC

The problem with parsing on every request
Server-side rendering with Pug looks simple until request volume grows. Parsing Pug syntax on each request adds CPU and allocation overhead for work that never changes. The useful takeaway is to separate the one-time compile step from the per-request render step, and to treat data injection as a trust boundary with default escaping.
Requirements for server-side HTML generation
An architecture needs to turn a stable template into HTML quickly, accept a data object per request, and prevent untrusted input from becoming executable markup. The pipeline must fail fast on authoring errors and remain observable under load. Memory use should scale with the number of distinct templates, not request count.
Smallest suitable design
Pug uses a two-stage pipeline. A lexer/parser converts Pug syntax into an Abstract Syntax Tree. The AST is then compiled into a JavaScript function. That function accepts locals and returns a string.
The minimal runtime is: compile once, cache the function, call the function per request.
// Node.js server startup, read permission required for template files
const pug = require('pug');
const renderUser = pug.compileFile('views/user.pug', { cache: true });
// renderUser is a function: (locals) => string
// Per request:
const html = renderUser({ name: user.name });
With cache enabled the compile step runs at startup or on first use. Subsequent renders skip parsing and AST generation. The design avoids repeated parsing of the same template and keeps per-request cost to function invocation and string concatenation.
Limitation: the cache holds a compiled function per template in memory. Environments with thousands of unique dynamic templates can see increased memory consumption.
How to check the result
Verify the compiled artifact is a function and that rendering is repeatable.
console.log(typeof renderUser); // expected 'function'
const out1 = renderUser({ name: 'A' });
const out2 = renderUser({ name: 'A' });
Inspecting the compiled output via Pug's internal API shows the transformation from AST to JS. A practical check for performance difference is to compare render time of a pre-compiled function versus calling pug.compile on every request in a load test harness.
Trust and data boundaries
The trust boundary is at interpolation. By default Pug escapes interpolated values with HTML entities to prevent Cross-Site Scripting.
// views/user.pug
p= user.name
Passing a string containing HTML tags should render as literal text, not markup, under default escaping. The unescaped operator !{ } bypasses this boundary.
p!= user.bio
Using !{ } requires the caller to guarantee the value is safe. If input is not sanitized, this introduces XSS vulnerabilities. Treat !{ } as an explicit opt-out of the security boundary and audit its use.
Operational checks
Operational health centers on the cache and compilation lifecycle.
- Cache hit rate for compiled templates. Misses indicate cold starts or cache eviction.
- Memory footprint of the template cache. Monitor process RSS growth as new templates are loaded.
- Startup compilation errors. Compile templates at boot so syntax errors surface before traffic.
Example startup guard:
try {
pug.compileFile('views/index.pug');
} catch (err) {
// fail fast, do not start server
process.exit(1);
}
Risk: catching errors only at render time turns a template authoring mistake into 500 errors for users.
Failure modes
Syntax errors during compilation are the primary failure mode. They occur when the lexer/parser encounters invalid Pug. If compilation is deferred to first request without a try-catch, the first user triggers a 500.
Data shape mismatches do not crash Pug but produce empty output or 'undefined' strings. Validate locals at the boundary of the render call if the template assumes specific fields.
Cache poisoning or stale templates occur if the file system changes but the in-memory cache is not invalidated. With cache: true, changes to source files are invisible until restart.
When the design changes
The compile-once, cache-forever design assumes templates are static between deploys. If real-time, dynamic template modification without server restarts is required, the design must change.
Conditions that change the design:
- Editors or CMS users modify templates at runtime.
- Multi-tenant systems load per-tenant templates on demand.
- A/B tests require hot-swapping templates.
In those cases add a file-watcher or explicit cache-invalidation strategy, with versioned cache keys and a reload path that recompiles safely. This adds complexity: race conditions during recompile, increased I/O, and the need to drain in-flight renders.
For the common case of deploy-time templates, the smallest suitable design remains compile once to a JavaScript function, cache it, render with escaped locals, and fail fast on compilation errors.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.