A hand-welded hydraulic press in a metal fabrication shop, with the words 'Build step' crossed out overlaid on the image

Why Server-Rendered Templates Don’t Need a Build Step

This stack has no build step. Not Deno, not Fresh, not anything purpose-built for the idea: plain Node, Express, and EJS. Deploying is push source, restart the process. There is no npm run build, no bundler, no compiled output sitting between the code being edited and the code running in production.

The whole of package.json‘s script block:

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

One devDependency, nodemon, and it only watches files for restarts. That setup is closer to how the web worked before build tooling became a default assumption, and it still works on an ordinary Node process serving a content site with real traffic.

Rundown

  • A build step is a set of code transformations, compiling, minifying, bundling, code-splitting, that happens before a request reaches a user. The design question is not whether transformation happens, but whether it happens ahead of time in CI or just-in-time on request.
  • EJS compiles each template to a JavaScript function on first render and caches it by filename. The chain that enables that caching runs through three files and is worth knowing exactly, because it is conditional.
  • The condition is NODE_ENV=production. In development, templates recompile on every request by design, which is what makes editing feel instant.
  • Deploying means restarting a process. That removes the class of bugs where a deployed artifact does not match the source, because no intermediate artifact exists.
  • The dependency tree is 292 packages against 652 for the Next.js application this replaced, which is the maintenance argument in one number.
  • The real cost is cache busting. Without a build step, static assets keep stable filenames, so they cannot be cached immutably without risking staleness, and this site’s own CSS is served max-age=0 as a result. That is fixable without a bundler.
  • Prior art exists and predates this stack: Deno’s team made a related case in 2023, and “manual ’till it hurts” has been an indie web practice for years.

What a build step does

A build step is any set of code transformations applied before a request reaches a user:

  • Compiling turns JSX, TypeScript, or other non-browser syntax into plain JavaScript.
  • Minifying strips and renames code to ship fewer bytes.
  • Bundling walks a dependency graph and packages what an entry point needs into one or more files.
  • Code-splitting chunks that bundle so a page loads only what it uses.

Each solves a real problem for a specific kind of application: one built around a component tree and a client-side runtime that needs matching code shipped to the browser.

Serving HTML requires none of it. The question is whether those transformations happen once, ahead of time, in a separate pipeline, or on request, cheaply enough that repeating them does not matter.

Andy Jiang put the distinction cleanly in Deno’s 2023 piece on the subject: whether transformation is “a separate step that takes minutes and happens in CI/CD” or happens just-in-time on request depends on the stack.

What EJS compiles, and when

The claim that EJS “compiles at request time and caches” is true, and the mechanism is more specific than that summary suggests. It runs through three files, and the caching is conditional on one environment variable.

Express decides whether caching is on. In express/lib/application.js, the default configuration enables the view cache only in production:

if (env === 'production') {
  this.enable('view cache');
}

Express passes that decision to the template engine. In the same file, the render path sets the flag EJS reads:

if (renderOptions.cache == null) {
  renderOptions.cache = this.enabled('view cache');
}

EJS acts on it. In ejs/lib/esm/ejs.js, the handler checks the cache by filename, compiles on a miss, and stores the compiled function:

if (options.cache) {
  func = ejs.cache.get(filename);
  if (func) {
    return func;
  }
  // …read the file…
}
func = ejs.compile(template, options);
if (options.cache) {
  ejs.cache.set(filename, func);
}

So in production, each template is read from disk and compiled to a function exactly once, on the first request that touches it, then served from an in-memory map keyed by filename for the life of the process. The versions here are Express 5.2.1 and EJS 6.0.1, and NODE_ENV: 'production' is set in the PM2 config.

In development the flag is off, so every request recompiles. The default is correct, and it is the reason editing a template and refreshing shows the change with no restart. It also means anyone benchmarking template rendering on a dev server is measuring compilation they will never pay for in production.

The compilation itself is not a build step in any meaningful sense: there is no dependency graph to walk, no output written to disk, no artifact to deploy, and nothing shipped to the browser. It is a function being memoized.

