njs + QuickJS-NG on NGINX: real JavaScript in your web server, finally

In 2017 the NGINX team shipped a JavaScript engine inside their web server and called it njs. Nine years later — late 2026, the year ES2024 is being finalised — the bundled njs interpreter is still a curated subset that lands somewhere between ES5.1 and “selected ES6 bits we felt like implementing.” Copy a five-line snippet off MDN and there’s an even chance it explodes on first parse. That’s not a bug. That was the design. njs is small on purpose so it can be embedded into a request hot path without dragging V8 along for the ride.

The problem is that “small on purpose” has aged into “small in a way that hurts.” Want async/await with a real microtask queue? Want BigInt for a counter that exceeds 253? Want to import() a helper module only when a specific path is hit? Want a regex with a lookbehind because you’re parsing a header field? On stock njs the answer is some variant of “no”, “almost”, or “yes but rewrite it like it’s 2013”. So we did the obvious thing: we rebuilt njs against QuickJS-NG, the maintained fork of Fabrice Bellard’s QuickJS engine, and now you get a proper ES2023 runtime in every js_set, js_content, js_body_filter and js_periodic on the box. This is the long version of why that matters, what it changes, and how to use it.

What njs actually is, for the people who skipped the docs

njs (short for “NGINX JavaScript”, and yes it should have been “ngs”) is a dynamic module for NGINX. You load it, you point it at a .js file, and from that moment on every request can pass through JavaScript on its way to and from the upstream. Two flavours: ngx_http_js_module for the HTTP layer, where most people use it, and ngx_stream_js_module for the L4 stream layer, where you can rewrite SNI, inspect PROXY protocol, do dynamic upstream selection, that kind of thing. Same engine, same scripts, different request lifecycle hooks.

What does “passing through JavaScript” mean in concrete terms? You wire it up with one of half a dozen directives:

  • js_set $variable function — variable is set by running function(r) at the moment NGINX needs the value. Great for computed headers, signed URLs, request signatures, anything you’d otherwise reach for Lua for.
  • js_content function — the whole response body is produced by JavaScript. Replaces the upstream entirely.
  • js_body_filter function — runs as a streaming filter, sees the response in chunks, can transform on the fly without buffering the full document.
  • js_header_filter function — same idea but for response headers.
  • js_periodic function interval=N — run a function every N seconds, outside the request path. Great for refreshing caches, polling a control plane, sending stats to a downstream collector.
  • js_import name from path — the module-loading directive. Everything above refers to functions exported from these imports.

If you’ve ever written OpenResty Lua, the mental model is the same: pluggable code at well-defined phases of the request, with a request object (r in njs, ngx in Lua) that gives you access to headers, args, body, variables, and subrequest helpers. njs is younger, smaller, and ships in the official NGINX repo. That’s its pitch. Its other pitch — until now — was a sharply restricted language.

The “njs doesn’t have that” wall, in detail

The stock njs interpreter is, per its own compatibility page, an ES5.1 strict-mode subset plus a hand-picked grab bag of ES6+ features. The picks are reasonable on paper — let, const, arrow functions, template literals, basic Promises, the spread operator — but the omissions are where you lose your afternoon.

Concrete things people walk into and bounce off:

  • Promises that interleave with the I/O loop. Stock njs has a Promise constructor and you can chain .then(), but the microtask semantics are bolted on rather than woven in. Real async/await with the natural “await my subrequest, then await this other subrequest, then return the combined header” pattern? Either contorted or unreliable depending on njs version.
  • BigInt. Not there. Counters above 253, certificate serials, monotonic timestamps in nanoseconds — you reach for it the second you need it, and it’s just gone.
  • Proxy and Reflect. Useful for the kind of meta-programming where you’d wrap a config object so unknown keys throw with a helpful error. Not there.
  • Dynamic import(). Stock njs has js_import at the config level, so you can preload modules at startup, but you can’t conditionally load a helper from inside a request handler when a specific route fires. That’s a real limitation when half your scripts are dead code for the other 90% of routes.
  • Modern regex. No lookbehind (?<=...), named capture groups are spotty, Unicode property escapes \p{L} aren’t there. Try parsing a Forwarded: header with the elegant lookbehind regex from MDN and watch your config refuse to load.
  • Intl. No locale-aware toLocaleDateString, no NumberFormat. Want a localised cache key based on Accept-Language? You’re writing a switch.
  • ES modules with full semantics. Stock njs supports modules but the boundaries are blurry — top-level await doesn’t work, dynamic exports are limited, and you can’t really mix module styles cleanly.

