jst 0.1.0: Rewriting My First npm Package After 15 Years

· 10 min read

A few weeks ago, I published a retrospective on jst, the very first npm package I shipped back in April 2011 during the Node v0.4 era. At the time, I framed it as digital archaeology. The package had been parked at version 0.0.13 for fifteen years, I had long since handed maintainership over to someone else, and I even concluded the post with a self-answering FAQ:

Why not rewrite it into a modern version?
Because the problem has already been solved. Template engines were swallowed by framework internals, JSX, and single-file components. Writing another one makes no sense.

Well, a few weeks later, I went ahead and rewrote it anyway.

I reached out to nick_d2 (who had been holding the package on npm), reclaimed maintainership, dusted off the repository on GitHub (shaunlee/node-jst), and spent the weekend taking the engine apart down to the bare metal. Today, jst 0.1.0 is live on npm.

Why revisit a fifteen-year-old template engine?

Not because the JavaScript ecosystem is crying out for another server-side view library, but because returning to code you wrote fifteen years ago with fifteen years of architecture, compiler, and runtime experience is an irresistible exercise in craftsmanship. It poses a simple question: If you strip away the historical cruft and apply modern runtime knowledge and strict zero-dependency discipline to a string-to-function template engine, how fast can it actually run?

The answer: across Node 26 and Bun 1.4, over 540,000 ops/sec on a real page. That is up to 19x faster than 0.0.13, comfortably ahead of Handlebars and EJS, and faster than doT in typical web workloads.


Benchmarks: Measuring Honestly

Template engine benchmarks are notoriously easy to fake by accident. Two traps in particular will quietly ruin your numbers if you don't defend against them:

  1. JavaScriptCore loop hoisting: When given a constant context object, JavaScriptCore's optimizer will hoist the entire template render straight out of the timing loop. One early run on Bun clocked an absurd 1.9 billion ops/s before the benchmark harness was updated to mutate data.title = i on every single iteration.
  2. V8 cons-string laziness: String concatenation (out += ...) constructs a cons-string tree in memory; nothing gets flattened or copied until something actually inspects the characters. A benchmark that simply discards the return value measures memory allocation rather than template rendering. The harness reads out.charCodeAt(out.length - 1) on every pass to force string evaluation.

The suite runs via npm run bench (or bun bench/index.js). It measures a 5KB page rendering 20 rows through an HTML-escaping filter, taking the median of three runs of best-of-seven rounds on the same hardware (Node 26.8 and Bun 1.4.1):

1. Typical web workload (1 row in 10 containing HTML markup)

Engine Node 26 Bun 1.4 Runtime Deps
jst 0.1.0 544k ops/s 553k ops/s 0
doT 1.1.3 321k ops/s 466k ops/s 0
Handlebars 4.7.9 263k ops/s 252k ops/s 14
EJS 6.0.1 56k ops/s 95k ops/s 0

