Back to blog

One app, seven renderers: what a kanban board taught me about every frontend era

Frontend has exactly one hard problem: keeping the DOM consistent with state. Every framework era is a different answer to who does that bookkeeping. I wanted to see the eras side by side, so I built Tiny Kanban seven times: same markup, same CSS, same behavior contract enforced by a browser-driven conformance suite, different synchronization strategy each time.

  1. Vanilla JS, naive full re-render
  2. Vanilla JS, keyed DOM reuse
  3. jQuery, render loop
  4. jQuery, hand-targeted incremental patches
  5. React 19
  6. Svelte 5 with runes
  7. SolidJS 1.9

Most public framework comparisons are unfalsifiable because nobody holds the app constant. Here the seven boards are behaviorally identical down to the acceptance checklist, so differences in the numbers are the strategies themselves, not app-design noise. Everything below comes from the repo's bench suite: production builds only, a vendored jQuery, headless Chromium, one machine.

Who keeps the DOM consistent with stateThe same synchronization problem solved five ways, from manual code to compilers and signal graphs.State changesWho syncs the DOM?You, by rebuilding everythingvanilla A, jQuery AYou, by hand-patching nodesvanilla B, jQuery BA diffing runtimeReactA compilerSvelte 5A signal graphSolidJS

Count the writes, not the vibes

The most useful instrument in the whole suite is also the cheapest: a MutationObserver that counts every DOM write each strategy issues per user action. It is deterministic - the suite runs every operation twice and the ledgers match byte for byte - so there is no benchmark noise to argue about. This is what each era actually touches on a 300-card board:

Heatmap of DOM writes per user action across seven implementations. Rebuild strategies issue about 6,000 writes per action while diffing and fine-grained strategies issue 3 to 34.
DOM writes per user action. The interactive report in the repo adds per-cell breakdowns.

Committing a single edited card title costs Svelte and Solid 3 writes, jQuery's hand-patched version 11, React 14, and the rebuild-everything versions 6,067. That is a 2,000x amplification factor for the same user intent:

Log-scale bars of DOM writes spent committing one card title, from 3 writes on Svelte and Solid to thousands on the rebuild implementations.
One logical change, log scale. Fifteen years of framework history is this number going down.

The row that taught me the most is vanilla B, the "keyed patch" version - the one that reuses DOM nodes to preserve editing focus, the way you hand-rolled identity before frameworks existed. It issues more writes than the naive version (8,904 per commit), because reusing a node is not the same as diffing it: it re-stamps every attribute with the same value on every render. Worse, its first draft had a genuine data-loss bug I only found in a real browser: replaceChildren detaches even the nodes it is about to re-insert, Chromium fires focusout on the detach, and the blur handler committed your half-typed title on every keystroke. Identity without diffing is the worst of both worlds, and the gap between them is precisely the part nobody should hand-write.

Frameworks sell floors, not ceilings

Here is the uncomfortable result. The fastest structural updates in the entire suite belong to jQuery B, the hand-patched version - and it is the only implementation whose latency stays flat as the board grows:

Latency versus board size line charts for boot, add, draft keystroke, commit, and filter. jQuery B stays flat while every other strategy rises with board size.
Latency vs board size, 100 to 10,000 cards, 4x CPU throttle, log x-axis.

Adding a card among 10,000 takes jQuery B 27ms. Solid takes 121ms, React 288ms, and the rebuild versions 366-1,182ms. Framework list reconciliation is O(N) too - it just has a far smaller constant than rebuilding. The one place fine-grained reactivity is genuinely flat is the draft-keystroke panel: typing in the edit field holds at about 1ms at every board size for five of the seven implementations, while React climbs to 112ms (a re-render per keystroke is the model working as designed) and vanilla B to 485ms.

So if hand-patching wins the benchmarks, why did the industry abandon it? Because the same jQuery B file is a museum of manually maintained invariants: re-sync the column metadata on move, re-check the empty state on delete, escape the selector, commit the open edit before starting the next one. Every operation is fast because a person did the diffing at development time, and every future change re-runs that person. What React, Svelte, and Solid actually sell is not speed - jQuery B proves the speed was always available - but a correctness floor: focus, identity, and state coherence survive the change you make six months from now. Frameworks are insurance premiums, and the premium is visible in the numbers below.

Where the CPU actually goes

A fixed 110-action editing session, identical on every implementation, with CPU time split by what the browser was doing:

Stacked bars splitting CPU time into script, style recalculation, and layout for each implementation. Rebuild strategies are dominated by layout; frameworks are dominated by script.
CPU per 110-action session, unthrottled CDP task accounting.

