DEV Community

Cover image for ESLint Ships in 459 KB, So My Blog Posts Lint Your Code
Ofri Peretz
Ofri Peretz

Posted on Edited on Originally published at ofriperetz.dev

ESLint Ships in 459 KB, So My Blog Posts Lint Your Code

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
};
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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"));
};
Enter fullscreen mode Exit fullscreen mode

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   # 470563
Enter fullscreen mode Exit fullscreen mode

Those 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.

Top comments (0)