2. Worst-case escaping (every single row contains <>&"')

Engine Node 26 Bun 1.4
doT 1.1.3 129k ops/s 182k ops/s
jst 0.1.0 123k ops/s 178k ops/s
Handlebars 4.7.9 100k ops/s 122k ops/s
EJS 6.0.1 43k ops/s 72k ops/s

3. Direct comparison against jst 0.0.13

Template variable style Node 26 (0.0.13 → 0.1.0) Speedup Bun 1.4 (0.0.13 → 0.1.0) Speedup
it. in interpolation tags 126k → 470k ops/s 3.7x 136k → 543k ops/s 4.0x
it. only inside {% %} blocks 75k → 1,442k ops/s 19.2x 103k → 1,979k ops/s 19.2x
Bare identifiers ({{ name }}) 48k → 66k ops/s 1.4x 72k → 118k ops/s 1.6x

The Five Architectural Changes

Where did that 19x speedup come from? When inspecting the 2011 codebase, it was immediately clear that 0.0.13 was plagued by the naive assumptions of early Node. 0.1.0 replaces those historical shortcuts with five deliberate engineering decisions.

1. Static scope analysis: Eliminating with(it)

The with statement is kryptonite to JavaScript runtimes. V8's Turbofan and Crankshaft cannot optimize through a with block; Inline Caches (ICs) are disabled, property lookups become dynamic, and function inlining grinds to a halt. On small templates, wrapping the render body in with(it) costs anywhere from 8x to 30x.

In 2011, jst 0.0.13 attempted to detect whether a template used the it. prefix via a trivial regular expression: /{{ (e\()?it\./. But the moment a template referenced it. inside a loop ({% for (var i = 0; i < it.rows.length; i++) %}) or inside a function argument ({{ f(it.name) }}), the regex missed it completely and dumped the template right back onto the slow with(it) path. That was why loop-heavy templates crawled at 75k ops/s.

In 0.1.0, I built a lightweight tokenizer and static scope analyzer (lib/scope.js, ~110 lines of vanilla JavaScript):

  • It scans expressions and control blocks, safely skipping string literals, comments, and member property accesses (.foo);
  • It tracks all local declarations: var, let, const, function parameters, and catch (e) bindings;
  • It ignores standard JavaScript language keywords, control-flow statements, and built-in runtime globals (Math, JSON, Date, console, Array, Object, etc.);
  • It collects any remaining free identifiers.

If every data reference in the template is explicitly prefixed with it. and no undeclared free variables exist, the compiler emits a plain, naked function body with zero with(it) overhead:

// Emitted function body (fast path — no with):
function(it) {
  it = it || {};
  var out = "<title>" + it.title + "</title>...";
  for (var i = 0; i < it.rows.length; i++) {
    out += "<li>" + filters.e(it.rows[i]) + "</li>";
  }
  return out;
}

Whenever the scope analyzer encounters an ambiguous identifier—such as globals injected via Express's app.locals or bare variables—it conservatively falls back to with(it). Safety and 100% backward compatibility come first; only provably safe templates take the fast path.

2. The escaping fast-path: Skip replacements when there is nothing to escape

In real-world HTML rendering, the vast majority of string values do not contain <>&"'. Despite this, standard template engines run str.replace(/[&<>"']/g, ...) unconditionally on every interpolated value, instantiating regex engines and creating string allocations on every call.

jst 0.1.0 introduces a cheap pre-test in lib/filters.js:

const htmltestre = /[&<>"']/;

function escape(src) {
  if (typeof src !== 'string' || !htmltestre.test(src)) return src;
  return src.replace(htmlre, htmlEscape);
}

A quick RegExp.test() check determines whether any of the five forbidden characters exist. If none do, it immediately returns the original string reference by pointer. Zero allocations, zero replacement passes.

When rendering 20 rows with no markup, this single shortcut boosted throughput from 659k to 1,701k ops/s (2.58x). Even when one in ten rows does need escaping, it delivers a 1.69x improvement over blind replacements.

3. Caching compiled functions instead of hashing templates

jst 0.0.13 had an astonishing historical bottleneck in render(): on every single invocation, it computed an MD5 hash across the entire template string just to produce a cache key:

// The 2011 bottleneck:
var hash = crypto.createHash('md5').update(str).digest('hex');

If you rendered a 50KB template on an incoming HTTP request, you spent CPU cycles hashing 50KB of memory every single time—frequently doing more work than the render itself!

In 0.1.0:

  1. The template string is the cache key: V8 already caches string hashes internally and compares string identities by pointer in the common case.
  2. Bounded Map with LRU eviction: 0.0.13 used a plain object that grew unboundedly if templates were assembled at runtime. 0.1.0 uses a bounded Map (cacheLimit: 1000 by default) that evicts in insertion order. In microbenchmarks, lookup throughput on this bounded map reaches 175 million operations per second.
  3. Skipping stat() in production: In development, renderFile() checks mtime to detect changes. In production, calling jst.configure({ cache: true }) bypasses filesystem stat() calls entirely and executes the cached function directly.

4. Segment pass compiler and precision diagnostics

The original compiler stripped all whitespace and newlines from the template up front. That created three stubborn defects:

  • Content inside <pre> and <textarea> tags was collapsed alongside layout markup;
  • Single-line // comments in {% %} blocks commented out subsequent generated statements because everything collapsed onto one line;
  • Code blocks spanning multiple lines failed to compile.

0.1.0 rewrites compilation as a single Segment Pass:

  • The template is parsed into text, code, and comment segments;
  • Text minification tracks <pre> and <textarea> nesting depth, preserving verbatim formatting inside them;
  • Code blocks retain surrounding newlines, so comments terminate safely;
  • Accurate syntax error diagnostics (lib/errors.js): If a template contains a syntax error (such as an unclosed bracket), jst pinpoints the exact file, line number, column, and displays a source snippet with a caret pointer:
jst: unbalanced "(" in this tag
  at views/index.jst:2:1

  1 | <div>
  2 |   {% if ( %}
    |   ^
  3 | </div>

Additionally, 0.1.0 enforces coding standards at compile time: {{ value }} is valid syntax, but {{value}} is rejected with an explicit compile error rather than silently slipping through to the HTML response.

5. Synchronous view composition (renderFileSync) and zero runtime dependencies

When rendering views on a server, composing layouts and partials happens synchronously inside the template execution flow. 0.0.13 only supported asynchronous renderFile(path, callback), forcing developers to manually read files and invoke render(), bypassing file-level caching.

0.1.0 adds renderFileSync(filename, args), which shares the same compiled function cache and respects configure({ cache: true }).

Finally, I dropped commander and wrote a 25-line argument parser for the CLI. jst 0.1.0 has zero runtime dependencies. Installing it on Node 18+ or Bun pulls down zero third-party packages.


Quick Start

Installation

npm install jst

Basic Usage

const jst = require('jst');

// Render string (it. prefix unlocks the fast path)
const html = jst.render('Hello {{ it.name|e }}', { name: '<b>World</b>' });

// Compile once, call many times
const template = jst.compile('Count: {{ it.count }}');
console.log(template({ count: 42 }));

// Render file asynchronously
jst.renderFile('views/index.jst', { title: 'Home' }, (err, out) => {
  if (!err) console.log(out);
});

// Production tuning: skip stat() calls and cap cache entries
jst.configure({ cache: true, cacheLimit: 2000 });

Express 5 Integration

const express = require('express');
const jst = require('jst');
const app = express();

app.engine('jst', jst.renderFile);
app.set('view engine', 'jst');
app.set('views', './views');

if (app.get('env') === 'production') {
  jst.configure({ cache: true });
}

app.get('/', (req, res) => {
  res.render('index', { user: { name: 'Shaun' } });
});

app.listen(3000);

Standalone CLI Pre-compilation

To pre-compile templates for browser bundles or build pipelines:

npx jst views/index.jst > dist/templates.js

Fifteen Years Later

In software engineering, we often chase complex architectures, massive framework abstractions, and ever-expanding dependency trees. But there is a distinct satisfaction in revisiting code you wrote fifteen years ago, throwing away the obsolete compromises, and bringing modern craftsmanship to bear on the core problem:

  • The entire library remains under a few hundred lines;
  • No dependencies were added to "modernize" it—runtime dependencies were reduced to zero;
  • Performance is no longer a 2011 marketing claim, but a verified 540k+ ops/sec under hardened, trap-free benchmarks.

What was once a piece of digital archaeology has become a clean, tiny, and blazingly fast tool again.