The React era did not end with a bang. It ended with a thousand kilobytes of hydration script, with a virtual DOM reconciling trees we never needed to build, with Lighthouse scores that quietly stopped climbing.
What follows is a survey of six frameworks that each, in their own way, refuse the React bargain. Some compile the runtime away. Some never ship one. Some argue the server never left the room.
Fine-grained reactivity,
without a virtual DOM
Solid looks like React, ships like Svelte, and runs like compiled C. The component function executes exactly once; from there, signals carry the load.
- Bundle7.1 KB
- Runtime overhead~0 KB
- ReactivitySignals (fine-grained)
- AuthorRyan Carniato
- First release2018
- LicenseMIT
import { createSignal } from 'solid-js';
export function Counter() {
// runs once, never again
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount(c => c + 1)}>
{count()} // ← read site, not the value
</button>
);
}
Approach
The component body is a setup function, not a render function. It runs exactly once. JSX is compiled into direct DOM operations, and reactive reads (count()) wire themselves into a dependency graph at the moment they execute.
When the signal changes, only the specific text node that read it updates. No diffing. No reconciliation. No virtual tree.
No diffing. No re-render. The signal knows exactly which DOM nodes depend on it.
"The framework that disappears. You write JSX, the compiler emits the minimum DOM operations the browser actually needs."
— Ryan Carniato
Trade-offs
The mental model is unfamiliar: destructuring a signal loses its reactivity, and store updates require function-form setters. Ecosystem is smaller than React's, and not every React library ports cleanly — hooks that depend on re-renders simply don't translate.
Resumability.
The first framework
that ships zero hydration
Qwik's bet: HTML is a paused program. Resume it on the server's behalf — lazy-load the JavaScript for any handler only when the user actually triggers it.
- Initial JS~1 KB
- HydrationNone (resumable)
- ReactivityLazy signals
- AuthorMiško Hevery
- First release2021
- LicenseMIT
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
);
});
Approach
Every $ suffix marks a "resumable" boundary — a place where execution can pause and serialize to JSON. The server renders HTML, embeds the serialized state, and the client wakes the application only when an event fires.
The handler for a button click is downloaded at the moment of the click. Not before. The initial page ships ~1 KB of Qwik loader; everything else streams on demand.
No replay. No re-execution of component tree. State is resumed, not rebuilt.
"Hydration is a tax we pay for picking the wrong execution boundary. Qwik moves the boundary to where it always belonged — the user's intent."
— Miško Hevery
Trade-offs
The $ sigils and component$ wrapping are visual noise at first, and the optimizer is doing serious work behind the scenes — debugging requires understanding what got serialized where. SSR is essentially mandatory; pure-CSR Qwik is a degraded mode.
Runes make
the compiler's reactivity
explicit
Svelte 5 retires the implicit let = reactive heuristic and adopts runes — $state, $derived, $effect — that name exactly what's reactive and how.
- Runtime~2 KB
- ParadigmCompiler + runes
- Reactivity$state / $derived / $effect
- AuthorRich Harris
- First release2016 (v5: 2024)
- LicenseMIT
<script>
let count = $state(0);
let doubled = $derived(count * 2);
function increment() {
count++;
}
</script>
<button onclick={increment}>
{count} · doubled: {doubled}
</button>
Approach
The compiler walks the AST, finds rune calls, and replaces them with fine-grained signal wiring. The developer writes syntax that looks almost like plain JavaScript; the compiler emits surgical DOM updates.
Svelte 4's reactivity was tied to let declarations and the $: label — elegant but implicit, and it broke outside .svelte files. Runes work anywhere: in .svelte.js modules, in nested helpers, in shared stores. The reactivity travels.
The framework is the compiler. What you ship is what the browser needs, not what the developer wrote.
"The compiler is the framework. Runes are how we tell it the truth about what's reactive — instead of hoping it infers correctly."
— Rich Harris
Trade-offs
The compiler is a black box: when it misbehaves, the debugging surface is the generated output, not your source. SvelteKit is the assumed deployment shape; using Svelte outside it is possible but feels like swimming against the current. And the ecosystem, while growing, still measures itself against React's gravity well.
Server-first.
Islands for the
interactive bits.
Astro ships zero JavaScript by default. Interactivity arrives as discrete "islands" — pinned to specific components, hydrated only when needed.
- Default JS0 KB
- ArchitectureIslands
- Multi-frameworkReact, Vue, Svelte, Solid…
- AuthorFred K. Schott
- First release2021
- LicenseMIT
---
// server-only by default
import { getPosts } from '../lib/posts';
import Counter from '../components/Counter.svelte';
const posts = await getPosts();
---
<h1>{posts.length} entries</h1>
<Counter client:visible />
<ul>
{posts.map(p => <li>{p.title}</li>)}
</ul>
Approach
An .astro file is a server template with two phases: a frontmatter script that runs at build (or request) time, and JSX-like markup that renders to static HTML. JavaScript ships only where you explicitly ask for it via client directives.
The killer feature: each island can be a different framework. A React chart, a Svelte form, a Solid player, all on one page, hydrated independently — client:visible, client:idle, client:only. The page itself stays static.
Each island owns its own hydration strategy. The shell never hydrates at all.
"Ship less JavaScript. It sounds like a joke; it's the entire thesis. The framework's job is to make the default the right answer."
— Fred K. Schott
Trade-offs
Mixing frameworks is power, not free performance — three runtimes cost more than one. Astro is best when most of the page is genuinely static; richly interactive apps end up fighting the model. And the island boundaries become architectural decisions you live with.
Hypermedia
as the engine
of application state.
HTMX returns to the original contract: servers return HTML, browsers render it. Interactivity arrives through attributes, not a parallel runtime.
- Bundle14 KB
- Build stepNone
- ParadigmHypermedia-driven
- AuthorCarson Gross
- First release2020
- LicenseBSD-2
<button
hx-get="/api/increment"
hx-target="#count"
hx-swap="innerHTML"
>
Increment
</button>
<span id="count">0</span>
/* server returns plain HTML: */
/* "1" → "2" → "3" → ... */
Approach
HTMX extends HTML with attributes that issue AJAX requests and swap fragments of the DOM with the response. There is no client-side state model — the server is the source of truth, and HTML is the protocol.
Want a button that mutates a counter? The server returns the new count as HTML. Want a sortable table? The server returns a new <tbody>. The contract is so simple it sounds regressive, and that is exactly the point.
No JSON. No client serializer. No state model. The DOM is the state.
"HTML is the application. Not the view layer, not a template — the application. We forgot that for ten years and called the forgetfulness progress."
— Carson Gross
Trade-offs
Round-trip latency is the floor — every interaction pays a network cost. Optimistic UIs are harder. And the moment you need rich client logic (drag-and-drop, canvas, complex form validation), HTMX steps aside for vanilla JS or Alpine — the boundary is honest but real.
Rust on the frontend.
Type-safe, WASM-fast,
fine-grained.
Leptos brings Solid's reactive model to Rust + WebAssembly. The same fine-grained signals, the same compile-time guarantees, with a binary that runs at native speed in the browser.
- WASM binary~50 KB (gzipped)
- ReactivitySignals (fine-grained)
- LanguageRust → WASM
- AuthorGreg Johnston
- First release2022
- LicenseMIT
use leptos::*;
#[component]
fn Counter() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<button
on:click=move |_| set_count.update(|c| *c += 1)
>
{count}
</button>
}
}
Approach
Leptos compiles to WebAssembly. The view! macro expands at compile time into DOM operations, not a virtual tree. Signals are Rust types; the borrow checker enforces ownership of reactive values at compile time.
You get the same fine-grained updates as Solid — a signal write mutates only the DOM nodes that read it — plus Rust's type system, no null, no undefined, no runtime shape errors. The trade is a steeper on-ramp and a WASM binary that must initialize before first paint.
Type safety at compile time. Native-speed execution. The cost is the WASM init tax on first load.
"The browser is a VM. We treated it as a JavaScript VM for twenty years. Rust lets us treat it as a real one."
— Greg Johnston
Trade-offs
The Rust learning curve is real — the borrow checker is unforgiving, and reactive patterns often require move closures that take getting used to. WASM binary size and initialization time are higher than JS frameworks. SSR works but is more intricate to operate than a Node server.
Six frameworks, six bets on what comes after the virtual DOM.
| Framework | Paradigm | Initial JS | Reactivity Model | Origin |
|---|---|---|---|---|
| SolidJS | Compiled JSX | 7 KB | Signals (fine-grained) | 2018 · USA |
| Qwik | Resumable | ~1 KB | Lazy signals | 2021 · USA |
| Svelte 5 | Compiler + runes | ~2 KB | $state / $derived | 2016 · UK |
| Astro | Islands | 0 KB default | None (server-first) | 2021 · USA |
| HTMX | Hypermedia | 14 KB | None (HTML attrs) | 2020 · USA |
| Leptos | Rust + WASM | ~50 KB | Signals (fine-grained) | 2022 · USA |
Six frameworks at a glance. Hover any row to dwell.
The virtual DOM was never the point.
It was a compromise — a buffer between a language that couldn't express reactivity and a DOM that demanded it.
Six frameworks, six different ways of removing that buffer. Some compile it away. Some never ship it. Some argue it was always the server's job.
None of them are React. All of them are answering the same question.