Benchmarks

Six JavaScript query builders, the same tree from 10 to 5,000 rules, every interaction through each library's own UI. Medians with their spread, a 4× slower CPU, the core engine on its own, and bundle sizes.
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.

Production builds, headless Chromium, no throttling. Each tile is qnot with React against the fastest and slowest of the five other libraries; the table has every library and interaction.
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.

Every size from 10 to 5,000 rules on log–log axes, so a straight line is a power law and parallel lines are a constant factor apart. Click a library in the legend to hide it; switch to the table for exact numbers and spreads.
Both axes logarithmic · rules in the tree → · cold mount of the stored tree in a fresh page, median
  • 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.

The same core under each binding. The renderer is the only difference: same store, same stored query, same markup and classes.
Both axes logarithmic · rules in the tree → · cold mount, median

On a 4× slower CPU.

Chrome's CPU throttling at 4× approximates a mid-range phone. Fewer repetitions than the main run (fresh pages: 5 at 100 rules, 3 at 1,000), so read the spreads in the table.
4× CPU slowdown (Chrome DevTools throttling), 1,000 rules, median. After the dot: how many times slower than unthrottled.

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

Both axes logarithmic · rules in the tree → · µs per call, median of rounds

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.

One call on a fresh 100-rule query (nothing memoized), median of 9 rounds. Grey: encoders; teal: parsing SQL back into a query.

What you ship.

Built with esbuild 0.28 for the benchmark pages; lower is better. Hover a bar for gzip, brotli and minified sizes.
Minified + gzip of the benchmark page, minus the framework runtime (React, Vue, Svelte, Solid; jQuery stays in). Includes each library’s SQL export.

Methodology.

What each number is, how it was taken, and how to get your own.

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 input and change, 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.getHeapUsage after 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
reproducepnpm install && pnpm --filter cqb-bench suite

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

Read these before quoting a number.

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.