Measured, not claimed

Benchmarks

Every figure below is generated by a script in this repository and read back by CI, and the losses are in the same tables as the wins.

Bundle floor, gzipped
8,799 B
Builder with zero plugins
3 fields, 6 plugins
2,444,308
validate ops/sec on the machine below
Dynamic code
0 occurrences
across 874 emitted .js / .mjs files

Read this first

  • This release is slower than 1.x on flat and nested shapes. Measured side by side in one process: ×0.21 on one field, ×0.79 on three, ×0.83 nested. The two wins are arrays (×4.3) and JSON Schema (×2.1). The table is below.
  • One validator was alive for every figure here. Real applications hold many, and a shared runtime behaves differently once its call sites have seen several. Whether that moves these numbers is not something this page answers, so read them as the one-validator case rather than as a per-call cost in an application.

Bundle size

The ceiling is the gate; the recorded figure is the evidence behind it. Bundler, options, Node version and date.

EntrygzipCI ceiling1.x, same method
core-onlyBuilder alone. The floor a reader pays before importing a single plugin.8,799 B8,870 B17,423 B
one-pluginBuilder + required. The point that shows what one plugin costs.8,935 B9,017 B—
three-pluginBuilder + required / stringMin / numberMin. The same set the barrel-equivalence check uses.9,261 B9,340 B—
six-pluginBuilder + the six plugins of 1.x's own “simple” comparison. The direct comparison for the 19–23 KB claim.9,671 B9,769 B19,562 B
preset-presenceBuilder + the presence preset. The smallest preset.9,041 B9,134 B—
preset-everydayBuilder + everydayRules, 13 plugins. What a preset costs against naming them one by one.10,217 B10,298 B—
jsonschema-pluginBuilder + jsonSchema. The chain method alone — it does not carry the plugins a document needs.23,304 B23,439 B—
jsonschema-full-featureBuilder + jsonSchemaFullFeature. One import that covers a whole Draft-07 document.25,757 B25,881 B—
full-featureBuilder + every plugin in the catalogue, including the two JSON Schema extensions. The ceiling of everything.28,512 B28,619 B—

30.9%

of the all-plugins build is the core. 1.x's core was 2.0× heavier than this one in absolute bytes, and 89.1% of its own “simple” figure.

~145 B

per plugin on average across the first six (872 B for all six). You pay for what you import.

7,412 B

the same core measured from the built dist/ instead of src/ — 8 B apart, which is why measuring the source is fair.

One row is not a win: all 76 plugins at 28,512 B is larger than the 23,015 B 1.x published for its complex case. The two are not comparable — 1.x's figure was one schema's plugin set, not its whole catalogue — so it is not counted either way.

JSON Schema entry points

Same esbuild and gzip method. @maroonedog/luq/plugins/jsonSchema is billed as the tree-shakeable half of the JSON Schema story. Measured, it is not.

EntrygzipNote
Builder + jsonSchemaPlugin, no bag18,992 BNot a configuration you can validate with — .jsonSchema() needs a bag.
Builder + jsonSchemaPlugin + a working 49-plugin bag21,371 BThe smallest usable JSON Schema build.
Builder + jsonSchemaFullFeaturePlugin21,383 BOne import. 12 B — 0.06% — larger than the row above.

Use jsonSchemaFullFeature. Reach for ./plugins/jsonSchema when you want the per-field chain method — not to save bytes. Both doors are explained on the JSON Schema page.

Throughput

Recorded 2026-09-09. The relative spread on every figure is in the last column — treat differences smaller than that as noise.

Shapevalidate ops/secparse ops/secspread
1 field, 1 check1 field, 1 presence rule, 1 check (string.required().min(3))5,581,1995,593,550±6.6%
3 fields, 6 plugins3 fields, 6 plugins (required/stringMin/stringMax/stringEmail/numberMin/numberMax) — 1.x's 'simple' shape2,444,3081,933,180±4.3%
nested, depth 2–34 fields at depth 2-3 (customer.name, customer.address.*)1,722,7701,701,094±5%
array of 50 elementsarray of 50 elements, 3 rules per element plus 2 length rules90,59090,013±1.8%
JSON Schema documentDraft-07 document (9 properties, $ref, nested object, array of objects) via fromJsonSchema312,630309,502±2.7%

