A FIELD SURVEY · WINTER 2025

The frontier
of frontend

Begin the survey 06 frameworks · 12 min read
SCROLL ↓

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
counter.tsx solid · 1.8
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.

REACTIVITY FLOW
createSignal(0)
count() called in JSX
text node bound directly
setCount → only that node updates

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
counter.tsx qwik · 1.9
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.

RESUMABILITY TIMELINE
Server renders HTML + state JSON
Browser paints instantly (1 KB JS)
User clicks button
Handler chunk downloaded on demand
State resumes, mutates, repaints

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
Counter.svelte svelte · 5.0
<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.

COMPILER PIPELINE
.svelte source with runes
AST walk: locate $state / $derived
Emit signal creation + fine-grained binds
Ships ~2 KB runtime + component JS

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
index.astro astro · 5.0
---
// 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.

ISLAND HYDRATION
Static HTML shell (0 KB JS)
Island A: client:load → hydrate now
Island B: client:visible → hydrate on scroll
Island C: client:idle → hydrate when free

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
counter.html htmx · 2.0
<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.

REQUEST CYCLE
User clicks button
HTMX issues GET /api/increment
Server returns HTML fragment
Target element swapped

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
counter.rs leptos · 0.6
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.

COMPILE & RUN
Rust source + view! macro
cargo + wasm-pack → .wasm binary
Browser loads ~50 KB gzipped WASM
Fine-grained signal graph runs natively

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.

02
SolidJS
Signals, no virtual DOM, JSX you already know. The component function runs once.
2018 · 7 KB
solidjs.com
03
Qwik
Resumability. The first framework that ships zero hydration and lazy-loads handlers on demand.
2021 · ~1 KB
qwik.dev
04
Svelte 5
Runes make the compiler's reactivity explicit. $state, $derived, $effect — anywhere.
2024 · ~2 KB
svelte.dev
05
Astro
Server-first. Islands for the interactive bits. Zero JavaScript by default.
2021 · 0 KB
astro.build
06
HTMX
HTML is the application. Servers return fragments. No build step, no client state.
2020 · 14 KB
htmx.org
07
Leptos
Rust on the frontend. Type-safe at compile time, WASM-fast at runtime, fine-grained.
2022 · ~50 KB
leptos.dev

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.

END · FRONTIER · FE WINTER 2025