Benchmarks
- run
- 2026-10-19
- machine
- Apple M5
- browser
- Chromium 156.0.8078.4
- suite
- 21.6 min wall clock
measured · reproducible · medians + spread
At 1,000 rules.
- first render
- 131 msno framework 117 ms
best other: 141 ms (DevExtreme)
slowest: 6.5 s (Syncfusion) - keystroke
- 0.5 msno framework 0.4 ms
best other: 7.75 ms (RAQB)
slowest: 58 ms (RQB) - add a rule
- 0.8 msno framework 0.65 ms
best other: 3.5 ms (jQuery QB)
slowest: 101 ms (RAQB) - js heap
- 14 MBno framework 4.8 MB
best other: 2.2 MB (jQuery QB)
slowest: 38 MB (RAQB) - library js, gzip, with sql export
- 25.2 KBno framework 25.7 KB
best other: 58 KB (RQB)
largest: 308.3 KB (Syncfusion)
How each library scales.
- Syncfusion QueryBuilder at 5,000: skipped (projected cold mount ≈173 s from the smaller sizes; over the suite's 45 s budget)
qnot in every framework.
On a 4× slower CPU.
No keystroke number for DevExtreme FilterBuilder: no inline value input.
The engine on its own.
@qnot/core alone in Node v22.22.2, no DOM: the store, validation, evaluation and every codec, on queries of 10, 100, 1,000, 10k rules with mixed operators. Each number is the median of 9 timed rounds of ~120 ms after a warm-up round.Time per operation
Encoding is memoized per node and an edit only replaces the path to the changed rule, so re-encoding SQL after one edit to a 10,000-rule query takes 401 µs against 12 ms for a cold encode.
What you ship.
Methodology.
The tree
Ten fields (text, number, select, boolean) and a root AND group of OR groups with ten rules each, so 1,000 rules is 100 groups. Every library gets the same tree, converted to its own stored format outside the timed region, with its default widgets and its recommended stylesheet.
The metrics
- First render:
performance.now()around "load the stored JSON and render" until every rule row is in the DOM, then a forced layout. Cold, in a fresh page each time: 9 pages per size up to 1,000 rules and 4 above (fewer for libraries whose mount takes seconds). - Keystroke: set a mid-tree text value, dispatch
inputandchange, wait one macrotask and force layout. 40 keystrokes per size; afterwards the library's own SQL export must contain the typed text, so the model was updated, not just the input. - Change field / operator: open the rule's field or operator picker the way a user does (focus and press on native selects, which also fills qnot's lazy option lists; a click on popup menus) and pick another option. Field changes alternate Age and First name so the value editor changes too.
- Drag to reorder: drag a rule to the top of its group with the library's own drag and drop, timed from the drop. Only qnot and react-awesome-query-builder have drag and drop in their default setup.
- Add / remove rule: the library's own buttons on the root group and on a rule. Menu-based libraries (Syncfusion, DevExtreme) include opening the menu.
- JS heap:
Runtime.getHeapUsageafter a forced GC, before and after the first mount. It does not count DOM memory, which is why a library that keeps its state in the DOM looks light here; read it with the DOM node count. - DOM nodes: elements under the builder's root after mount.
- Interactions run 12 times on the last page of each size; every metric records median, p25, p75, p95, min, max and the sample count.
- Core: batches calibrated to ~120 ms, inputs prepared outside the timed loop (encode results are memoized per node, so a "cold" encode needs a fresh query each call); µs per call, median of 9 rounds, with min and max.
- Budget: a size is skipped and recorded as skipped when the smaller sizes project a cold mount over 45 s.
Environment
- date
- 2026-10-19 12:33 UTC
- machine
- Mac17,2, Apple M5, 10 cores, 32 GB
- os
- macOS 26.3 (darwin 25.3.0 (arm64))
- node
- v22.22.2
- browser
- Chromium 156.0.8078.4 (Playwright 1.64.1), headless, 1400×900
- commit
- cc5a2fa
- duration
- 21.6 min
- react
- 19.3.1
- vue
- 3.5.44
- svelte
- 5.57.2
- solid-js
- 1.9.17
- react-querybuilder
- 8.25.0
- @react-awesome-query-builder/ui
- 6.6.15
- jQuery-QueryBuilder
- 3.0.0
- @syncfusion/ej2-querybuilder
- 35.1.37
- devextreme
- 26.1.5
- esbuild
- 0.28.2
pnpm install && pnpm --filter cqb-bench suiteThe harness, adapters and raw JSON are in bench/ on GitHub: results/suite.json holds every number on this page. A slice runs in a minute: ONLY=qnot,rqb SIZES=100,1000 node suite.mjs --no-core --no-throttle.
Caveats.
Where another library wins
- jQuery QueryBuilder beats qnot (React) on JS heap at 1,000 rules: 2.2 MB vs 14 MB.
- DevExtreme FilterBuilder beats qnot (React) on JS heap at 1,000 rules: 11 MB vs 14 MB.
- jQuery QueryBuilder beats qnot (no framework) on JS heap at 1,000 rules: 2.2 MB vs 4.8 MB.
- DevExtreme FilterBuilder beats qnot (React) on DOM nodes at 1,000 rules: 7,606 vs 13k.
- DevExtreme FilterBuilder beats qnot (no framework) on DOM nodes at 1,000 rules: 7,606 vs 16k.
Where qnot is weaker
- qnot (Vue): JS heap 29 MB vs 14 MB with React at 1,000 rules.
Design differences, not bugs
- DevExtreme shows values as text and opens an editor on click, so it has no inline typing path (no keystroke number) and far fewer DOM nodes.
- jQuery QueryBuilder keeps its state in the DOM and jQuery data, so its JS heap is small; its DOM node count is the fairer memory proxy.
- react-awesome-query-builder moves rules live while the pointer moves; its drag number is the release only. react-querybuilder, jQuery QueryBuilder (plugin), Syncfusion (opt-in) and DevExtreme have no drag and drop in the default setup.
- Syncfusion and DevExtreme run unlicensed with their trial banner; behaviour is otherwise the same.
- qnot's selects fill their option lists on first focus, so "change field" includes that work for qnot and not for libraries that render every option up front.
Noise
- Sub-millisecond timings are close to the browser timer's resolution (Chromium coarsens
performance.now()without cross-origin isolation), so 0.3 vs 0.4 ms is a tie. - Heap numbers move with GC timing by a few hundred KB; the throttled pass uses fewer samples than the main run.
- 69 of 319 medians from 100 rules up have a p25–p75 range wider than ±25% of the median, mostly interactions at 2,500 and 5,000 rules, which run 6 times rather than 12. The widest: jQuery QueryBuilder, remove a rule at 500: 2.3 ms with p25–p75 1.85 ms–8.2 ms; qnot (Vue), change field at 2,500: 3 ms with p25–p75 1.7 ms–7.98 ms; Syncfusion QueryBuilder, remove a rule at 500: 7.2 ms with p25–p75 1.7 ms–13.8 ms; jQuery QueryBuilder, change field at 1,000: 3.1 ms with p25–p75 2.67 ms–7.78 ms. The table view of each chart shows every range.
- A fresh page that never finished its first render within 60 s was replaced by another fresh page, once, for: react-awesome-query-builder at 500, react-awesome-query-builder at 1,000. The same happened once in an earlier run at a different size, so it looks like an intermittent hang rather than a size limit; the JSON records it.
- Skipped sizes (projected over the time budget, recorded in the JSON): Syncfusion QueryBuilder at 5,000.
- One machine, one browser, one run. Absolute numbers will differ on yours; compare ratios, and rerun the suite to check them.