parse and validate land within each other's spread on every shape: transforms run last and only in parse, and these shapes declare none.

Against 1.x

1.x's source and this source, both transpiled the same way, interleaved sample by sample in one process on one machine. 1.x carried a directory of specialised fast paths that this implementation has no equivalent of, and on flat shapes that is what the numbers show.

Neither speed figure 1.x published reproduces here. Its README claimed 1.2M ops/sec simple and 43K complex; the 1.x column below is what it actually does, and “complex” has no reproducible definition left to measure.

Shape1.xthis releaseratio
1 field, 1 check26,568,1115,463,953×0.21
3 fields, 6 plugins3,082,2902,434,509×0.79
nested, depth 2–32,203,6331,842,200×0.83
array of 50 elements19,61084,707×4.3
JSON Schema document146,283306,778×2.1

1.x demonstrably rejects bad values on all five shapes, so it is not doing less work.

Against zod, valibot, ajv and yup

Luq is faster in 9 of 16 pairings. The other 7 are losses and they are in the same table.

Judgements are compared before anything is timed. Only values every library answers the same way are timed, because a ratio against a library doing different work is not a slow number, it is a wrong one. Where they disagree, the value is listed below rather than dropped.

Read the last two columns, not the middle one

The pool is half values that pass and half that fail, so Ratio measures error construction as much as validation. A library can be quick to accept and slow to refuse, and then the pool's composition decides the headline rather than the engine does. Accepted is the column that resembles production traffic.

It changes the answer here. Against zod, Luq leads on the mixed pool in 4 of 4 shapes — and on accepted input alone it is the slower of the two in 4 of 4. The mixed figure is mostly the rejected column pulling it up, which is a measurement of how cheaply each library builds an error.

ShapeLibraryLuq ops/secTheirs ops/secRatioAcceptedRejected
singleFieldzod 4.6.24,132,4962,212,374×1.87×0.40×2.24
singleFieldvalibot 1.5.03,944,5157,452,093×0.53×0.67×0.41
singleFieldajv 8.20.04,054,24838,817,971×0.10×0.21×0.07
singleFieldyup 1.7.13,982,22575,938×52×14×109
multiFieldzod 4.6.21,662,219836,708×1.99×0.88×3.01
multiFieldvalibot 1.5.01,608,1052,035,618×0.79×0.82×0.81
multiFieldajv 8.20.01,678,3129,937,529×0.17×0.22×0.14
multiFieldyup 1.7.11,596,17571,103×22×8.43×47
nestedzod 4.6.21,407,5681,030,911×1.37×0.52×2.11
nestedvalibot 1.5.01,194,1541,255,687×0.95×1.01×1.14
nestedajv 8.20.01,436,7909,488,262×0.15×0.17×0.10
nestedyup 1.7.11,382,71050,554×27×12×45
arrayzod 4.6.269,59063,428×1.10×0.99×1.22
arrayvalibot 1.5.066,43765,202×1.02×1.09×1.02
arrayajv 8.20.066,384383,208×0.17×0.19×0.18
arrayyup 1.7.170,7833,309×21×16×20

Where a library disagreed, and was therefore not timed on that value

  • multiField / yup — Luq said false, it said true for {"name":"Alexandra","email":".alexandra@example.com","age":34}

This is a difference in what the libraries consider valid, not in how fast they are. Neither answer is a bug.

Reading the ajv column, which is the interesting one

ajv is an order of magnitude faster than Luq on every shape here, and it is faster for a structural reason: it compiles a schema into generated JavaScript. That is a real advantage and it is not going away.

What is NOT true is that this makes ajv unusable under a strict Content-Security-Policy. ajv/dist/standalone precompiles a schema to a module ahead of time, and the generated source contains no dynamic code — generated and inspected while writing this page. With build-time schemas, ajv is fine under a strict CSP, and saying otherwise would be an easy thing to claim and a wrong one.

