Before moving a data-heavy admin application off React, I built the same heavy feature three times, in React 19, Svelte 5 and SolidJS, and measured all three through one harness against a rubric I froze before seeing any results. Svelte won. Then I spent months making the resulting app fast, and discovered that almost everything I had assumed about where the time was going was wrong.
The bake-off: unthrottled, React and Svelte tie (103 ms vs 98 ms to interactive). At 6× CPU throttle, real hardware, Svelte reaches interactive in 357 ms against React's 634 ms, at less than half the JavaScript and identical memory.
The optimisation work: payload size was a red herring. An 18 KB response and a 435 KB response reached first content within 2% of each other. The real costs were round trips and DOM volume, eliminating a blocking re-fetch took revisits from ~560 ms to 9–15 ms, and virtualisation took a 2,000 ms main-thread task down to 165 ms.
Why we left React
The application before was React and Next.js, and it was not broken. Three arguments made the move worth testing:
- Svelte compiles the framework away. There is no virtual DOM shipped to the browser and no reconciliation happening at runtime. What arrives is closer to the DOM operations you would have written by hand. When your target is sub-100 ms navigation, starting with less runtime work is a structural advantage rather than a micro-optimisation.
- The design language survived the move. A shadcn-for-Svelte port meant we kept the same component and design system we had under React. Nothing was lost on the UI side, which removed the usual argument against migrating.
- Runes are a better foundation for what we needed. Svelte 5's
$stateand$derivedgive fine-grained signal reactivity, and the client-side caching and optimistic-update layers described below are built directly on top of that. In a coarser reactivity model those layers are harder to keep correct.
The controlled bake-off: React 19 vs Svelte 5 vs SolidJS
Before committing to the migration I ran a controlled bake-off, because "which framework is fastest" is usually answered with a to-do-list demo on a fast laptop and I did not want to make an expensive decision on vibes.
The setup: one realistic heavy feature, an authenticated Customer-360 workbench with a virtualised 20,000-row grid, debounced server-side search, faceted filters, optimistic inline edit with a deliberately server-rejected rollback, a cross-service detail panel, and a live server-sent-event invalidation bus, implemented three times, in React 19, Svelte 5 and SolidJS. Same backend, same 20k seed data, same design tokens, same harness, same pinned browser at a fixed viewport on one machine. Solid was included as a near-vanilla-JS runtime upper bound rather than as a serious hiring candidate.
The fairness controls
A bake-off is only worth as much as its controls, and these were the load-bearing ones:
- Equal DOM, asserted before timing. Steady-state node count, overscan and keying were checked equal across variants first, otherwise the test measures grid libraries, not frameworks.
- The rubric was frozen before any numbers existed. Runtime 40%, maintainability and hireability 25%, build and CI 20%, design and accessibility 15%. No result could retroactively reshape the criteria.
- Time-to-interactive measured post-authentication, so the login round trip never contaminated the runtime figure.
- A fixed-latency mock backend for the speed board to remove database jitter, with a separate read-only pass against the real backends to prove writes never bypassed them.
- Four CPU-throttle tiers, cold and warm reported separately, repeats with median, p75 and p95, because fast machines hide exactly the gap you are trying to measure.
Time to interactive, by CPU throttle
The gap opens under load. React and Svelte are indistinguishable at 1×, and 277 ms apart at 6×. Solid climbs off the top of the field at every tier.
| CPU throttle | React 19 | Svelte 5 | SolidJS |
|---|---|---|---|
| 1× (unthrottled) | 103 | 98 | 454 |
| 2× | 225 | 176 | 833 |
| 4× | 326 | 306 | 1,587 |
| 6× (older laptop) | 634 | 357 | 2,293 |
At 6× throttle: the tier that matches real hardware
Bar length is proportional to milliseconds, shorter is better.
Bundle and memory
| Variant | Bundle (gzip) | Build | Heap |
|---|---|---|---|
| React 19 | 79.15 KB | 675 ms | 64.8 MB |
| Svelte 5 | 36.99 KB | 855 ms | 64.8 MB |
| SolidJS | 28.68 KB | 827 ms | 104–132 MB |
Solid ships the smallest bundle, as you would expect from a near-vanilla runtime, but it paid for that with the worst time-to-interactive at every tier and roughly double the memory. Svelte lands on the practical sweet spot: under half of React's JavaScript at identical memory.
Where nothing separated them
Worth reporting the null result too. Steady-state interaction was excellent in all three: interaction p75 around 16 ms and roughly 60 FPS scrolling everywhere, far inside the 100 ms bar. Under throttle Svelte held the fewest long frames and the fastest view switches, but the honest summary is that once loaded, all three feel fine. The separation is in getting loaded, and in behaviour under CPU pressure.
The verdict, and what it does not mean
Svelte 5 won the frozen rubric, ahead on runtime under throttle, comfortably inside the bundle budget, and idiomatic enough that the React architecture ported over without harness changes, while remaining a mainstream and hireable choice in a way Solid is not. React held its own at light load and passed every interaction bar; this is not a claim that React is bad.
Solid's poor showing deserves a qualifier, which I have put in the caveats at the end. The durable findings here are the directions rather than the absolute milliseconds: Svelte degrades more gracefully under CPU pressure, ships meaningfully less JavaScript, and Solid pays for its small bundle in memory.
One more thing the bake-off surfaced, which turned out to matter more than the framework choice: the biggest wins came from architecture applied identically to every variant. Menus opening from already-loaded state instead of fetching on open, modules code-split and prefetched on hover through a single-flight loader, visited modules staying mounted so a view switch is a one-frame visibility flip rather than a re-virtualisation, and entitlement enforced structurally so a restricted module has no reachable import() at all. With that layer in place, every shell control resolved under 35 ms p75 at both 1× and 6× CPU, in both React and Svelte. The throttle barely moved those numbers, which is the signature of painting from state you already hold.
So: the framework sets the floor, the architecture decides where you land. The rest of this article is that architecture, measured on the SvelteKit application we ended up with. The harness and the raw tables are reproducible if you want to argue with the numbers.
How I measured
Every number here came from a Playwright harness driving headless Chromium through real navigation and reading timings off the page, not from a synthetic microbenchmark.
- Injected latency. A round-trip-time variable set to 0, 230 or 300 ms, combined with Chrome DevTools Protocol throttling at 25 Mbit and 8 Mbit, covering everything from localhost to a bad cross-continent connection.
- Three regimes measured separately. Cold hard load, warm client-side navigation, and revisiting an already-loaded route. They behave completely differently and averaging them hides the interesting parts.
- Instruments. Performance-timing deltas from navigation to first rendered card, node counts via
querySelectorAll, long-task and main-thread timing, and a heap sampler. - Compressed sizes. All payload figures are brotli or gzip, what actually crosses the wire, not raw JSON size.
Payload size was not the bottleneck on this route
The obvious suspect was payload size. The heaviest view serialised about 900 records into 435 KB, which felt indefensible. So I trimmed it to 18 KB and measured both under identical conditions.
| Scenario | Payload | RTT / bandwidth | First card |
|---|---|---|---|
| Cold, localhost | 435 KB | 0 ms | 461 ms |
| Cold, mid latency | 435 KB | 230 ms | 1,789 ms |
| Cold, cross-continent | 435 KB | 230 ms / 25 Mbit | 6,170 ms |
| Same view, trimmed | 18 KB | 230 ms / 25 Mbit | 6,060 ms |
| Cold, worse link | 435 KB | 300 ms / 8 Mbit | 7,980 ms |
| Same view, trimmed | 18 KB | 300 ms / 8 Mbit | 7,822 ms |
Cutting the payload by a factor of 24 moved first-card time by roughly 2%. Whatever was costing seconds, it was not the bytes.
A deliberately empty server render
SvelteKit's load in +page.server.ts is the standard place to fetch data, but we do the opposite of the standard thing: the server load returns nulls and ships no heavy data in the SSR document at all.
What goes out is a 4.3 KB brotli shell with a 29 ms TTFB. The skeleton paints at 153 ms, and the real data is fetched client-side immediately afterwards. Server-side rendering stays on, but it is skeleton-driven: a hard reload paints structure rather than a blank page, and client-side navigation never shows a skeleton at all because the data is usually already cached. For routes where even a reload skeleton was unwanted, ssr=false was available per route.
| Metric | Value |
|---|---|
| SSR document (brotli) | 4.3 KB |
| Document TTFB | 29 ms |
| First paint (skeleton) | 153 ms |
| Database query at the endpoint | 22 ms |
The endpoint itself was verified not to be the bottleneck, a 22 ms query is not what makes a page take six seconds.
Client-side caching: the instant-revisit win
If round trips are the enemy, the largest available lever is never making the same one twice. Returning to a route now serves from an in-memory cache seeded from IndexedDB, and the blocking re-fetch is simply gone.
| Metric | Before | After |
|---|---|---|
| Revisit, time to first card | 553 / 560 / 567 ms | 12 / 9 / 15 ms |
| Revisit, full route settled | 560 ms | 51 ms |
| Network on revisit | 1 × 253 KB blocking fetch | no heavy fetch |
Two different metrics. Time to first card, the point where the user sees content, drops from about 560 ms to 9 to 15 ms. The full route settling, including the remaining columns, lands at 51 ms. Both come from removing a request rather than making one faster.
The cache is scope-keyed snapshots persisted to localStorage and IndexedDB with a five-minute TTL. There is a race guard: each hydrate bumps a request counter and nulls the in-flight handle, so a slow older response cannot overwrite a newer one, the same stale-response bug that shows up in every debounced autocomplete ever written, just at a different scale. Navigation intent is anticipated with prefetch-on-hover, and writes are optimistic: a status change repaints in 1–2 ms, long before the server confirms it.
Payload discipline anyway
Payload size was not the latency bottleneck, but trimming it still cut parse time, memory and transfer cost, and it was cheap to do. Two techniques, both measured:
| Technique | Before | After | Factor |
|---|---|---|---|
Field projection (?fields= instead of ?include=) | 284 KB | 52 KB | 5.4× |
| Lean row shape (fat entity → 3 fields) | 14.5 KB | 3.2 KB | 4.5× |
Requesting only the fields the view renders shrank a 50-record list by 5.4× and rendered identically. Dropping one unused field from an owner projection was worth another 11 KB.
The principle worth keeping: the server should project the shape of the view, not the shape of the database.
DOM volume is the real cost
Once data arrived instantly, what remained was pure rendering, and this is where the second surprise showed up. A full board mounted 20,769 DOM nodes, which is a roughly 2,000 ms main-thread task on its own. Not a reactive-effect loop, not a framework inefficiency. Just node volume. No framework saves you from this.
The fix was virtualisation plus per-column lazy rendering: render the first page in each column and defer the rest.
| Metric | Before | After |
|---|---|---|
| First cards | 4,615 ms | 1,185 ms |
| Scroll to maximum | 2,130 ms | 165 ms |
| Cards mounted initially | ~75 | ~15 per column |
The final DOM is byte-identical. Only the timing of mounting changed. Steady-state heap held at 48–50 MB and interaction latency stayed at INP p75 under 100 ms.
Why a BFF makes aggressive caching safe
The frontend never talks to a database. Every data path runs through a backend-for-frontend layer: the SvelteKit app calls its own /api/* routes, which proxy to a backend service that owns the PostgreSQL access. No ORM lives in the frontend. A separate mail surface speaks JMAP through its own BFF, selecting per-account credentials from a request header so no secret ever reaches the client.
Field projection, caching hints and rate limiting all live at the BFF, which is both the single source of truth and the security boundary. That is what keeps credentials and database access off the client entirely. It does not, by itself, make cached customer data safe: anything sitting in IndexedDB is still exposed to cross-site scripting, to shared devices, and to stale authorisation if logout does not clear it. So the cached fields are minimised and scope-keyed, they expire after five minutes, and they are purged on logout and on any authorisation change.
Takeaways
- Measure before optimising. The obvious culprit was wrong. 18 KB and 435 KB behaved identically under latency, and I would have spent weeks on serialisation otherwise.
- Round trips dominate. On real networks, RTT and blocking re-fetches set the ceiling. Removing the second fetch was worth roughly 550 ms on revisit; shrinking the first was worth about 150 ms on a cold load.
- Keep SSR thin. A near-empty 4.3 KB document plus a skeleton plus a client hydrate beat shipping data inside the HTML.
- DOM volume is a real, framework-independent cost. Twenty thousand nodes is a two-second task in anything. Virtualization is mandatory at scale.
- Project the view, not the table. A 5.4× payload cut with an identical render, for very little work.
- Benchmark under throttle, not on your laptop. React and Svelte were indistinguishable unthrottled and 277 ms apart at 6×. A fast machine would have told me the choice did not matter.
- The framework sets the floor; architecture decides the outcome. Svelte gave us a lower baseline and a reactivity model that made the caching layer straightforward. But the instant-UI layer hit under 35 ms p75 in React too, so the framework did not make the app fast on its own.
Caveats
The bake-off's time-to-interactive figures come from a fixed-latency mock backend measured post-authentication on a single machine, with the real backends used only in a separate read-only correctness pass. Absolute milliseconds are environment-specific. The runtime dimension is objective; maintainability and design were scored with a manual change probe against the frozen rubric.
Solid's poor time-to-interactive is specific to this application shape, a heavy authenticated shell with a virtualised grid, and should not be read as a general verdict on Solid. Treat the directions as the durable findings rather than the exact numbers: Svelte's graceful degradation under throttle, its bundle advantage, and Solid's memory cost.
Frequently asked questions
Is Svelte faster than React?
Under CPU load, yes. In a controlled bake-off where the same heavy feature was built three times behind an identical backend and harness, React and Svelte tied unthrottled at 103 ms and 98 ms to interactive. At 6× CPU throttle Svelte reached interactive in 357 ms against React's 634 ms, shipping 37 KB of gzipped JavaScript against React's 79 KB at identical memory. Once loaded, all three frameworks felt fine, interaction p75 around 16 ms everywhere. The separation is in loading and in behaviour under CPU pressure.
How was the framework comparison made fair?
One realistic heavy feature, an authenticated workbench with a virtualised 20,000-row grid, server-side search, optimistic edits and a live event stream, was implemented in React 19, Svelte 5 and SolidJS behind the same backend, the same seed data and the same harness. Steady-state DOM node count and keying were asserted equal before any timing, time-to-interactive was measured post-authentication, and the scoring rubric was frozen before any result existed.
Where does SolidJS land?
It shipped the smallest bundle at 28.68 KB gzip, as expected from a near-vanilla runtime, but had the worst time-to-interactive at every throttle tier, 2,293 ms at 6× against Svelte's 357 ms, and roughly double the memory at 104–132 MB. That result is specific to this application shape, a heavy authenticated shell with a virtualised grid, and is not a general verdict on Solid.
Does payload size matter for perceived speed?
Far less than most people assume. Under a 230 ms round trip, cutting the response from 435 KB to 18 KB moved time to first rendered card by 110 ms on one link and 158 ms on a slower one, about 2%. Round trips and blocking re-fetches dominate; payload size mainly affects parse time and memory.
What made the biggest difference?
Client-side caching. Serving a revisited route from an in-memory cache seeded from IndexedDB took time to first card from roughly 560 ms to 9 to 15 ms, achieved by removing a request rather than by speeding one up.
Should SvelteKit load functions return data from the server?
Not always. Returning nulls and fetching client-side produced a 4.3 KB SSR document with a 29 ms TTFB and a skeleton painted at 153 ms, which beat shipping data inside the HTML for this data-heavy admin application. For content-driven pages the usual advice still applies.
Why does a large DOM slow the page down?
Mounting nodes costs main-thread time regardless of framework. A board with 20,769 nodes was a roughly 2,000 ms task on its own. Virtualization and lazy rendering reduced scroll-to-maximum from 2,130 ms to 165 ms while producing an identical final DOM.
What is a BFF and why use one?
A backend-for-frontend is a proxy layer the frontend calls instead of talking to a database directly. It owns the credentials and database access, and hosts field projection, caching hints and rate limiting. It keeps credentials and database access off the client entirely. It does not make browser-side caches safe on its own: anything in IndexedDB is still exposed to cross-site scripting and to shared devices, so cached fields are minimised, scope-keyed, expired quickly and purged on logout.