Benchmarks
Last updated 2026-07-27 — neutro/form v0.5.1
Environment
| Runner | local (darwin) |
| Node | v20.19.0 |
| Browser | Chromium (Playwright) |
Competitor Versions
| Library | Version |
|---|---|
| react-hook-form | v7.80.0 |
| formik | v2.4.9 |
| vee-validate | v4.15.1 |
| felte | v1.3.0 |
| tanstack-form (React) | v1.33.0 |
| tanstack-form (Svelte) | v1.33.0 |
Results reflect these exact releases — a later competitor update may change outcomes, so check the version above before drawing conclusions.
Methodology
Three dimensions: correctness (PASS/FAIL), browser performance (Playwright Chromium), and bundle size (esbuild + gzip). Badges are always relative to neutro/form:
- ✅ Win — neutro beats this library by more than 10%
- ➖ Tied — within 10% either way
- ❌ Behind — neutro trails by more than 10%, no documented reason
- ⚖️ Tradeoff — neutro trails for a documented design reason, or neutro passes a check this library architecturally can't (a harsh "neutro wins" is softened to Tradeoff instead) — see footnotes
- — N/A — surface doesn't apply to this library
Scorecard
Correctness
| Library | array-state-integrity | async-race | dependency-trigger |
|---|---|---|---|
| felte | — N/A | — N/A | — N/A |
| formik | — N/A ⓘ[1] | — N/A ⓘ[2] | — N/A ⓘ[3] |
| react-hook-form | — N/A ⓘ[4] | — N/A ⓘ[5] | — N/A ⓘ[6] |
| tanstack-form | — N/A ⓘ[7] | — N/A ⓘ[8] | — N/A ⓘ[9] |
| tanstack-form (React) | — N/A | — N/A | — N/A |
| tanstack-form (Svelte) | — N/A | — N/A | — N/A |
| vee-validate | — N/A ⓘ[10] | — N/A ⓘ[11] | — N/A ⓘ[12] |
Performance
| Library | re-renders/10 | re-renders/100 | async-latency | array-ops | async-cancellation |
|---|---|---|---|---|---|
| felte | ✅ Win ⓘ[13] | ✅ Win ⓘ[14] | ⚖️ Tradeoff ⓘ[15] | ✅ Win ⓘ[16] | ➖ Tied ⓘ |
| formik | ✅ Win ⓘ[17] | ✅ Win ⓘ[18] | ⚖️ Tradeoff ⓘ[19] | ✅ Win ⓘ[20] | ⚖️ Tradeoff ⓘ[21] |
| react-hook-form | ➖ Tied ⓘ[22] | ➖ Tied ⓘ[23] | ⚖️ Tradeoff ⓘ[24] | ➖ Tied ⓘ[25] | ➖ Tied ⓘ |
| tanstack-form | — N/A | — N/A | — N/A | — N/A | — N/A |
| tanstack-form (React) | ➖ Tied ⓘ[26] | ➖ Tied ⓘ[27] | ⚖️ Tradeoff ⓘ[28] | ✅ Win ⓘ[29] | ➖ Tied ⓘ |
| tanstack-form (Svelte) | ➖ Tied ⓘ[30] | ➖ Tied ⓘ[31] | ⚖️ Tradeoff ⓘ[32] | ⚖️ Tradeoff ⓘ[33] | ➖ Tied ⓘ |
| vee-validate | ➖ Tied ⓘ[34] | ➖ Tied ⓘ[35] | ⚖️ Tradeoff ⓘ[36] | ➖ Tied ⓘ[37] | ➖ Tied ⓘ |
Size
| Library | bundle-size |
|---|---|
| felte | ✅ Win ⓘ[38] |
| formik | ✅ Win ⓘ[39] |
| react-hook-form | ❌ Behind ⓘ[40] |
| tanstack-form | ✅ Win ⓘ[41] |
| tanstack-form (React) | — N/A |
| tanstack-form (Svelte) | — N/A |
| vee-validate | ❌ Behind ⓘ[42] |
Correctness
array-state-integrity
| Library | Result | Why |
|---|---|---|
| neutro/form | ✅ PASS | errors/touched/dirty state is rekeyed by index on every array splice/move/swap |
| tanstack-form | — N/A | no public API to rekey per-field error/touched state on array splice outside React context |
| react-hook-form | — N/A | state-map rekey on splice not exposed outside hook context |
| formik | — N/A | state-map rekey on splice not exposed outside hook context |
| vee-validate | — N/A | state-map rekey on splice not exposed outside hook context |
async-race
| Library | Result | Why |
|---|---|---|
| neutro/form | ✅ PASS | each async validation run gets its own AbortController; stale results are discarded by epoch |
| tanstack-form | — N/A | no async cancellation API in vanilla usage |
| react-hook-form | — N/A | no async cancellation API in vanilla usage |
| formik | — N/A | no async cancellation API in vanilla usage |
| vee-validate | — N/A | no async cancellation API in vanilla usage |
dependency-trigger
| Library | Result | Why |
|---|---|---|
| neutro/form | ✅ PASS | a static dependency graph is precompiled at form init, so dependent fields re-validate automatically |
| tanstack-form | — N/A | requires per-field validators; no declarative cross-field dependency graph |
| react-hook-form | — N/A | no declarative dependency graph; cross-field validation is manual |
| formik | — N/A | no declarative dependency graph; cross-field validation is manual |
| vee-validate | — N/A | no declarative dependency graph; cross-field validation is manual |
validation-scope-precision
| Library | Result | Why |
|---|---|---|
| neutro/form | ✅ PASS | set() on a field with 3 declared dependents validates exactly itself + those 3 (4 of 504 total fields), not the whole form — the O(1) precomputed dependency-graph claim, quantified |
Browser (Chromium / Playwright, production build, no StrictMode)
Dependency Chain — Settle Latency (200-field validation cascade)
| Library | Settle latency |
|---|---|
| neutro/form (React) | 87.9ms[43] |
| react-hook-form | 24.5ms |
| tanstack-form (React) | 156.0ms |
| neutro/form (Vue) | 23.2ms[44] |
| vee-validate | 23.4ms |
| neutro/form (Svelte) | 24.4ms[45] |
| tanstack-form (Svelte) | 167.6ms |
| formik | — N/A[46] |
| felte | — N/A[47] |
Array Operations (remove + move, render count)
Note: render counts are not directly comparable across all libraries on this surface — some libraries (e.g. TanStack Form) isolate counters per array index, while others (e.g. neutro/form, Felte) increment counters for every item in the array on any mutation. A low count does not necessarily indicate less DOM work.
| Library | Renders |
|---|---|
| neutro/form (React) | 18 |
| react-hook-form | 18 |
| formik | 54 |
| tanstack-form (React) | 24 |
| neutro/form (Vue) | 18 |
| vee-validate | 18 |
| neutro/form (Svelte) | 21 |
| tanstack-form (Svelte) | 0[33:1] |
| felte | 47 |
DOM Cleanup (connect/disconnect, neutro only)
| Library | Connected after cleanup |
|---|---|
| neutro/form (React) | 0 |
| neutro/form (Vue) | 0 |
| neutro/form (Svelte) | 0 |
Memory Churn (heap delta across mount/unmount cycles, post-GC)
| Library | Heap delta (post-GC) |
|---|---|
| neutro/form (React) | 590.7 KB |
| react-hook-form | 635.9 KB |
Async Cancellation (stale-result race)
| Library | Cancellation |
|---|---|
| neutro/form (React) | ✅ |
| react-hook-form | ✅ |
| formik | ❌[21:1] |
| tanstack-form (React) | ✅ |
| neutro/form (Vue) | ✅ |
| vee-validate | ✅ |
| neutro/form (Svelte) | ✅ |
| tanstack-form (Svelte) | ✅ |
| felte | ✅ |
Re-renders per 20-keystroke sequence (10-field form)
| Library | Renders |
|---|---|
| neutro/form (React) | 20 |
| react-hook-form | 20 |
| formik | 400 |
| tanstack-form (React) | 20 |
| neutro/form (Vue) | 20 |
| vee-validate | 20 |
| neutro/form (Svelte) | 20 |
| tanstack-form (Svelte) | 20 |
| felte | 200 |
Mount Cost (time to interactive, Navigation Timing API)
| Library | Time to interactive |
|---|---|
| neutro/form (React) | 3.5ms |
| react-hook-form | 5.3ms |
| formik | 4.5ms |
| tanstack-form (React) | 5.8ms |
| neutro/form (Vue) | 3.4ms |
| vee-validate | 5.5ms |
| neutro/form (Svelte) | 3.9ms |
| tanstack-form (Svelte) | 4.1ms |
| felte | 4.0ms |
Schema Validation — Submit Latency (Zod, click-to-error-visible)
| Library | Submit latency |
|---|---|
| neutro/form (React) | 39.3ms |
| react-hook-form | 44.9ms |
| tanstack-form (React) | 66.1ms |
| neutro/form (Vue) | 55.4ms |
| vee-validate | 75.7ms |
| neutro/form (Svelte) | 48.7ms |
| tanstack-form (Svelte) | 49.8ms |
| felte | 49.2ms |
| formik | — N/A[48] |
Schema Validation — Re-renders per 20-keystroke sequence (Zod, 10-field form)
| Library | Renders |
|---|---|
| neutro/form (React) | 20 |
| react-hook-form | 0 |
| tanstack-form (React) | 200 |
| neutro/form (Vue) | 20 |
| vee-validate | 20 |
| neutro/form (Svelte) | 20 |
| tanstack-form (Svelte) | 200 |
| felte | 200 |
| formik | — N/A[49] |
Re-renders per 20-keystroke sequence (100-field form)
| Library | Renders |
|---|---|
| neutro/form (React) | 20 |
| react-hook-form | 20 |
| formik | 4000 |
| tanstack-form (React) | 20 |
| neutro/form (Vue) | 20 |
| vee-validate | 20 |
| neutro/form (Svelte) | 20 |
| tanstack-form (Svelte) | 20 |
| felte | 2000 |
Async Validation Latency
| Library | p50 | p99 |
|---|---|---|
| neutro/form (React) | 302ms[50] | 303ms |
| react-hook-form | 202ms | 203ms |
| formik | 203ms | 203ms |
| tanstack-form (React) | 201ms | 202ms |
| neutro/form (Vue) | 302ms[51] | 304ms |
| vee-validate | 203ms | 203ms |
| neutro/form (Svelte) | 303ms[52] | 303ms |
| tanstack-form (Svelte) | 201ms | 202ms |
| felte | 202ms | 203ms |
Async Validation Latency — Debounce Floor (neutro only)
| Library | p50 | p99 |
|---|---|---|
| neutro/form (React) [debounce=0] | 203ms | 203ms |
| neutro/form (Vue) [debounce=0] | 202ms | 203ms |
| neutro/form (Svelte) [debounce=0] | 202ms | 203ms |
Bundle Size
| Library | Gzip size |
|---|---|
| neutro/form | 11.9 KB |
| neutro/form (minimal) | 8.0 KB |
| react-hook-form | 9.7 KB |
| formik | 13.2 KB |
| tanstack-form | 17.4 KB |
| vee-validate | 10.4 KB |
| felte | 22.9 KB |
Architecture Notes
DOM cleanup (dom-cleanup row above, neutro only): neutro/form's connect/disconnect lifecycle registers a WeakRef per connected field in an internal registry, pruned by a MutationObserver watching for node removal. The "Connected after cleanup" number confirms this registry returns to 0 after mount/unmount churn — competitor libraries have no equivalent connect/disconnect API to compare against, so this section has no comparison table.
formik — state-map rekey on splice not exposed outside hook context ↩︎
formik — no async cancellation API in vanilla usage ↩︎
formik — no declarative dependency graph; cross-field validation is manual ↩︎
react-hook-form — state-map rekey on splice not exposed outside hook context ↩︎
react-hook-form — no async cancellation API in vanilla usage ↩︎
react-hook-form — no declarative dependency graph; cross-field validation is manual ↩︎
tanstack-form — no public API to rekey per-field error/touched state on array splice outside React context ↩︎
tanstack-form — no async cancellation API in vanilla usage ↩︎
tanstack-form — requires per-field validators; no declarative cross-field dependency graph ↩︎
vee-validate — state-map rekey on splice not exposed outside hook context ↩︎
vee-validate — no async cancellation API in vanilla usage ↩︎
vee-validate — no declarative dependency graph; cross-field validation is manual ↩︎
felte — neutro/form: 20 renders vs felte: 200 renders (900% fewer/faster) ↩︎
felte — neutro/form: 20 renders vs felte: 2000 renders (9900% fewer/faster) ↩︎
felte — same debounce policy as React — see debounce=0 column. ↩︎
felte — neutro/form: 21 renders vs felte: 47 renders (124% fewer/faster) ↩︎
formik — neutro/form: 20 renders vs formik: 400 renders (1900% fewer/faster) ↩︎
formik — neutro/form: 20 renders vs formik: 4000 renders (19900% fewer/faster) ↩︎
formik — neutro debounces async validation 300ms by default (asyncDebounceMs) to avoid firing on every keystroke. See the debounce=0 column for the floor cost. ↩︎
formik — neutro/form: 18 renders vs formik: 54 renders (200% fewer/faster) ↩︎
react-hook-form — neutro/form: 20 renders vs react-hook-form: 20 renders ↩︎
react-hook-form — neutro/form: 20 renders vs react-hook-form: 20 renders ↩︎
react-hook-form — neutro debounces async validation 300ms by default (asyncDebounceMs) to avoid firing on every keystroke. See the debounce=0 column for the floor cost. ↩︎
react-hook-form — neutro/form: 18 renders vs react-hook-form: 18 renders ↩︎
tanstack-form (React) — neutro/form: 20 renders vs tanstack-form (React): 20 renders ↩︎
tanstack-form (React) — neutro/form: 20 renders vs tanstack-form (React): 20 renders ↩︎
tanstack-form (React) — neutro debounces async validation 300ms by default (asyncDebounceMs) to avoid firing on every keystroke. See the debounce=0 column for the floor cost. ↩︎
tanstack-form (React) — neutro/form: 18 renders vs tanstack-form (React): 24 renders (33% fewer/faster) ↩︎
tanstack-form (Svelte) — neutro/form: 20 renders vs tanstack-form (Svelte): 20 renders ↩︎
tanstack-form (Svelte) — neutro/form: 20 renders vs tanstack-form (Svelte): 20 renders ↩︎
tanstack-form (Svelte) — same debounce policy as React — see debounce=0 column. ↩︎
tanstack-form (Svelte) — TanStack's own Svelte bench harness never defines window.__resetArrayRenders, so its render counter (window.__tanstackArrayRenders) stays permanently empty and reports an artificial 0 — not a real absence of render work. Confirmed by direct inspection during this project's own v0.5.0 verification; not a neutro/form architectural gap. ↩︎ ↩︎
vee-validate — neutro/form: 20 renders vs vee-validate: 20 renders ↩︎
vee-validate — neutro/form: 20 renders vs vee-validate: 20 renders ↩︎
vee-validate — same debounce policy as React — see debounce=0 column. ↩︎
vee-validate — neutro/form: 18 renders vs vee-validate: 18 renders ↩︎
felte — neutro/form: 11.9 KB vs felte: 22.9 KB (93% fewer/faster) ↩︎
formik — neutro/form: 11.9 KB vs formik: 13.2 KB (11% fewer/faster) ↩︎
react-hook-form — neutro/form: 11.9 KB vs react-hook-form: 9.7 KB ↩︎
tanstack-form — neutro/form: 11.9 KB vs tanstack-form: 17.4 KB (46% fewer/faster) ↩︎
vee-validate — neutro/form: 11.9 KB vs vee-validate: 10.4 KB ↩︎
neutro/form (React) — This surface’s settle-latency measurement is bounded below by Playwright’s waitForFunction polling granularity, not by real cascade cost — for very fast libraries the reported number reflects polling overhead more than actual work. Treat the gap between neutro and the manual-wiring competitors as a floor on the true gap, not an exact figure. ↩︎
neutro/form (Vue) — This surface’s settle-latency measurement is bounded below by Playwright’s waitForFunction polling granularity, not by real cascade cost — for very fast libraries the reported number reflects polling overhead more than actual work. Treat the gap between neutro and the manual-wiring competitors as a floor on the true gap, not an exact figure. ↩︎
neutro/form (Svelte) — This surface’s settle-latency measurement is bounded below by Playwright’s waitForFunction polling granularity, not by real cascade cost — for very fast libraries the reported number reflects polling overhead more than actual work. Treat the gap between neutro and the manual-wiring competitors as a floor on the true gap, not an exact figure. ↩︎
formik — Formik has no live field-to-field subscription API to drive a manual validation cascade of this shape — excluded from this comparison. Formik participates fully in every other browser surface. ↩︎
felte — Felte has no field-level watch API to drive a manual validation cascade of this shape — excluded from this comparison. Felte participates fully in every other browser surface. ↩︎
formik — Formik has no first-party Zod integration (only an unofficial zod-formik-adapter package) — excluded from this Zod-specific comparison. Formik participates fully in every other browser surface. ↩︎
formik — Formik has no first-party Zod integration (only an unofficial zod-formik-adapter package) — excluded from this Zod-specific comparison. Formik participates fully in every other browser surface. ↩︎
neutro/form (React) — neutro debounces async validation 300ms by default (asyncDebounceMs) to avoid firing on every keystroke. See the debounce=0 column for the floor cost. ↩︎
neutro/form (Vue) — same debounce policy as React — see debounce=0 column. ↩︎
neutro/form (Svelte) — same debounce policy as React — see debounce=0 column. ↩︎