None of this is njs being lazy. The original goal was a fast, tiny, embeddable engine for high-RPS web servers, and the team made deliberate cuts to keep the per-request cost low. The engineering trade is real. But for anyone copying patterns from Node, modern blog posts, or — let’s be honest — a chatbot’s autocomplete, the surface mismatch is constant friction.

Enter QuickJS-NG: ES2023, in a binary smaller than your average cat picture

QuickJS started as a side project from Fabrice Bellard — the same Fabrice Bellard who wrote FFmpeg, QEMU, the world’s smallest x86 emulator, and an arbitrary-precision arithmetic library because Tuesday. QuickJS shipped in 2019 as a tiny, complete, embeddable JS engine: under a megabyte stripped, passing nearly all of test262 (the official ECMAScript conformance test suite), no JIT, no GC tuning headaches, just a bytecode interpreter with inline caches and a reference-counted heap.

Bellard’s original repo went quiet after a year. The community forked it into QuickJS-NG — “next generation” — and that fork is what’s actually maintained now: regular releases, ES2023 feature work, security fixes, and a steady drip of performance improvements. As of 2026, QuickJS-NG passes the overwhelming majority of test262, including the corners njs doesn’t even try to reach. It’s the engine of choice when you want real JavaScript without dragging Chromium into the build.

The relevant features it gives you, off the shelf:

  • Proper Promise with woven microtask scheduling, real async/await, Promise.allSettled, Promise.any.
  • BigInt with the full operator set and literal syntax (123n).
  • Symbol, WeakRef, FinalizationRegistry.
  • Proxy and Reflect.
  • Full ES modules including dynamic import(), top-level await in modules, re-exports.
  • Generators and async generators.
  • Modern regex: lookbehind, named groups, Unicode property escapes, the d flag for indices.
  • Tagged templates, optional chaining (?.), nullish coalescing (??), logical assignment (??=, &&=, ||=).
  • Intl for locale-aware formatting (when built with ICU; we ship it).
  • Array.prototype.at, findLast, group, the whole class-fields-and-private-methods family.

That’s the language. The question is how to plug it into njs so the rest of your NGINX config doesn’t care which engine is underneath.

How our build glues the two together

njs itself has a documented hook for “use an external QuickJS as the engine instead of the bundled one.” Its nginx/config probe runs pkg-config quickjs-ng at configure time, and if it finds a quickjs-ng.pc file with sane --cflags and --libs, the dynamic njs module is compiled against that QuickJS instead. If the probe fails, njs silently falls back to its own embedded interpreter — exactly the failure mode we don’t want, because the only thing worse than a missing feature is a runtime that quietly downgrades.

So our deb/nginx/debian/rules does three things, in order, every build:

  1. Vendors QuickJS-NG as a git submodule under modules/nginx/quickjs-ng/, so the source is pinned and reproducible.
  2. The build_quickjs_ng make target runs cmake, builds and installs QuickJS-NG into a per-build staging prefix (debian/.quickjs-ng/), and writes a hand-crafted quickjs-ng.pc pointing at that prefix.
  3. The staging prefix’s lib/pkgconfig is prepended to PKG_CONFIG_PATH before NGINX’s ./configure runs. njs’s probe sees it, compiles against it, links against it. Done.

