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
Builderwith zero plugins- 3 fields, 6 plugins
- 2,444,308
validateops/sec on the machine below- Dynamic code
- 0 occurrences
- across 874 emitted
.js/.mjsfiles
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.
| Entry | gzip | CI ceiling | 1.x, same method |
|---|---|---|---|
| core-onlyBuilder alone. The floor a reader pays before importing a single plugin. | 8,799 B | 8,870 B | 17,423 B |
| one-pluginBuilder + required. The point that shows what one plugin costs. | 8,935 B | 9,017 B | — |
| three-pluginBuilder + required / stringMin / numberMin. The same set the barrel-equivalence check uses. | 9,261 B | 9,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 B | 9,769 B | 19,562 B |
| preset-presenceBuilder + the presence preset. The smallest preset. | 9,041 B | 9,134 B | — |
| preset-everydayBuilder + everydayRules, 13 plugins. What a preset costs against naming them one by one. | 10,217 B | 10,298 B | — |
| jsonschema-pluginBuilder + jsonSchema. The chain method alone — it does not carry the plugins a document needs. | 23,304 B | 23,439 B | — |
| jsonschema-full-featureBuilder + jsonSchemaFullFeature. One import that covers a whole Draft-07 document. | 25,757 B | 25,881 B | — |
| full-featureBuilder + every plugin in the catalogue, including the two JSON Schema extensions. The ceiling of everything. | 28,512 B | 28,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.
| Entry | gzip | Note |
|---|---|---|
| Builder + jsonSchemaPlugin, no bag | 18,992 B | Not a configuration you can validate with — .jsonSchema() needs a bag. |
| Builder + jsonSchemaPlugin + a working 49-plugin bag | 21,371 B | The smallest usable JSON Schema build. |
| Builder + jsonSchemaFullFeaturePlugin | 21,383 B | One 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.
| Shape | validate ops/sec | parse ops/sec | spread |
|---|---|---|---|
| 1 field, 1 check1 field, 1 presence rule, 1 check (string.required().min(3)) | 5,581,199 | 5,593,550 | ±6.6% |
| 3 fields, 6 plugins3 fields, 6 plugins (required/stringMin/stringMax/stringEmail/numberMin/numberMax) — 1.x's 'simple' shape | 2,444,308 | 1,933,180 | ±4.3% |
| nested, depth 2–34 fields at depth 2-3 (customer.name, customer.address.*) | 1,722,770 | 1,701,094 | ±5% |
| array of 50 elementsarray of 50 elements, 3 rules per element plus 2 length rules | 90,590 | 90,013 | ±1.8% |
| JSON Schema documentDraft-07 document (9 properties, $ref, nested object, array of objects) via fromJsonSchema | 312,630 | 309,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.
| Shape | 1.x | this release | ratio |
|---|---|---|---|
| 1 field, 1 check | 26,568,111 | 5,463,953 | ×0.21 |
| 3 fields, 6 plugins | 3,082,290 | 2,434,509 | ×0.79 |
| nested, depth 2–3 | 2,203,633 | 1,842,200 | ×0.83 |
| array of 50 elements | 19,610 | 84,707 | ×4.3 |
| JSON Schema document | 146,283 | 306,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.
| Shape | Library | Luq ops/sec | Theirs ops/sec | Ratio | Accepted | Rejected |
|---|---|---|---|---|---|---|
| singleField | zod 4.6.2 | 4,132,496 | 2,212,374 | ×1.87 | ×0.40 | ×2.24 |
| singleField | valibot 1.5.0 | 3,944,515 | 7,452,093 | ×0.53 | ×0.67 | ×0.41 |
| singleField | ajv 8.20.0 | 4,054,248 | 38,817,971 | ×0.10 | ×0.21 | ×0.07 |
| singleField | yup 1.7.1 | 3,982,225 | 75,938 | ×52 | ×14 | ×109 |
| multiField | zod 4.6.2 | 1,662,219 | 836,708 | ×1.99 | ×0.88 | ×3.01 |
| multiField | valibot 1.5.0 | 1,608,105 | 2,035,618 | ×0.79 | ×0.82 | ×0.81 |
| multiField | ajv 8.20.0 | 1,678,312 | 9,937,529 | ×0.17 | ×0.22 | ×0.14 |
| multiField | yup 1.7.1 | 1,596,175 | 71,103 | ×22 | ×8.43 | ×47 |
| nested | zod 4.6.2 | 1,407,568 | 1,030,911 | ×1.37 | ×0.52 | ×2.11 |
| nested | valibot 1.5.0 | 1,194,154 | 1,255,687 | ×0.95 | ×1.01 | ×1.14 |
| nested | ajv 8.20.0 | 1,436,790 | 9,488,262 | ×0.15 | ×0.17 | ×0.10 |
| nested | yup 1.7.1 | 1,382,710 | 50,554 | ×27 | ×12 | ×45 |
| array | zod 4.6.2 | 69,590 | 63,428 | ×1.10 | ×0.99 | ×1.22 |
| array | valibot 1.5.0 | 66,437 | 65,202 | ×1.02 | ×1.09 | ×1.02 |
| array | ajv 8.20.0 | 66,384 | 383,208 | ×0.17 | ×0.19 | ×0.18 |
| array | yup 1.7.1 | 70,783 | 3,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/sec | breaks even after |
|---|---|---|---|
| 1 field, 1 check | 13.63 | 73,381 | 81 calls |
| 3 fields, 6 plugins | 46.54 | 21,487 | 102 calls |
| nested, depth 2–3 | 59.26 | 16,874 | 101 calls |
| array of 50 elements | 67.48 | 14,818 | 6 calls |
| JSON Schema document | 697.33 | 1,434 | 207 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.
| Shape | Operation | ratio to hand-written | floor |
|---|---|---|---|
| 3 fields, 6 plugins | validate | 0.1271 | 0.0547 |
| 3 fields, 6 plugins | parse | 0.1377 | 0.0548 |
| nested, depth 2–3 | validate | 0.0533 | 0.0169 |
| nested, depth 2–3 | parse | 0.0544 | 0.0174 |
| array of 50 elements | validate | 0.0893 | 0.0227 |
| array of 50 elements | parse | 0.0899 | 0.0226 |
| JSON Schema document | validate | 0.2987 | 0.1194 |
| JSON Schema document | parse | 0.3029 | 0.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:zlibgzipSyncat its default level - Measured from: the TypeScript in
src/, notdist/— 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-comparisonused. 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.
# 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