Every article about a lint rule has the same hole in it.
I paste a snippet, I paste the finding it produces. You think: fine, but does it fire on my code? The article cannot answer — it can only ever show you someone else's code.
So I shipped the linter instead. On the JWT article there is now a Try it live button: paste your own jwt.verify call and the published rule runs on it, in your browser.
The reason is a number I did not expect.
ESLint ships in 459 KB
eslint/universal exports the Linter class with no Node dependencies in its public surface: you hand it source text and a flat config, it hands you messages back.
It is a hard floor, not a polyfill: the ./universal export first appears in ESLint 9.11.0. ESLint 9.10 and every ESLint 8 resolve it to nothing, so this recipe does not degrade on older majors — it fails at build time.
With two real security plugins bundled inside it:
| bytes | |
|---|---|
| raw bundle | 1,764,382 |
brotli -q 11 locally | 370,746 |
| what the CDN actually sends | 470,563 |
Quote the last row, not the second. I first published 362 KB, having confirmed content-encoding: br — which proves the encoding, not the size. The CDN compresses on the fly below -q 11, so the honest number is 459 KB. Same artifact byte for byte; only the compressor differs.
Still the whole argument. At several megabytes you write a blog post about the rule. At 459 KB you ship the rule.
The recipe
The worker holds the linter and never talks to a server:
import { Linter } from "eslint/universal";
import jwt from "eslint-plugin-jwt";
const PLUGINS = { jwt }; // enumerated, never dynamic
const linter = new Linter();
self.onmessage = ({ data: { id, code, pluginId, rules } }) => {
const findings = linter.verify(code, {
plugins: { [pluginId]: PLUGINS[pluginId] },
languageOptions: { ecmaVersion: 2024, sourceType: "module" },
rules,
});
self.postMessage({ id, findings }); // no network, ever
};The build step is where the real work is — aliases and a banner:
buildSync({
entryPoints: ["src/workers/lint.worker.ts"],
outfile: "public/lint-worker.js",
bundle: true,
minify: true,
format: "iife",
platform: "browser",
// A worker global has no `process`. ESLint touches more of it than
// NODE_ENV — cwd, platform, emitWarning — so stub it in a banner.
banner: {
js:
'var process={env:{NODE_ENV:"production"},' +
'platform:"browser",cwd:function(){return "/"},argv:[]};',
},
alias: {
path: "path-browserify",
"node:path": "path-browserify",
fs: shims,
os: shims,
util: shims,
// If anything in your graph pulls oxc-resolver, its native
// bindings ride in and break the build. Rules never touch it.
"oxc-resolver": shims,
},
});shims is a no-op Proxy, imported but never exercised.
The client seam is a lazy worker. The part worth copying is the failure path:
worker.onerror = () => {
worker?.terminate();
worker = null; // ← this line
for (const entry of pending.values())
entry.reject(new Error("worker failed"));
};Nulling the singleton separates a playground that self-heals from one dead until reload. Without it every later lint posts into a corpse and the UI shows a clean result forever: "no findings" and "the analyzer is dead" look identical. So the surface renders "unknown, not clean" on failure, never an empty list.
Three traps
Don't let your framework bundle it. Asking Next.js to build the worker means teaching both webpack and Turbopack about node: schemes: two fragile configs for one artifact. Use esbuild yourself.
A real worker has no process. My spike ran under Node so ReferenceError: process is not defined only appeared in the browser. A spike passing is not the thing working.
Linting the artifact will OOM your editor. ESLint tried to lint the 1.7 MB bundle and died with Abort trap: 6: heap exhaustion, not a native crash. Add public/** to globalIgnores.
Try it
Both run published npm packages, the tarballs npm install gives you:
- JWT
alg:none— remove"none"from the algorithms array, watch the finding clear. - node-security — three rules on an upload handler.
Nothing you type leaves the page — and the UI says so in as many words, because pasting auth code into a stranger's site deserves an explicit answer rather than an assumption.
Reproduce it
Measured 2026-09-03 against eslint 9.39.4, eslint-plugin-jwt 2.2.14, eslint-plugin-node-security 5.2.3, esbuild 0.28.2, on Node 24.18. The build step itself inherits ESLint's floor, ^18.18.0 || ^20.9.0 || >=21.1.0 — there is no Oxlint variant of this recipe, because eslint/universal is ESLint's own export.
# from the repo root
node apps/blog/scripts/build-lint-worker.mjs
wc -c < apps/blog/public/lint-worker.js # 1764382
brotli -q 11 -c apps/blog/public/lint-worker.js | wc -c # 370746
# what a reader actually downloads — measure this one, not the line above
curl -s -H 'Accept-Encoding: br' https://ofriperetz.dev/lint-worker.js | wc -c # 470563Those bytes drift as plugins ship rules; quote them with a date.
Browser-hosted linting is not new; ESLint and typescript-eslint both run excellent playgrounds. What is unusual is placement: not a destination you navigate to, but the rule the paragraph is arguing, running on your code, at the moment you wonder about it.
⭐ Star the repo if you have ever wanted docs that run instead of assert.