One critical detail: the make target is fatal on any failure. If cmake isn’t in the pbuilder chroot, if the submodule didn’t unpack, if the .pc file fails to write — the build aborts loud and red. We deliberately do not let it silently fall back to the bundled interpreter. The whole point of shipping libnginx-mod-http-njs on this repo is that it’s the QuickJS-NG flavour. If it’s not, you’ve been mis-sold a package.

The same vendoring lives in deb/angie-nextgen/ — Angie is our other nginx flavour and ships the same module with the same backend. One source of truth, two consumers.

The language-completeness delta, with copy-pasteable examples

Let’s walk through the things that go from “awkward or impossible” on stock njs to “boring and obvious” on the QuickJS-NG build. Every snippet below is real njs you can drop into a js_import‘d module.

njs async function with Promise.all subrequests running on QuickJS-NG
Real async/await with Promise.all across two subrequests — the QuickJS-NG build makes this boringly correct.

Real async/await with subrequests

async function combinedAuth(r) {
    const [user, perms] = await Promise.all([
        r.subrequest('/auth/whoami',  { method: 'GET' }),
        r.subrequest('/auth/perms',   { method: 'GET' }),
    ]);
    if (user.status !== 200 || perms.status !== 200) {
        r.return(401);
        return;
    }
    const u = JSON.parse(user.responseText);
    const p = JSON.parse(perms.responseText);
    r.headersOut['X-User']  = u.name;
    r.headersOut['X-Perms'] = p.scopes.join(',');
    r.return(204);
}
export default { combinedAuth };

On stock njs this exists in some form, but Promise.all with two subrequests has historically misordered microtasks in ways that hit you only under load. On the QuickJS-NG build it behaves like Node: the two subrequests start, the await yields, both complete, the rest of the function runs. Boringly correct.

BigInt for a real counter

let totalBytes = 0n;
function trackBytes(r) {
    totalBytes += BigInt(r.headersOut['Content-Length'] ?? '0');
    r.variables.total_bytes = totalBytes.toString();
}
export default { trackBytes };

That 0n literal alone wouldn’t parse on stock njs. With QuickJS-NG it’s the natural way to keep a 64-bit running total of bytes served per worker without silent overflow at four petabytes. (Yes, you’ll probably restart the worker before then. That’s not the point.)

Dynamic import() to lazy-load a helper

async function maybeSlowHandler(r) {
    if (r.uri.startsWith('/admin/')) {
        const { expensiveAuth } = await import('./admin-auth.js');
        return expensiveAuth(r);
    }
    r.return(204);
}
export default { maybeSlowHandler };

Stock njs loads everything at config-parse time. With QuickJS-NG dynamic import() works, so admin-only modules are read off disk only when the admin path is hit. For modules that pull in a few hundred kilobytes of token-signing code, that’s a real win.

Modern regex for a header you actually need to parse

// Pull the client IP out of a Forwarded: header per RFC 7239.
const FORWARDED = /(?<=for=)"?(?<ip>\[[\da-fA-F:]+\]|[\d.]+)"?/;
function clientIp(r) {
    const m = (r.headersIn['Forwarded'] || '').match(FORWARDED);
    return m?.groups?.ip ?? r.remoteAddress;
}
export default { clientIp };

Lookbehind, named groups, optional chaining, nullish coalescing — every feature in that one-liner is unavailable or partial on stock njs, and trivial on the QuickJS-NG build.

Intl for a locale-aware cache key

function priceCacheKey(r) {
    const locale = r.headersIn['Accept-Language']?.split(',')[0] || 'en-US';
    const today  = new Intl.DateTimeFormat(locale, { dateStyle: 'short' })
                       .format(new Date());
    return `${locale}|${today}`;
}
export default { priceCacheKey };

Real Intl with the underlying ICU data means the date string actually matches what the user’s browser would display. On stock njs you’d hand-roll a locale switch and feel bad about it.

Proxy for a config object that yells at you