The difference is a schema that is only known at run time — one that arrives from a server, sits in a database, or is written by the user. Precompiling is then impossible by construction, and a code-generating validator has to build a function in the browser, which is exactly what the policy forbids. Luq turns a runtime document into rules and generates nothing, so it keeps working. If your schemas are fixed at build time, ajv's speed is yours to take.

What build() costs

build() compiles the plan once; validate() walks it. Build a validator at module scope and reuse it — the last column is how many validate() calls it takes for one build() to have paid for itself.

Shapeµs per build()builds/secbreaks even after
1 field, 1 check13.6373,38181 calls
3 fields, 6 plugins46.5421,487102 calls
nested, depth 2–359.2616,874101 calls
array of 50 elements67.4814,8186 calls
JSON Schema document697.331,434207 calls

The 50-element array breaks even after 6 calls only because validate() itself costs about 11.2 µs there. The JSON Schema document is the expensive build: 697.33 µs, because converting a document is real work — done once.

What CI actually gates on

Not an absolute ops/sec — a CI runner's speed varies too much for that to mean anything. The gate is the ratio between Luq and a hand-written validator measured in the same process on the same inputs, so the runner's speed cancels out. If the ratio drops below the recorded floor, the build goes red.

ShapeOperationratio to hand-writtenfloor
3 fields, 6 pluginsvalidate0.12710.0547
3 fields, 6 pluginsparse0.13770.0548
nested, depth 2–3validate0.05330.0169
nested, depth 2–3parse0.05440.0174
array of 50 elementsvalidate0.08930.0227
array of 50 elementsparse0.08990.0226
JSON Schema documentvalidate0.29870.1194
JSON Schema documentparse0.30290.1182

How sensitive is that gate?

Measured by deliberately slowing every shape down and watching what the gate does (each shape's validator wrapped so N of every 20 calls run twice and the two outcomes are compared; every gated pairing then measured against its recorded floor):

  • +100% slower: 15/15 pairings below floor
  • +60% slower: 15/15 below floor
  • +35% slower: 14/15 below floor (jsonSchema validate/accepted passed at 0.1202 against a floor of 0.1194)
  • +25% slower: 3/15 below floor — the build still goes red, but on three pairings out of fifteen
  • +15% slower: 0/15 below floor. A regression this size does not move any number in this file.
  • +0%: 0/15 below floor, so the levels above are the mutation and not the noise

So: a slowdown of 35% is caught, and one of 15% is not.

Method

Machine

CPU
AMD Ryzen 7 5825U with Radeon Graphics
Logical cores
16
Memory
15.3 GB
Node
v23.11.0
Platform
win32 / x64
Recorded
2026-09-09

Conditions

  • Subject: src/ transpiled by ts-node (CommonJS, ES2020) — not the bundled dist
  • Samples: 9, each targeting 120 ms; the reported figure is the median of the fastest half
  • Options:abortEarly: true, input accepted, so every rule runs
  • Inputs: every subject rotates over a pool of at least four distinct values (bench/rotate-over-values.ts); one frozen value let V8 delete the singleField reference outright
  • Array shape:50 elements

Bundle size method

  • Bundler: esbuild 0.25.5
  • Options:bundle, minify, format: esm, target: es2020, platform: neutral, treeShaking, no legal comments
  • Compression: node:zlib gzipSync at its default level
  • Measured from: the TypeScript in src/, not dist/ — the package ships unbundled ESM, so the tree a reader's bundler walks is the same tree esbuild walks here
  • Entry shape: a synthetic module that re-exports only the named symbols, so only code reachable from those names survives
  • Comparability: these are the options 1.x's own bundle-size-comparison used. Without that, the 1.x column would not be a comparison at all
  • Node: v23.11.0, recorded 2026-09-07, over 76 catalogue plugins

Reproduce it

Clone the repository and run these, on the machine named above and not on yours.

repository root
# Speed — rewrites config/perf-baseline.json
npm run bench:record

# Bundle size — fails the build if any entry exceeds its ceiling
npm run check:size

# CSP — greps every emitted .js/.mjs for eval and new Function
npm run check:no-dynamic-code