What the just-in-time compilation costs

The argument rests on that compilation being cheap enough to do on demand, which is worth measuring rather than asserting. Compiling every template in this project:

import ejs from 'ejs';
const t = process.hrtime.bigint();
ejs.compile(readFileSync(file, 'utf8'), { filename: file });
const ms = Number(process.hrtime.bigint() - t) / 1e6;
MeasureValue
Templates58
Total template source415,880 B
Total compile time17.3 ms
Mean per template0.30 ms
Slowest single template4.4 ms

17.3 milliseconds to compile the entire view layer. And no request pays that total, because each template compiles only when first rendered: the first visitor to a page absorbs a fraction of a millisecond, and nobody pays it again until the process restarts.

Seventeen milliseconds is the whole quantity being moved from ahead-of-time to just-in-time. Set against the ahead-of-time alternative Deno’s piece describes, a separate CI phase measured in minutes, this costs a third of the time budget for a single animation frame, once, spread across whichever pages get visited.

The asymmetry is what makes the tradeoff lopsided for this shape of site. Ahead-of-time compilation earns its cost when the work is expensive, when it produces something the browser needs, or when it catches errors before deploy. Here it is none of the three.

Verifying the cache in your own app

Two checks confirm the behavior rather than assuming it.

Whether the view cache is enabled at runtime:

app.get('/_debug/cache', (req, res) => {
  res.json({
    env: app.get('env'),
    viewCache: app.enabled('view cache'),
  });
});

app.get('env') reads NODE_ENV and falls back to development, which is the trap: a process started without NODE_ENV=production recompiles every template on every request, silently. Nothing breaks, it is just slower under load than it needs to be, and no error surfaces.

What is in the cache after some traffic:

import ejs from 'ejs';
console.log(Object.keys(ejs.cache._data ?? {}));

That prints the filenames compiled so far, keyed by absolute path. The list grows as pages get visited and then stops. An empty list on a warm production process means the cache is off.

Two caveats on that second snippet. _data is an internal, so treat it as a debugging aid rather than an API. And entries only appear via the render path Express uses; calling ejs.compile() directly bypasses the cache entirely, which is why the timing code above measures compilation without polluting it.

What deploying looks like without one

Push source, restart the process. That is the entire sequence.

The absence worth naming is not speed. It is a category of bug that stops existing. When a deployed artifact is generated from source by a pipeline, the artifact and the source can disagree: a cached build layer, a stale output directory, a CI step that silently skipped, a bundler that resolved a different version than expected. Debugging that means proving which copy of the code is the one running.

With no intermediate artifact, the file on disk is the file executing. A template renders from the same bytes sitting in the repository.

“Restart the process” also understates what the restart is. Under PM2 in cluster mode, it is a rolling reload: new workers start, signal readiness, and only then does traffic move off the old ones.

instances: 'max',
exec_mode: 'cluster',
wait_ready: true,
listen_timeout: 10000,
kill_timeout: 5000,

wait_ready holds traffic on the old workers until each new one calls process.send('ready') after its listener is bound, and kill_timeout gives the outgoing workers five seconds to finish in-flight requests. So a deploy is zero-downtime without a build artifact, a blue-green environment, or a deployment platform. The unit being swapped is a process, and processes are cheap to start when nothing has to be compiled first.

The maintenance side shows up in the dependency tree. This application resolves 292 packages in its lockfile, from 29 runtime dependencies and one development dependency. The Next.js application it replaced resolves 652, from 32 runtime and 12 development dependencies. Every one of those is a package that can publish a breaking change, need a security patch, or stop being maintained.

Jeremy Keith makes the same point at the practical level in his argument for buildless projects in “Manual ’till it hurts” from September 2024. He runs the Clearleft podcast site, Patterns Day, UX London, and a browser support page this way, and the payoff he names is what happens on return: “there’ll be no faffing about with npm updates, installs, or vulnerabilities.” The idea has a longer history in the indie web community as manual until it hurts, which is the more general principle: do it by hand, automate only when the manual version stops working, and notice how often that never arrives.