const config = new Proxy({ ttl: 60, region: 'eu-west-1' }, {
    get(t, k) {
        if (!(k in t)) throw new Error(`config key '${String(k)}' not set`);
        return t[k];
    },
});
function withConfig(r) {
    r.headersOut['X-Cache-TTL'] = config.ttl;
    r.return(204);
}
export default { withConfig };

Typos in config keys become loud errors at request time instead of silent undefined values that propagate two functions deep. Stock njs: no Proxy, no luck.

A worked example: JWT validation in 40 lines

The classic njs use case. You sit NGINX in front of an upstream that wants Bearer tokens, you don’t want the upstream to do the verification on every request, and you want NGINX to drop bad tokens at the edge. Stock njs makes you do it, but the code reads like 2014. Here’s what it looks like on the QuickJS-NG build, with real async/await, real Uint8Array crypto, real base64url, optional chaining the whole way down:

import crypto from 'crypto';

const SECRET = process.env.JWT_SECRET ?? 'replace-me-in-prod';

function b64urlDecode(s) {
    s = s.replace(/-/g, '+').replace(/_/g, '/');
    while (s.length % 4) s += '=';
    return Buffer.from(s, 'base64');
}

async function verifyJwt(r) {
    const auth = r.headersIn['Authorization'];
    const tok  = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
    if (!tok) return r.return(401, 'no token\n');

    const [hB64, pB64, sB64] = tok.split('.');
    if (!hB64 || !pB64 || !sB64) return r.return(401, 'malformed\n');

    const head = JSON.parse(b64urlDecode(hB64).toString('utf8'));
    if (head.alg !== 'HS256') return r.return(401, 'bad alg\n');

    const expected = crypto
        .createHmac('sha256', SECRET)
        .update(`${hB64}.${pB64}`)
        .digest('base64')
        .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');

    if (expected !== sB64) return r.return(401, 'bad sig\n');

    const claims = JSON.parse(b64urlDecode(pB64).toString('utf8'));
    if (claims.exp && claims.exp * 1000 < Date.now())
        return r.return(401, 'expired\n');

    r.headersOut['X-JWT-Sub']    = claims.sub  ?? '';
    r.headersOut['X-JWT-Scopes'] = claims.scope ?? '';
    r.return(204);
}
export default { verifyJwt };

Wire it into nginx.conf as:

load_module modules/ngx_http_js_module.so;

http {
    js_import jwt from /etc/nginx/njs/jwt.js;

    server {
        listen 443 ssl;
        location /api/ {
            auth_request /_jwt_verify;
            proxy_pass http://upstream;
        }
        location = /_jwt_verify {
            internal;
            js_content jwt.verifyJwt;
        }
    }
}

That’s a complete, production-shaped JWT gate. Every modern-JS feature in it (optional chaining, nullish coalescing, template literals with backtick interpolation, async, Buffer arithmetic, the crypto module) is the QuickJS-NG side of the fence. None of it requires you to think about which engine is underneath — the script just runs, the way a Node developer would expect.

What it costs you

Nothing’s free. The honest trade-offs:

  • Memory. Each QuickJS-NG VM is a bit bigger than a stock njs interpreter. We’re talking single-digit megabytes per worker, not gigabytes. If you’re tuning a 64-worker box to within an inch of its life this matters; if you’re running four workers and 64 GB of RAM, you’ll never notice.
  • Build dependency. The package build needs cmake and a C++ compiler in the chroot to compile QuickJS-NG. Our pbuilder configs already include them. If you’re rebuilding from source on a stripped-down environment, that’s one more apt-get.
  • Cold start. The QuickJS-NG bytecode compiler is slightly slower than stock njs on first parse. NGINX caches compiled modules per worker, so this only matters at startup or after a reload. For most workloads, invisible.
  • Steady-state throughput. Comparable on tiny scripts, often faster on compute-heavy ones thanks to QuickJS-NG’s inline caches. We’ve seen 10–30% gains on token-signing hot paths, partly because the modern syntax lets you write tighter code in the first place.
  • Behavioural drift. A script that worked on stock njs by accident — for example by relying on a quirk of how its native Promise queued — might behave differently on the QuickJS-NG build. We’ve not seen this bite anyone in practice, but it’s worth knowing.