The rebuild versions pay in layout - vanilla A spends 1.9 seconds in layout against 0.25 for the three frameworks - while the frameworks pay in script. That split settles an old argument: "the DOM is slow" was always the wrong diagnosis. DOM API calls were never the cost; recomputing layout for thousands of nodes you did not change is. The virtual DOM was not invented because the DOM was slow. It was invented so that the correctness-friendly model - UI as a pure function of state - stayed affordable.

Bytes are time, but waterfalls beat bytes

On an emulated Fast-3G connection with everything gzipped, the byte column does not predict the ranking:

Stacked bars of cold-start time segmented into HTML, JavaScript transfer, and render. The jQuery implementations take longer to transfer 33 kilobytes than React takes to transfer 62.
Cold start to a rendered 1,000-card board: HTML, JS transfer, then parse/execute/render.

The jQuery versions ship 33 kB and spend 501ms transferring it; React ships 62 kB and spends 464ms. The difference is shape, not size: jQuery is a blocking library fetch followed by a module waterfall, each hop paying the 150ms round trip, while React is one bundle. Request topology beats raw bytes at mobile latencies. Solid ships 9.7 kB in one bundle and boots the same board in 462ms total.

The bundle-composition split (via sourcemap attribution) puts the payload story plainly: the application is ~4 kB everywhere; React wraps it in 185 kB of framework, Svelte in 35 kB, Solid in 17 kB. You ship the framework, or you ship the discipline it replaces:

Scatter plot of authored source lines against shipped gzipped kilobytes for all seven implementations.

Author cost vs user cost. All seven implementations are 188-315 lines; what varies is what rides along.

Memory is the tax on the other side of the ledger. Fine-grained reactivity keeps a signal graph per row: Solid retains 4.4 kB and Svelte 4.2 kB per card against ~140 bytes for the imperative versions, Svelte adds 4,000 anchor comment nodes to an otherwise identical DOM, and React's synthetic event system registers 4,159 listeners where the delegated implementations use about 20.

What each one felt like to build

ImplementationThe one-line verdict
Vanilla, naiveHonest and legible; its real costs (a swallowed click after blur-commit, wiped form text) are invisible until you probe them in a browser.
Vanilla, keyedThe instructive failure: identity bookkeeping without diffing is slower, noisier, and it still ate my keystrokes.
jQuery, rebuildPleasant APIs over the wrong architecture; same bugs as naive vanilla, three times the node-construction cost.
jQuery, patchedBest numbers in the suite, worst maintenance story; every invariant is a promise a future editor has to keep.
React 19The most rules to hold (reducer purity, key discipline), the most institutional support, zero tooling surprises.
Svelte 5The nicest final code - after I threw away my first version, which the compiler happily accepted as React-in-runes with none of the benefit.
SolidJSThe steepest single idea (components run once), then almost no ceremony; the friction lives in tooling edges, not app code.

Two of those deserve a sentence more. My first Svelte version reassigned immutable state through $bindable prop chains - architecturally React - and it compiled with zero warnings while silently discarding the fine-grained reactivity that is Svelte's entire pitch. Paradigm errors are not type errors; syntax transfers between frameworks instantly, architecture does not, and nothing in the toolchain tells you which one you wrote. And React's adversarial review finding was the same lesson inverted: crypto.randomUUID() inside a reducer looked idiomatic and violated purity in a way only StrictMode's deliberate double-invocation exposes.

What I actually take away

The durable skill is state modeling. One line in the spec - editing state is explicit app state, never something inferred from the DOM - did more for correctness across all seven implementations than any framework feature. Where truth lives, what is durable versus transient, what is derived versus stored: those decisions transferred unchanged across five paradigms and will outlive all of them.

Measure, because folklore does not survive instrumentation. My own claims kept dying on contact with data: "targeted patching stays flat" (framework reconciliation is O(N) too), "bytes dominate cold start" (the 33 kB waterfall lost to the 62 kB bundle), "reset should favor frameworks" (naive rebuild won; reconcilers pay for teardown). A 30-line MutationObserver ledger settled arguments that a decade of blog posts could not, and it runs deterministically in CI.

The complexity moved to the meta-layer. All seven apps are a few hundred lines and roughly equally pleasant. The genuine friction of this project was between npm and the bundler: a Vite plugin claiming the global JSX transform, a resolve alias bypassing package export conditions and silently loading a server build, lint rules that assume every JSX file is React. The ecosystem's tooling quietly presumes React, and every other choice pays a small permanent toll.

The eras converge. Svelte 5's runes and Solid's stores produce nearly identical application code - module state, direct mutation, one persistence effect - and React is compiling its way toward the same destination. The ledger explains why: 6,067 writes, then 14, then 3, for the same click. There was only ever one problem.