The cost: cache busting

The honest downside of having no build step shows up in HTTP headers, and this site demonstrates it.

Templates reference stylesheets and scripts by stable path:

<link rel="stylesheet" href="/css/main.css" />
<script src="/js/main.js"></script>

A bundler would emit main.a3f9c2.css and rewrite that reference, with the hash derived from the file’s contents. Because the URL changes whenever the content does, the file can be cached forever with no staleness risk. That is what Next.js was doing automatically here before the migration.

Without it, a stable URL cannot be cached aggressively without the risk that a returning visitor gets last month’s stylesheet. The result, live:

AssetCache-Control
/css/main.csspublic, max-age=0
/js/main.jspublic, max-age=0
/vendor/alpinejs/cdn.min.jspublic, max-age=31536000, immutable

The vendor file gets a year because it comes from a version-pinned npm package, so its effective identity changes when the version does. The site’s own CSS revalidates on every visit.

This does not require a bundler to fix. Computing a content hash at boot and appending it as a query string gets the same guarantee:

import { createHash } from 'crypto';
import { readFileSync } from 'fs';

const assetHash = (p) =>
  createHash('sha1').update(readFileSync(join(__dirname, 'public', p))).digest('hex').slice(0, 8);

app.locals.v = { css: assetHash('css/main.css'), js: assetHash('js/main.js') };
<link rel="stylesheet" href="/css/main.css?v=<%= v.css %>" />

Serve those paths with a long max-age and the URL changes whenever the content does, which is the entire mechanism a bundler provides for this. It runs once at startup rather than in a pipeline, which keeps it in the just-in-time category.

Worth being clear that this is a real gap and it went unnoticed here for months. “No build step” removes tooling, and some of that tooling was doing something.

Prior art

Deno’s team published You Don’t Need a Build Step in March 2023, using Next.js’s own blog-starter template as a worked example of accumulated build complexity, and offering Fresh as the answer. Their framing of the real question as ahead-of-time versus just-in-time transformation is the cleanest statement of the principle.

Their honesty about Fresh is worth carrying over. Fresh has no build step, no bundling and no transpiling as a separate phase, but transformation still happens: the Deno runtime transpiles TypeScript and TSX just-in-time on request. The claim is not that transformation disappears. It is that it moves.

EJS on Node lands in the same category by a different route. Deno’s version is a runtime feature; this one is server-side templating on a runtime that never needed to transpile the language it was already running. The destination is the same, and the piece is cited here as prior art rather than as the source of this setup.

When a build step is the right call

Real reasons to keep one:

  • A client bundle that needs splitting. An application shipping enough JavaScript that initial load suffers without route-level chunks.
  • An asset pipeline. Image transcoding, responsive variants, sprite generation, critical CSS extraction.
  • TypeScript enforced as a gate. Type checking in CI is a build step, and wanting it is a legitimate reason to have one.
  • Anything with a compile-to-JS language. JSX, Svelte components, Vue SFCs. The syntax has to become JavaScript somewhere.

A content-driven site rendering server-side templates with small, scattered interactivity fits none of those. The reason so little remains for a build step to do here is that the client bundle it would produce does not exist: interactivity is a 16.7kB Alpine file loaded directly from node_modules, and there is no hydration payload because nothing hydrates.

There is also no TypeScript in this stack. It ran during the earlier Next.js version of the site and was dropped as part of the migration, a related decision rather than a consequence of this one. TypeScript runs perfectly well in just-in-time setups elsewhere, including the Deno one described above.

Related reading

The build step disappeared because the things needing one left first. Alpine.js replacing React removed the client bundle a bundler would otherwise produce, and React SSR blocking Node’s event loop covers the hydration payload EJS never generates.

The migration itself was driven by problems unrelated to tooling: unbounded memory growth from caching dynamic routes and social embeds breaking on client-side navigation.

RSS Feed Newsletter
Contact us

Latest Blog Posts