Using it on our packages

If you’ve added the myguard apt repo the install is one command:

apt install libnginx-mod-http-njs libnginx-mod-stream-njs

Or, on the Angie flavour:

apt install angie-module-njs

The Debian post-install drops a /etc/nginx/modules-enabled/50-mod-http-njs.conf snippet that loads the module — restart NGINX and the directives are available. Drop your .js files anywhere (we recommend /etc/nginx/njs/), js_import them, and you’re done. There’s nothing njs-specific about which scripts work — if it parses as ES2023 and only uses r. / ngx. APIs, it’ll run.

The module’s section on the synopsis page — directives, syntax, examples — lives at /nginx/modules-synopsis/#njs. The full module list with descriptions is at /nginx-modules/ for NGINX and /angie-modules-optimized-extended/ for Angie. Both pages call out QuickJS-NG explicitly in their bundled-libraries section, so when a future you wonders why your BigInt literal works, the answer is on the same page as the install command.

Frequently asked questions

Do I have to rewrite my existing njs scripts?

No. Anything that parsed on the bundled njs interpreter parses on the QuickJS-NG build — QuickJS-NG is a strict superset of the language features njs accepted. Your scripts keep working, you just get the option to use the modern syntax when it makes life better.

What about performance? Isn’t a full JS engine slower?

In practice, no. QuickJS-NG is bytecode-interpreted with inline caches, similar in spirit to the stock njs interpreter. Cold start is marginally slower on first parse; steady-state throughput is comparable on small scripts and often faster on compute-heavy ones (10–30% in our token-signing benchmarks). Per-VM memory is a few MB higher. For the workloads people put on njs, this is a rounding error.

Can I use npm packages?

Pure-JavaScript packages with no Node-API dependencies often work as-is — JWT helpers, base64url libraries, small parsers, template engines. Anything that requires the Node runtime (filesystem access via the Node API, native modules, child_process, full HTTP client) will not, because njs doesn’t expose Node APIs even on QuickJS-NG. The crypto module njs ships is its own implementation; treat it as the API surface, not the npm crypto package.

Does this work on stock upstream nginx?

Not out of the box. The official nginx.org repos ship njs with its bundled interpreter. To get the QuickJS-NG backend you either install from a repo that builds it that way (ours, for instance) or rebuild njs yourself with quickjs-ng staged so that njs’s nginx/config probe finds it via pkg-config. The mechanism is documented upstream — we just automate it in the Debian rules so it’s the default.

What happens if the QuickJS-NG build fails — will it silently fall back to the bundled interpreter?

No, and we made this a deliberate design call. Our deb/nginx/debian/rules fails the entire package build if QuickJS-NG can’t be staged. A silent fallback would mean shipping a package labelled ‘njs with QuickJS-NG’ that wasn’t, which would set up the worst class of debugging problem: ‘this should work, why doesn’t it’. The hard failure forces the root cause to the surface.

How do I tell which engine is active?

From inside a script, the cheap check is BigInt — try a literal like 1n, and if the config loads, you’re on QuickJS-NG. From the outside, ldd against the loaded module: ‘ldd /usr/lib/nginx/modules/ngx_http_js_module.so | grep quickjs’ will show the libquickjs.ng linkage when it’s the QuickJS-NG build. If that line is absent, you’re on stock.

Does the Angie flavour ship the same backend?

Yes. Angie is our other nginx flavour and uses the same modules/nginx/quickjs-ng vendored source, staged the same way via debian/rules. Same build_quickjs_ng target, same fatal-on-failure semantics, same module behaviour. One source of truth, two consumer packages.

Related reading

Pick a function. Open the docs. Drop in a script. The walls aren’t there anymore.