Benchmarks
How Attaform holds up across the Vue form-library field on the same demanding forms, measured in a real browser. Bundle size, supply-chain health, and per-scenario runtime, with every number traced to the run that produced it.
- Category
- Comparison
- Method
- Playwright, real Chromium
- Provenance
- committed results.json
This page is an honest scoreboard. The numbers come from apps/bench-arena, a self-contained harness that drives every library through identical scenarios in real Chromium via Playwright, then writes one provenance-stamped results.json that this page renders directly. Nothing is hand-entered. Where Attaform leads, the run says so; where it pays a cost, the run says that too. Both ship from the same green run.
How to read this
A fair cross-library comparison has to account for the fact that these libraries do different amounts of work. The harness handles that with a few rules:
- Layers are the fairness axis. Form-state libraries (Attaform among them) own reactive values, validation, and input binding. Validation-only libraries own validation against state you wire yourself. A batteries-included library renders its own inputs. Each row is labeled with its layer, so a validation-only engine mounting faster than a full form-state library is read as owning less, not as winning.
- The DOM is held constant. Every headless library drives the same bare
<input>markup over the same field count and the same schema, so a runtime number reflects the library's own machinery, not its choice of components. - Every library runs in its fastest idiomatic configuration. Debounces are neutralized, validation triggers are normalized, and array and union primitives use each library's native fast path. Attaform is measured on its shipping configuration, never a relaxed setting.
- Real builds, pinned validators. Attaform is consumed as its published
dist, the same artifact an installer gets, minified and code-split in the same build as every other library. Zod v3 is pinned across the Zod-capable cohort, with one deliberate exception below. - Two libraries run more than one row. Attaform appears twice in the runtime and capability tables, as Attaform (Zod 3) through
attaform/zod-v3and Attaform (Zod 4) throughattaform/zod-v4. The two adapters are first-class peers, so the arena measures both rather than picking a favourite, and the rows are identical in every respect but the validator. Regle appears twice for the same reason, Regle (schema) over Zod and Regle (rules) over its own validators. The bundle table carries one Attaform row, built on the pinned Zod v3. - Numbers normalize two ways. A ratio compares each library to Attaform at the same size. A slope compares a library to itself at the scenario's smallest size, so the shape of growth survives a change of machine.
The harness, every adapter, and the scenario generators live in apps/bench-arena. Found a fairer configuration for a library? The adapters are small and the README invites a pull request.
What it costs to adopt
Before any runtime number, three figures decide whether a library is worth reaching for: what it can express, what it adds to your bundle, and how its supply chain scores.
Capability coverage
What each library expresses as a first-class primitive versus composes by hand. A gap here becomes honest expressiveness data, never a rigged timing loss: a shape a library cannot express idiomatically is left out of its runtime rows rather than forced into a slow number.
| Library | Layer | Flat | Deeply nested | Dynamic arrays | Grid | Discriminated union | Massive | Wizard |
|---|---|---|---|---|---|---|---|---|
| Attaform (Zod 3) Zod 3 | Form state | Native | Native | Native | Native | Native | Native | Native |
| Attaform (Zod 4) Zod 4 | Form state | Native | Native | Native | Native | Native | Native | Native |
| vee-validate Zod 3 | Form state | Native | Native | Native | Native | Hand-rolled | Native | Hand-rolled |
| @tanstack/vue-form Zod 3 | Form state | Native | Native | Native | Native | Hand-rolled | Native | Hand-rolled |
| @formisch/vue Valibot | Form state | Native | Native | Native | Native | Hand-rolled | Native | Hand-rolled |
| Regle (schema) Zod 3 | Validation only | Native | Native | Native | Native | Native | Native | Hand-rolled |
| Regle (rules) native rules | Validation only | Native | Native | Native | Native | Hand-rolled | Native | Hand-rolled |
| FormKit Zod 3 | Batteries included | Native | Native | Native | Native | Hand-rolled | Native | Hand-rolled |
| Vuelidate native rules | Validation only | Native | Native | Hand-rolled | Hand-rolled | Hand-rolled | Native | Hand-rolled |
Native: a first-class primitive. Hand-rolled: composed from lower-level pieces. Dash: not expressed, which the runtime tables read as no number, never a slow one.
Bundle size
Attaform is the heaviest in the cohort, and the heaviest in its own layer. That is the cost of shipping reactive form state, deep schema binding, and the whole display-state model in one zero-dependency package, and it is the honest price of admission. The figure is what a code-split build executes before the form is interactive; opt-in layers like the v-register directive, undo and redo, and the multi-step wizard live behind separate imports this minimal form never pays for.
The table groups by layer for the reason the fairness rule above gives: a validation-only engine is not a cheaper version of a form-state library, so the two should not be read as a single ranking. Each row also carries the one line the arena can derive about what that library is for, from its adapter's own declared design point. And the Zod rows share a floor, quoted under the table: building nothing but the two-field schema and the parse call every Zod adapter makes already costs most of a small library, so that much of each of those figures is identical bytes before any form library. That figure is measured by the same build as the rows rather than written here, so it cannot drift away from the chart it explains.
| Library | Gzipped | vs Attaform | Validator |
|---|---|---|---|
| Validation onlyYour components own the values; the library answers whether they are valid. | |||
Vuelidate Validating state your components already own, from its own rule set. | 5.0 kB | 0.1× | native validators |
Regle Validating state your components already own, from a Zod schema. | 33.1 kB | 0.69× | zod 3.25.76 |
| Form stateThe library owns values, errors and the wiring between them. You write the inputs. | |||
@formisch/vue Holding form state behind inputs you write yourself, from a Valibot schema. | 4.0 kB | 0.08× | valibot 1.5.0 |
vee-validate Holding form state behind inputs you write yourself, from a Zod schema. | 24.6 kB | 0.52× | zod 3.25.76 |
@tanstack/vue-form Holding form state behind inputs you write yourself, from a Zod schema. | 29.9 kB | 0.63× | zod 3.25.76 |
Attaform Holding form state behind inputs you write yourself, from a Zod schema. | 47.6 kB | 1× | zod 3.25.76 |
| Batteries includedThe library ships the field components as well as the state behind them. | |||
FormKit Rendering the fields as well as holding their state, from a Zod schema. | 44.5 kB | 0.93× | zod 3.25.76 |
Each row is the same minimal real form (one text field, one email field, schema-validated, a submit handler) in that library's idiomatic API, with its validator weighed in. Vue is external, since every app ships it once. Builds are code-split the way a real bundler ships them: each figure is the JavaScript that executes before the form is interactive, and code a library defers behind a dynamic import loads on demand instead of on first paint.
Read this as a map, not a leaderboard. The groups are the fairness axis: a library that only validates is not a cheaper version of one that owns form state, it is doing a different job, and the row says which. The Zod rows also share a floor. Building nothing but that two-field schema and the parse every Zod adapter runs already costs 13.8 kB of zod 3.25.76, so that much of each of those figures is the same bytes in every one of them, before any form library.
Supply-chain health
The OpenSSF Scorecard rates a project's adoption of supply-chain practices: branch protection, signed releases, pinned dependencies, CI hardening, and more. For a form library headed into an audited setting, that posture is part of the cost of adoption, so the benchmark stamps each project's current score alongside the size and runtime figures.
| Library | OpenSSF Scorecard | As of | Link |
|---|---|---|---|
| Attaform (Zod 3) | 7.9 / 10 | 2026-09-18 | Scorecard |
| Attaform (Zod 4) | 7.9 / 10 | 2026-09-18 | Scorecard |
| vee-validate | 3.6 / 10 | 2026-09-14 | Scorecard |
| @tanstack/vue-form | Not published | — | Repository |
| @formisch/vue | Not published | — | Repository |
| Regle (schema) | 5.2 / 10 | 2026-09-08 | Scorecard |
| Regle (rules) | 5.2 / 10 | 2026-09-08 | Scorecard |
| FormKit | Not published | — | Repository |
| Vuelidate | 4.4 / 10 | 2026-09-14 | Scorecard |
The OpenSSF Scorecard rates a project's adoption of supply-chain practices out of 10. An absent score has two meanings, kept distinct here. Not published means the project has not opted into a Scorecard, which is a choice, not a deficiency. Unavailable means the lookup did not complete on this run, a network gap on our side and never a statement about the project. Scores are point-in-time; the linked viewer shows the live result.
Typing into a form
Keystroke latency
The headline interaction. A keystroke runs the value write, validation, and the re-render it triggers. On a flat form Attaform clears a 60 fps frame budget with room to spare.
In the leading group of form-state libraries on this run.
| Library | F10 | F50 |
|---|---|---|
| vee-validate | 0.50 ms 1× | 0.70 ms 0.78× |
| @formisch/vue | 0.40 ms 0.8× | 0.70 ms 0.78× |
| Regle (rules) | 0.50 ms 1× | 0.70 ms 0.78× |
| Vuelidate | 0.50 ms 1× | 0.70 ms 0.78× |
| Attaform (Zod 4) | 0.60 ms 1.2× | 0.80 ms 0.89× |
| Attaform (Zod 3) | 0.50 ms 1× | 0.90 ms 1× |
| @tanstack/vue-form | 0.50 ms 1× | 0.90 ms 1× |
| FormKit | 0.70 ms 1.4× | 0.90 ms 1× |
| Regle (schema) | 0.60 ms 1.2× | 1.10 ms 1.22× |
At five thousand fields the picture tightens. The harness reports where Attaform lands plainly, and it is a scenario worth a future look.
Near the front of the form-state pack here.
| Library | L2000 | L5000 |
|---|---|---|
| Regle (rules) | 10.4 ms 0.81× | 18.2 ms 0.76× |
| @formisch/vue | 10.5 ms 0.81× | 18.5 ms 0.77× |
| Vuelidate | 10.7 ms 0.83× | 18.8 ms 0.78× |
| vee-validate | 11.2 ms 0.87× | 19.5 ms 0.81× |
| Attaform (Zod 4) | 12.9 ms 1× | 23.7 ms 0.99× |
| Attaform (Zod 3) | 12.9 ms 1× | 24.0 ms 1× |
| Regle (schema) | 19.3 ms 1.5× | 43.0 ms 1.79× |
| @tanstack/vue-form | 27.9 ms 2.16× | 69.6 ms 2.9× |
| FormKit | 4.80 ms 0.37× | did not finish> 5 min |
Re-render scope
Editing one cell of a large grid should re-render one field, not the form. Configured optimally, the modern headless cohort all reaches that bound, and there is no lower number to beat.
One render per keystroke, whatever the form size. That is the design target, and the run holds it.
| Library | N20M8 | N100M8 |
|---|---|---|
| Attaform (Zod 3) | 1 renders 1× | 1 renders 1× |
| Attaform (Zod 4) | 1 renders 1× | 1 renders 1× |
| vee-validate | 1 renders 1× | 1 renders 1× |
| @tanstack/vue-form | 1 renders 1× | 1 renders 1× |
| @formisch/vue | 1 renders 1× | 1 renders 1× |
| Regle (schema) | 1 renders 1× | 1 renders 1× |
| Regle (rules) | 1 renders 1× | 1 renders 1× |
| Vuelidate | 1 renders 1× | 1 renders 1× |
| FormKit | 3 DOM mutations †3× | 3 DOM mutations †3× |
† Reported as DOM mutations, a proxy for a library that owns its inputs rather than binding the shared bare field. Not directly comparable to a Vue render count.
Standing up a form
Mounting a large form
Building a two-thousand-field form from scratch is where the form-state libraries separate. Attaform mounts the whole reactive tree, validation wiring included, every value and validation path live before the first paint.
Squarely in the form-state pack, neither out front nor at the back.
| Library | L2000 |
|---|---|
| @formisch/vue | 79.7 ms 0.65× |
| Vuelidate | 103 ms 0.84× |
| Attaform (Zod 3) | 122 ms 1× |
| Attaform (Zod 4) | 132 ms 1.08× |
| Regle (schema) | 208 ms 1.7× |
| Regle (rules) | 231 ms 1.89× |
| vee-validate | 644 ms 5.27× |
| @tanstack/vue-form | 6882 ms 56.32× |
| FormKit | 10696 ms 87.53× |
Memory
Retained heap after mount, the library's reactive and validation state at scenario size. Churn is the per-cycle allocation pressure, and leak is the residual across mount and teardown cycles. The sparkline traces retained heap across the measured cycles.
Among the faster form-state libraries on this shape.
| Library | L2000 |
|---|---|
| @formisch/vue | 11945 kB1× churn 63 kB · leak 11 kB |
| Attaform (Zod 3) | 11972 kB1× churn 619 kB · leak 39 kB |
| Vuelidate | 14913 kB1.25× churn 95 kB · leak 8 kB |
| vee-validate | 20398 kB1.7× churn 451 kB · leak 1607 kB |
| Attaform (Zod 4) | 21759 kB1.82× churn 717 kB · leak 60 kB |
| @tanstack/vue-form | 22616 kB1.89× churn 357 kB · leak 1674 kB |
| Regle (schema) | 33982 kB2.84× churn 388 kB · leak 23 kB |
| Regle (rules) | 42045 kB3.51× churn 297 kB · leak 29 kB |
| FormKit | 385429 kB32.19× churn 135 kB · leak 40 kB |
Working the harder shapes
Validation throughput
A full-form validation pass over a massive form, the cost of a submit on the largest shape in the suite.
Near the front of the form-state pack here.
| Library | L2000 | L5000 |
|---|---|---|
| Vuelidate | 0.20 ms 0.14× | 0.60 ms 0.25× |
| Attaform (Zod 4) | 1.45 ms 1.04× | 1.80 ms 0.75× |
| Attaform (Zod 3) | 1.40 ms 1× | 2.40 ms 1× |
| @formisch/vue | 1.20 ms 0.86× | 2.40 ms 1× |
| vee-validate | 15.5 ms 11.07× | 31.2 ms 13× |
| Regle (schema) | 22.4 ms 15.96× | 70.5 ms 29.38× |
| Regle (rules) | 75.6 ms 54× | 194 ms 80.69× |
| @tanstack/vue-form | 981 ms 701× | did not finish> 5 min |
| FormKit | 326 ms 232.5× | did not finish> 5 min |
Discriminated unions
Writing into and flipping between variants of a discriminated union. Attaform walks only the active branch, so a variant flip touches the branch in play rather than every alternative.
Near the front of the form-state pack here.
| Library | DU |
|---|---|
| Vuelidate | 0.40 ms 0.67× |
| @formisch/vue | 0.50 ms 0.83× |
| Attaform (Zod 3) | 0.60 ms 1× |
| Attaform (Zod 4) | 0.60 ms 1× |
| @tanstack/vue-form | 0.60 ms 1× |
| vee-validate | 0.70 ms 1.17× |
| Regle (schema) | 0.80 ms 1.33× |
| Regle (rules) | 1.20 ms 2× |
| FormKit | 5.00 ms 8.33× |
Dynamic arrays
Appending and reordering rows in a growing list. Attaform's array helpers copy the target array before mutating, which keeps reads fast and identity stable but shows up as real cost on a reorder at a hundred rows. It is an honest line on the board.
Mid-pack among the form-state libraries here.
| Library | N10 | N100 |
|---|---|---|
| @formisch/vue | 0.60 ms 0.43× | 1.30 ms 0.46× |
| Attaform (Zod 4) | 1.30 ms 0.93× | 2.30 ms 0.82× |
| Attaform (Zod 3) | 1.40 ms 1× | 2.80 ms 1× |
| Vuelidate | 1.00 ms 0.71× | 2.90 ms 1.04× |
| @tanstack/vue-form | 1.10 ms 0.79× | 3.30 ms 1.18× |
| vee-validate | 2.00 ms 1.43× | 9.00 ms 3.21× |
| Regle (rules) | 1.70 ms 1.21× | 9.20 ms 3.29× |
| Regle (schema) | 1.80 ms 1.29× | 10.3 ms 3.68× |
| FormKit | 4.25 ms 3.04× | 10.4 ms 3.71× |
Holds the middle of the form-state field on this shape.
| Library | N10 | N100 |
|---|---|---|
| @formisch/vue | 0.70 ms 0.64× | 1.30 ms 0.62× |
| Attaform (Zod 4) | 1.10 ms 1× | 1.80 ms 0.86× |
| Vuelidate | 0.80 ms 0.73× | 1.90 ms 0.9× |
| Attaform (Zod 3) | 1.10 ms 1× | 2.10 ms 1× |
| @tanstack/vue-form | 0.70 ms 0.64× | 2.50 ms 1.19× |
| vee-validate | 1.00 ms 0.91× | 4.20 ms 2× |
| FormKit | 1.20 ms 1.09× | 4.30 ms 2.05× |
| Regle (rules) | 1.10 ms 1× | 4.90 ms 2.33× |
| Regle (schema) | 1.00 ms 0.91× | 5.60 ms 2.67× |
Multi-step wizard
Most of the cohort has no wizard primitive and composes a multi-step flow by hand, so this is an expressiveness comparison as much as a timing one. Where a comparable step transition exists, here is its cost; Attaform's useWizard ships the flow as a first-class shape.
Holds the middle of the form-state field on this shape.
| Library | S4 |
|---|---|
| @formisch/vue | 0.10 ms 0.33× |
| Vuelidate | 0.10 ms 0.33× |
| Regle (rules) | 0.20 ms 0.67× |
| Attaform (Zod 3) | 0.30 ms 1× |
| Attaform (Zod 4) | 0.40 ms 1.33× |
| @tanstack/vue-form | 0.45 ms 1.5× |
| Regle (schema) | 1.00 ms 3.33× |
| FormKit | 2.80 ms 9.33× |
| vee-validate | 5.50 ms 18.33× |
Caveats
The methodology is only as good as what it admits.
- FormKit owns its inputs. It cannot drive the shared bare field, so its re-render figure is a DOM-mutation proxy, marked in the tables, and its mount and memory figures include its own component tree. It is labeled batteries-included throughout and never placed silently beside bare-input libraries.
- Heap is Chromium-quantized.
usedJSHeapSizereports rounded magnitudes, not byte-exact values, so memory figures show whole kilobytes and the slope across sizes carries more signal than any single number. - Every cell shares one time budget. Each measured cell gets the same per-cell ceiling on the CI runner, identical for every library. A cell that cannot settle to a stable median inside it (a single mount of thousands of fields, or a full-form validation at the largest sizes on the heaviest libraries) is recorded as "did not finish" rather than dropped or estimated. The ceiling is uniform across the cohort, so it marks where a shape outgrows one measurement window, never a verdict on a library.
- Bundle is the initial load, with any deferred part beside it. The build code-splits the way a real bundler does. A row's figure is the entry chunk plus every chunk it reaches through static imports, minified and gzipped, with the validator weighed in. Anything reached only through a dynamic import is weighed separately and shown beside the figure as deferred, so a row with no deferred badge loads in one chunk. Vue is external, since every app ships it once.
- The shared-validator floor is a floor, not a subtraction. The figure quoted under the bundle table is the schema and parse call every Zod row has in common. An adapter that reaches further into Zod (async parsing, issue introspection) pulls a little more than that; none pulls less. So it is the right figure for "how much of this is not the form library at all" and the wrong one for backing out an exact library-only size per row.
- An absent score has two distinct meanings. "Not published" means a project has not opted into a Scorecard, which is a choice and not a deficiency. "Unavailable" means the lookup did not complete on that run, a network gap on our side and never a statement about the project. The viewer linked on each row shows the live result either way, and scores are point-in-time.
- Local versus CI. The committed numbers come from CI rather than a developer machine, but CI is not one fixed machine. The sweep shards across runners and takes whatever hardware the host assigns that day, so a row's absolute milliseconds can move between refreshes with no code change behind it. Every row within a single table is measured on one runner, which is what keeps the comparison inside a table honest, and the ratio and slope are the figures built to survive a refresh. A figure stamped "local run" is illustrative shape data from a developer machine, superseded the next time CI refreshes the page.
Reproduce it yourself
The harness is meant to be run by hand. Clone the repo, build Attaform's real dist with pnpm prepack, then from apps/bench-arena install the browser and run the arena. The full instructions live in the bench-arena README. Every adapter mounts by query parameter, so you can also open a single library and scenario in a browser and watch it work.
Where to next
- Performance: Attaform's own hot-path numbers, measured against a per-PR regression gate.
- How values are stored: the slim write shape behind the keystroke and validation figures.
- Field-array mutations: the array-helper characteristics behind the dynamic-array rows.