v1.3.0 MIT · zero dependencies · inspired by Qwik

Server-rendered HTML, wired on intent.

CycleWire makes the HTML your server already rendered interactive, without hydration. One tiny listener per event type; the code behind a button loads when someone reaches for it. Works with Laravel, Rails, Django, Astro, Web Components, or any other stack.

Get started Read the docs
npm i cyclewire
5.2 kBcore, brotli
0hydration passes
0runtime dependencies
Anybackend or framework
How it works

The server renders. CycleWire only connects the wires.

Qwik's loader architecture, without Qwik's compiler: markup carries the intent, one listener per event type does the rest.

  1. Server renders HTML

    Your markup is the UI and works before any JavaScript: links link, forms submit. Intent is written as cw-action="cart#add".

  2. One listener per event

    A single delegated listener serves every button on the page, including content added later by any framework.

  3. Intent preloads

    Hover, focus or touch fetches the action with modulepreload: downloaded and compiled, not run.

  4. Interaction runs

    On click the module is ready. CycleWire yields so the press paints, then runs your handler with an abort signal.

Live demos

Try it, and keep an eye on the network panel.

Every demo below is plain server-rendered HTML. Each one pulls in its own action module, and dom, morph or signals where it needs them, only when you use it.

01

Fetched on intent, run on click

The like button's code is not on the page yet. Hover or focus the button and like.js starts downloading; click and it runs.

Markup and action
<button cw-action="like" aria-pressed="false">
    ♥ Like <span class="like__count">128</span>
</button>
// actions/like.js
export function run({ element }) {
    const pressed = element.getAttribute('aria-pressed') === 'true';
    element.setAttribute('aria-pressed', String(!pressed));
    const count = element.querySelector('.like__count');
    count.textContent = Number(count.textContent) + (pressed ? -1 : 1);
}
02

Search as you type

Input runs in restart mode: every keystroke aborts the stale run's signal. Results are rendered with the context-escaping html template from cyclewire/dom, which loads with the first keystroke.

Markup and action
<input type="search" cw-action="search" cw-debounce="120">
<ul id="results"></ul>
// actions/search.js
import { html, swap } from 'cyclewire/dom';

export async function run({ element, signal }) {
    const response = await fetch(`/search?q=${element.value}`, { signal });
    const hits = await response.json();
    swap(results, html`${hits.map((hit) => html`<li>${hit.name}</li>`)}`);
}
03

Wakes up when it scrolls into view, styles included

This card has a visible trigger. As it approached the viewport, its module was fetched with cyclewire/dom and cyclewire/css, and the chart's stylesheet applied before the bars were drawn from the real sizes.json. None of it was on the page before.

The chart module loads when this card comes into view.

Markup
// actions/chart.js
import { css } from 'cyclewire/css';
import { html, swap } from 'cyclewire/dom';

export async function run({ element, signal }) {
    const [sizes] = await Promise.all([
        fetch('./dist/sizes.json', { signal }).then((response) => response.json()),
        css('./styles/chart.css', element), // applied before the bars are drawn
    ]);
    swap(element.querySelector('.chart__bars'), html`…`);
}
<article cw-action="chart" cw-trigger="visible">
    <!-- server-rendered placeholder -->
</article>

<div cw-action="analytics" cw-trigger="idle"></div>
<nav cw-action="menu#compact" cw-trigger="media:(max-width: 40em)"></nav>
04

State that resumes, not re-renders

The badge below was rendered by the server. On your first click, cyclewire/signals reads the serialized store and wires only the bindings that depend on it.

0 $0.00
  • Wire mug$12
  • Spark tee$24
  • Signal sticker$3
Nothing yet.
Markup and action
<script type="application/json" cw-store="cart">{"items": []}</script>

<span cw-bind="text: $cart.count">0</span>
<button cw-action="cart#add" cw-props='{"name": "Wire mug", "price": 12}'>Add</button>
// actions/cart.js
import { store } from 'cyclewire/signals';

export function add({ props }) {
    const cart = store('cart', { get count() { return this.items.length; } });
    cart.items.push(props); // the badge updates itself
}
05

Morph server HTML, keep the state

Type a note next to a name, then refresh. The new standings come from the server as HTML; morph() moves the existing rows (with moveBefore() where available) so your note, and even your cursor, travel with them.

  1. 1Ada98
  2. 2Grace91
  3. 3Alan87
  4. 4Hedy80
  5. 5Linus74
Action
// actions/board.js
import { html } from 'cyclewire/dom';
import { morph } from 'cyclewire/morph';

export async function refresh({ signal }) {
    const response = await fetch('/partials/board', { signal });
    await morph(board, html.raw(await response.text()), { transition: true });
}
06

Inside a shadow root

A server-rendered web component with a declarative shadow root. submit never leaves a shadow root, so CycleWire observes it: this page starts with { shadow: true }.

Markup
<cw-greeter>
    <template shadowrootmode="open">
        <form cw-action="shadow#greet">…</form>
    </template>
</cw-greeter>
start({ actions, shadow: true }); // or observe(host.shadowRoot)
07

Requests from markup, no JavaScript of yours

The button asks the server for the next page of this list and appends it, as htmx would; a <cw-stream> message in the same answer updates the button, which keeps focus. cyclewire/request is an action like any other, so its code arrives when you reach for the button. The answers here are static files.

  • 1.2.0 cyclewire/request: links, forms and buttons that fetch HTML, declared in markup
  • 1.2.0 cyclewire/early: taps made before CycleWire starts, kept and run once it does
  • 1.2.0 A listener only for the event types a page binds
Markup and answer
<ul id="feed">…</ul>
<button id="feed-more" cw-action="request" cw-get="./partials/feed-2.html"
        cw-target="#feed" cw-swap="append">Load older releases</button>

<!-- ./partials/feed-2.html: the next items, and a message for the button -->
<li><b>1.1.0</b> cyclewire/stream …</li>
<cw-stream op="morph" target="feed-more"><template>
    <button id="feed-more" cw-action="request" cw-get="./partials/feed-3.html" …>…</button>
</template></cw-stream>
start({ actions: { request: () => import('cyclewire/request') } });
Quick start

Three steps, any stack.

Mark up the intent, write the action as a plain ES module, register it. Pick how you load CycleWire:

No build step. Map action names to URLs in a JSON block placed before the script.

<button cw-action="cart#add" cw-props='{"sku": "wire-01"}'>Add to cart</button>

<script type="application/json" data-cyclewire>
    { "actions": { "cart": "/js/actions/cart.js" } }
</script>
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.global.min.js" defer></script>
npm install cyclewire
import { start, fromGlob } from 'cyclewire';

start({
    // Vite: every file in ./actions becomes its own chunk.
    actions: fromGlob(import.meta.glob('./actions/**/*.js')),
});

Native ES modules from a CDN, with the optional modules one import away.

<script type="importmap">
    { "imports": {
        "cyclewire": "https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.min.js",
        "cyclewire/dom": "https://cdn.jsdelivr.net/npm/cyclewire@1/dist/dom.min.js"
    } }
</script>
<script type="module">
    import { start } from 'cyclewire';
    start({ actions: { cart: './js/actions/cart.js' } });
</script>

Boot with the core alone. Import an optional module from the action that needs it, and it downloads with that action: this page works exactly like that.

// actions/refresh.js: cyclewire/dom and cyclewire/morph arrive with this action
import { html } from 'cyclewire/dom';
import { morph } from 'cyclewire/morph';

export async function run({ element, signal }) {
    const response = await fetch(element.dataset.url, { signal });
    await morph(document.querySelector(element.dataset.target), html.raw(await response.text()));
}
Reference

Everything fits on one screen.

Short cw-* attributes that pass through JSX and every template language, with a prefix you can change. The full reference lives in the docs.

AttributeDoes
cw-actionRuns on the element's natural event: submit, input, change, toggle or click
cw-on-<event>Runs on any delegated event, e.g. keydown or command
cw-triggerload, idle, visible, media:(…)
cw-preloadintent (default), visible, idle, load, none
cw-propsJSON for the handler, as ctx.props
cw-concurrencydrop, restart, latest, parallel
cw-once · -debounceOne successful run · wait for a pause
cw-preventExplicit preventDefault(); links are never prevented otherwise
cw-ignoreStop the search for bindings: for user content
JavaScriptDoes
start(options)Delegate events, activate triggers, watch the DOM
register(map)Name → loader function, URL or import-map specifier
run(name, el)Run an action through the same pipeline
observe(shadowRoot)Handle events that never leave a shadow root
preload(name)Fetch a module ahead of time
loaded()Modules imported so far ([] on load)
listen(types) · scan(root)More event types · activate a subtree
use(plugin) · stop()Extend the context · tear everything down
handler ctxevent, target, element, signal, props, action, wire
Modules

Batteries you only pay for when you import them.

The core covers activation on its own: events, forms, triggers, preloading, concurrency and shadow DOM. Add a module when a feature needs it, ideally from the action that uses it. The modules guide has core-only recipes and a decision table.

ImportWhat it doesbrotli
cyclewireDelegation, registry, triggers, preloading, concurrency, lifecycle events, shadow DOM, plugins5.2 kB
cyclewire/cssStylesheets that arrive with the actions that need them: { module, css } entries and css(), shadow roots included0.6 kB
cyclewire/domhtml templates escaped by context, inert fragment(), swap(), View Transitions, Trusted Types2.2 kB
cyclewire/morphDOM morphing that keeps elements, focus and input: the virtual-DOM job without the virtual DOM2.2 kB
cyclewire/signalsSignals, reactive stores, cw-bind, resumed lazily from server state3.4 kB
cyclewire/streamHTML messages from the server that change the page, over Server-Sent Events, a WebSocket or in any response; includes dom and morph3.8 kB
cyclewire/prefetchData fetched on intent next to the action's code (cw-prefetch), taken by ctx.fetch: no code-then-data waterfall0.5 kB
cyclewire/requestLinks, forms and buttons that fetch HTML and put it into the page (cw-get, cw-target, cw-swap), as an action that loads when it is first used3.7 kB
cyclewire/earlyTaps and typing before CycleWire starts, shown as pending and run once it does; inline at the top of <head>0.4 kB
cyclewire/bootstrapBootstrap 5's data API (modal, dropdown, collapse, offcanvas, tabs, alerts) without its JavaScript2.1 kB
Why

No hydration. No virtual DOM. No lock-in.

No hydration

The server's HTML is already correct. CycleWire never re-runs components to attach listeners; one delegated listener per event type is enough. Nothing heavy runs on load, so a tap waits only for its own action's code, which you can preload.

No virtual DOM

A virtual DOM re-renders server HTML on the client just to diff it. morph() compares DOM to DOM instead, and html parses into an inert <template> and inserts a DocumentFragment in one go.

No lock-in

Actions are plain ES modules. The markup is plain HTML. Remove CycleWire and your links still link and your forms still submit, because it only prevents what it knows it can handle.

Benchmark

One store page, built with each stack, measured the same way.

A product listing with live search, category filters, a quick view and a newsletter form, built with each stack the way its documentation recommends. Every build must show the same text before anything is measured. CycleWire's authors run it in Chromium on GitHub's runners, so the apps, the runner and the raw data are all in the repository. There is no overall score.

15 iterations on 2026-10-01 · AMD EPYC 7763 64-Core Processor, 4 cores, GitHub's hosted runner · Chrome 153.0.8010.12 · commit 5acd100 · raw data (JSON)

JavaScript downloaded on load · median and 95% confidence interval · lower is better
  1. Static HTML0.0 kB
  2. Vanilla JS1.2 kB
  3. CycleWire, inline3.6 kB
  4. Marko4.8 kB
  5. CycleWire, intent only6.0 kB
  6. CycleWire9.0 kB
  7. CycleWire, requests from markup9.1 kB
  8. Astro + Preact13.1 kB
  9. htmx15.4 kB
  10. Alpine.js18.0 kB
  11. SolidStart31.1 kB
  12. Hotwire (Turbo + Stimulus)31.9 kB
  13. SvelteKit32.7 kB
  14. Qwik City37.5 kB
  15. Nuxt70.9 kB
  16. Angular87.5 kB
  17. Next.js, client filtering119.2 kB
  18. Next.js120.1 kB

Mobile: medians of 75 loads and 15 of each interaction per stack. Lower is better; bold marks the stacks no other stack clearly beats, controls aside.

StackLoadingTime to effect
Static HTML control0.0 kB1,588 ms0 ms1,370 ms771 ms799 ms804 ms1,407 ms1,619 ms page load
Vanilla JS control1.2 kB1,596 ms0 ms702 ms120 ms12 ms694 ms703 ms832 ms in the page
Alpine.js18.0 kB1,776 ms65 ms700 ms125 ms24 ms703 ms704 ms1,742 ms page load
Angular87.5 kB1,792 ms154 ms713 ms142 ms22 ms726 ms713 ms1,648 ms page load
Astro + Preact13.1 kB1,588 ms0 ms707 ms121 ms12 ms710 ms706 ms1,670 ms page load
CycleWire9.0 kB1,628 ms0 ms706 ms120 ms12 ms616 ms711 ms836 ms in the page
CycleWire, inline variant3.6 kB1,412 ms0 ms717 ms135 ms12 ms616 ms718 ms855 ms in the page
CycleWire, intent only variant6.0 kB1,624 ms0 ms1,203 ms617 ms15 ms645 ms708 ms1,537 ms in the page
CycleWire, requests from markup variant9.1 kB1,636 ms0 ms709 ms711 ms802 ms618 ms712 ms860 ms in the page 10/15, page load 5/15
Hotwire (Turbo + Stimulus)31.9 kB1,692 ms0 ms729 ms783 ms859 ms749 ms729 ms1,621 ms page load
htmx15.4 kB1,572 ms0 ms713 ms759 ms848 ms717 ms717 ms858 ms in the page
Marko4.8 kB1,620 ms0 ms706 ms116 ms12 ms701 ms705 ms833 ms in the page
Next.js120.1 kB1,880 ms111 ms761 ms740 ms838 ms1,307 ms716 ms999 ms page load
Next.js, client filtering variant119.2 kB1,824 ms110 ms756 ms149 ms22 ms1,316 ms721 ms983 ms page load
Nuxt70.9 kB2,016 ms127 ms703 ms152 ms28 ms733 ms703 ms1,694 ms page load
Qwik City37.5 kB1,728 ms0 ms816 ms193 ms29 ms789 ms822 ms2,466 ms in the page
SolidStart31.1 kB1,588 ms0 ms709 ms128 ms14 ms722 ms704 ms1,668 ms page load
SvelteKit32.7 kB1,840 ms7 ms1,298 ms143 ms19 ms634 ms1,300 ms969 ms page load

Where another stack beats CycleWire

Every metric on which a stack that is not a control has a median at least 3% lower, with 95% confidence intervals that do not overlap. For each, the stack with the lowest median. Variants of the CycleWire app are CycleWire, so they are not listed.

  • First Contentful Paint: Astro + Preact 1,208 ms, CycleWire 1,264 ms
  • Largest Contentful Paint: htmx 1,572 ms, CycleWire 1,628 ms
  • JavaScript: Marko 4.8 kB, CycleWire 9.0 kB
  • Requests: Alpine.js 24, CycleWire 26
  • Script: Qwik City 14 ms, CycleWire 19 ms
  • Event listeners: Marko 9, CycleWire 10
Early taps: when "Add to cart" first appears, and 1 s and 2 s later

Mobile: time from the tap to its effect, median, and how the taps were handled. A tap handled by a page load is the form working without JavaScript. Bold marks the stacks no other stack clearly beats among those that handled every tap, controls aside.

StackTapped after first paint
Static HTML control1,619 ms page load1,505 ms page load1,383 ms page load
Vanilla JS control832 ms in the page836 ms in the page705 ms in the page
Alpine.js1,742 ms page load836 ms in the page706 ms in the page
Angular1,648 ms page load1,514 ms page load1,377 ms page load
Astro + Preact1,670 ms page load1,514 ms page load707 ms in the page
CycleWire836 ms in the page839 ms in the page722 ms in the page
CycleWire, inline variant855 ms in the page855 ms in the page723 ms in the page
CycleWire, intent only variant1,537 ms in the page1,337 ms in the page1,205 ms in the page
CycleWire, requests from markup variant860 ms in the page 10/15, page load 5/15838 ms in the page722 ms in the page
Hotwire (Turbo + Stimulus)1,621 ms page load854 ms in the page736 ms in the page
htmx858 ms in the page844 ms in the page717 ms in the page
Marko833 ms in the page824 ms in the page705 ms in the page
Next.js999 ms page load964 ms page load921 ms page load
Next.js, client filtering variant983 ms page load960 ms page load906 ms page load
Nuxt1,694 ms page load1,565 ms page load720 ms in the page
Qwik City2,466 ms in the page1,453 ms in the page819 ms in the page
SolidStart1,668 ms page load843 ms in the page709 ms in the page
SvelteKit969 ms page load1,429 ms in the page1,303 ms in the page
More metrics: first paint, layout shift, bytes, requests, the main thread, repeat visits and memory

Mobile: medians, lower is better. The main thread's time is split by what it spent it on while the page loaded; the rest is parsing, painting and the like. Memory is read after garbage collection on the repeat visit.

StackCold loadMain thread while loadingRepeat visit
Static HTML control1,260 ms0.0003,467 ms377.9 kB2.5 kB23359 ms2.2 ms21 ms52 ms660 ms2.5 kB914.6 kB4
Vanilla JS control1,264 ms0.0003,472 ms379.0 kB2.5 kB24325 ms2.9 ms9.6 ms35 ms664 ms2.5 kB919.9 kB7
Alpine.js1,260 ms0.0003,474 ms397.0 kB3.8 kB24444 ms111 ms9.7 ms36 ms680 ms3.8 kB1972.6 kB118
Angular1,272 ms0.0004,561 ms466.9 kB4.1 kB25587 ms123 ms11 ms37 ms672 ms4.1 kB2919.1 kB126
Astro + Preact1,208 ms0.0003,583 ms394.0 kB5.5 kB33416 ms17 ms11 ms35 ms640 ms5.5 kB1414.2 kB117
CycleWire1,264 ms0.0003,497 ms387.1 kB2.7 kB26362 ms19 ms9.8 ms34 ms656 ms2.7 kB1094.9 kB10
CycleWire, inline variant1,280 ms0.0004,242 ms387.7 kB8.6 kB27413 ms43 ms11 ms34 ms676 ms8.6 kB1145.9 kB16
CycleWire, intent only variant1,260 ms0.0003,473 ms384.1 kB2.7 kB24338 ms8.3 ms9.7 ms34 ms680 ms2.7 kB982.3 kB10
CycleWire, requests from markup variant1,264 ms0.0003,482 ms387.1 kB2.7 kB25352 ms18 ms9.7 ms34 ms664 ms2.7 kB1088.3 kB10
Hotwire (Turbo + Stimulus)1,260 ms0.0003,472 ms410.0 kB2.7 kB24367 ms21 ms11 ms37 ms672 ms2.7 kB1307.0 kB33
htmx1,524 ms0.0004,443 ms393.6 kB2.9 kB24376 ms40 ms10 ms34 ms688 ms2.8 kB1142.0 kB276
Marko1,264 ms0.0003,474 ms384.7 kB4.5 kB24352 ms21 ms9.6 ms34 ms684 ms4.5 kB1075.4 kB9
Next.js1,280 ms0.0005,980 ms507.5 kB6.7 kB42745 ms158 ms11 ms35 ms672 ms11.8 kB3060.0 kB510
Next.js, client filtering variant1,280 ms0.0005,924 ms503.6 kB5.4 kB39715 ms152 ms11 ms34 ms676 ms8.8 kB3008.0 kB510
Nuxt1,268 ms0.0004,868 ms453.5 kB4.2 kB31599 ms47 ms11 ms36 ms712 ms4.3 kB3315.0 kB124
Qwik City1,280 ms0.0003,709 ms422.6 kB8.9 kB51409 ms14 ms10 ms36 ms708 ms9.8 kB1123.9 kB17
SolidStart1,272 ms0.0004,208 ms410.6 kB4.2 kB28418 ms40 ms11 ms36 ms700 ms4.2 kB1521.7 kB17
SvelteKit1,316 ms0.0003,533 ms412.1 kB4.1 kB32437 ms22 ms11 ms36 ms704 ms3.6 kB1651.0 kB70

15 iterations on 2026-10-01 · AMD EPYC 7763 64-Core Processor, 4 cores, GitHub's hosted runner · Chrome 153.0.8010.12 · commit 5acd100 · raw data (JSON)

JavaScript downloaded on load · median and 95% confidence interval · lower is better
  1. Static HTML0.0 kB
  2. CycleWire, inline0.8 kB
  3. Vanilla JS1.2 kB
  4. Marko4.8 kB
  5. CycleWire, intent only6.0 kB
  6. CycleWire6.3 kB
  7. CycleWire, requests from markup8.9 kB
  8. Astro + Preact13.1 kB
  9. htmx15.3 kB
  10. Alpine.js17.9 kB
  11. SolidStart31.1 kB
  12. Hotwire (Turbo + Stimulus)31.9 kB
  13. SvelteKit32.7 kB
  14. Qwik City37.4 kB
  15. Nuxt70.9 kB
  16. Angular87.5 kB
  17. Next.js, client filtering119.2 kB
  18. Next.js120.1 kB

Desktop: medians of 75 loads and 15 of each interaction per stack. Lower is better; bold marks the stacks no other stack clearly beats, controls aside.

StackLoadingTime to effect
Static HTML control0.0 kB380 ms0 ms428 ms268 ms317 ms273 ms433 ms519 ms page load
Vanilla JS control1.2 kB384 ms0 ms250 ms85 ms6.4 ms251 ms251 ms284 ms in the page
Alpine.js17.9 kB384 ms0 ms250 ms100 ms23 ms251 ms251 ms289 ms in the page
Angular87.5 kB392 ms0 ms250 ms116 ms26 ms251 ms251 ms300 ms in the page
Astro + Preact13.1 kB392 ms0 ms251 ms87 ms9.6 ms251 ms251 ms522 ms page load
CycleWire6.3 kB384 ms0 ms250 ms98 ms9.7 ms116 ms251 ms283 ms in the page
CycleWire, inline variant0.8 kB400 ms0 ms266 ms116 ms8.1 ms115 ms251 ms300 ms in the page
CycleWire, intent only variant6.0 kB384 ms0 ms248 ms98 ms7.5 ms115 ms251 ms299 ms in the page
CycleWire, requests from markup variant8.9 kB384 ms0 ms250 ms251 ms368 ms115 ms250 ms284 ms in the page
Hotwire (Turbo + Stimulus)31.9 kB384 ms0 ms265 ms182 ms385 ms215 ms266 ms300 ms in the page
htmx15.3 kB428 ms0 ms250 ms255 ms371 ms251 ms251 ms285 ms in the page
Marko4.8 kB384 ms0 ms250 ms86 ms12 ms251 ms251 ms284 ms in the page
Next.js120.1 kB396 ms0 ms263 ms252 ms370 ms416 ms250 ms456 ms in the page
Next.js, client filtering variant119.2 kB400 ms0 ms255 ms99 ms9.5 ms417 ms250 ms367 ms in the page
Nuxt70.9 kB392 ms0 ms250 ms114 ms27 ms264 ms251 ms284 ms in the page
Qwik City37.4 kB392 ms0 ms284 ms104 ms30 ms267 ms284 ms499 ms in the page
SolidStart31.1 kB392 ms0 ms250 ms96 ms5.6 ms99 ms251 ms284 ms in the page
SvelteKit32.7 kB396 ms0 ms412 ms116 ms13 ms91 ms401 ms528 ms in the page

Where another stack beats CycleWire

Every metric on which a stack that is not a control has a median at least 3% lower, with 95% confidence intervals that do not overlap. For each, the stack with the lowest median. Variants of the CycleWire app are CycleWire, so they are not listed.

  • First Contentful Paint: Astro + Preact 348 ms, CycleWire 384 ms
  • JavaScript: Marko 4.8 kB, CycleWire 6.3 kB
  • Layout: htmx 9.5 ms, CycleWire 10 ms
  • Quick view: SvelteKit 91 ms, CycleWire 116 ms
  • Event listeners: Marko 9, CycleWire 10
Early taps: when "Add to cart" first appears, and 1 s and 2 s later

Desktop: time from the tap to its effect, median, and how the taps were handled. A tap handled by a page load is the form working without JavaScript. Bold marks the stacks no other stack clearly beats among those that handled every tap, controls aside.

StackTapped after first paint
Static HTML control519 ms page load443 ms page load445 ms page load
Vanilla JS control284 ms in the page250 ms in the page250 ms in the page
Alpine.js289 ms in the page250 ms in the page250 ms in the page
Angular300 ms in the page250 ms in the page250 ms in the page
Astro + Preact522 ms page load251 ms in the page250 ms in the page
CycleWire283 ms in the page250 ms in the page249 ms in the page
CycleWire, inline variant300 ms in the page266 ms in the page266 ms in the page
CycleWire, intent only variant299 ms in the page248 ms in the page248 ms in the page
CycleWire, requests from markup variant284 ms in the page249 ms in the page249 ms in the page
Hotwire (Turbo + Stimulus)300 ms in the page265 ms in the page265 ms in the page
htmx285 ms in the page250 ms in the page250 ms in the page
Marko284 ms in the page250 ms in the page250 ms in the page
Next.js456 ms in the page257 ms in the page257 ms in the page
Next.js, client filtering variant367 ms in the page255 ms in the page255 ms in the page
Nuxt284 ms in the page250 ms in the page250 ms in the page
Qwik City499 ms in the page284 ms in the page300 ms in the page
SolidStart284 ms in the page250 ms in the page250 ms in the page
SvelteKit528 ms in the page416 ms in the page404 ms in the page
More metrics: first paint, layout shift, bytes, requests, the main thread, repeat visits and memory

Desktop: medians, lower is better. The main thread's time is split by what it spent it on while the page loaded; the rest is parsing, painting and the like. Memory is read after garbage collection on the repeat visit.

StackCold loadMain thread while loadingRepeat visit
Static HTML control380 ms0.0001,034 ms677.0 kB2.5 kB39107 ms0.7 ms6.4 ms18 ms236 ms2.4 kB864.9 kB4
Vanilla JS control384 ms0.0001,036 ms678.2 kB2.5 kB4096 ms0.8 ms2.4 ms9.9 ms236 ms2.5 kB870.2 kB7
Alpine.js384 ms0.0001,037 ms696.2 kB3.8 kB40123 ms26 ms2.5 ms10 ms264 ms3.7 kB1930.7 kB118
Angular392 ms0.0001,051 ms766.1 kB4.1 kB41155 ms31 ms2.6 ms10 ms304 ms4.1 kB2878.6 kB126
Astro + Preact348 ms0.0001,061 ms693.2 kB5.5 kB49115 ms4.6 ms2.5 ms10 ms256 ms5.5 kB1369.7 kB117
CycleWire384 ms0.0001,039 ms683.5 kB2.7 kB40103 ms3.2 ms2.4 ms10 ms236 ms2.7 kB936.6 kB10
CycleWire, inline variant400 ms0.0001,200 ms684.0 kB8.6 kB41104 ms6.3 ms2.1 ms9.7 ms240 ms8.6 kB1022.8 kB10
CycleWire, intent only variant384 ms0.0001,040 ms683.2 kB2.7 kB40105 ms3.2 ms2.4 ms10 ms240 ms2.7 kB932.9 kB10
CycleWire, requests from markup variant384 ms0.0001,036 ms686.1 kB2.7 kB40102 ms3.1 ms2.4 ms10 ms240 ms2.6 kB938.0 kB10
Hotwire (Turbo + Stimulus)384 ms0.0001,035 ms709.1 kB2.7 kB40104 ms5.6 ms2.7 ms10 ms248 ms2.7 kB1257.5 kB33
htmx428 ms0.0001,238 ms692.7 kB2.9 kB40110 ms14 ms2.4 ms9.5 ms244 ms2.8 kB1093.2 kB276
Marko384 ms0.0001,043 ms683.8 kB4.5 kB40113 ms7.1 ms2.4 ms10 ms236 ms4.5 kB1033.9 kB9
Next.js396 ms0.0001,760 ms809.1 kB6.7 kB74240 ms48 ms2.9 ms11 ms304 ms14.4 kB3123.6 kB510
Next.js, client filtering variant400 ms0.0001,743 ms805.8 kB5.4 kB71229 ms46 ms2.8 ms11 ms304 ms11.7 kB3090.8 kB510
Nuxt392 ms0.0001,691 ms752.7 kB4.2 kB47178 ms12 ms2.7 ms11 ms276 ms4.3 kB3344.1 kB124
Qwik City392 ms0.0001,082 ms721.8 kB8.9 kB67127 ms4.1 ms2.6 ms11 ms244 ms9.8 kB1074.6 kB17
SolidStart392 ms0.0001,063 ms709.8 kB4.2 kB44122 ms12 ms2.4 ms11 ms244 ms4.1 kB1480.6 kB17
SvelteKit396 ms0.0001,053 ms711.3 kB4.1 kB48136 ms5.3 ms2.7 ms11 ms252 ms3.6 kB1626.6 kB70
How each app is built

Each app follows its documentation; every choice it makes is listed, with a link to the page that recommends it, in its bench.json.

  • Static HTML control

    The reference page with no JavaScript at all. Every interaction is a full page load: forms post and links navigate. It shows what the network and the server cost on their own.

  • Vanilla JS control

    The reference page plus one small, hand-written module: event delegation, fetch and DOM updates, no library. It stands for the least JavaScript these interactions need.

  • Alpine.js alpinejs@3.17.4

    The reference page with Alpine's directives written into the server's markup. One module, Alpine bundled with the page's stores and components, loads up front and starts Alpine over the server-rendered HTML; stores hold the cart and the filters, and the cart, the quick view and the newsletter call the shared JSON API.

  • Angular @angular/core@22.2.0 @angular/ssr@22.2.0

    The page rendered per request by @angular/ssr's Express server and hydrated with Angular 22's defaults: incremental hydration, with the newsletter below the fold hydrating when it scrolls into view, and event replay. httpResource reads the shared JSON API, whose answers on the server reach the browser in the transfer cache; search and category are query parameters filtered in the browser, and the forms post to Express without JavaScript and to the API once hydrated.

  • Astro + Preact astro@7.3.5 @astrojs/preact@6.0.5 preact@10.29.8

    Astro renders the page on demand with its Node adapter. The header, the catalog, the quick view and the newsletter are Preact islands, each hydrated when it is needed; they share state through nanostores and call Astro Actions, which the same forms also use without JavaScript.

  • CycleWire cyclewire@1.2.0+5acd100

    The reference page activated by CycleWire, built from this repository's source, so every run measures the commit it runs on: the core loads up front, and each interaction's code loads when someone reaches for it. Nothing hydrates.

  • CycleWire, inline variant cyclewire@1.2.0+5acd100 esbuild@0.28.2

    The CycleWire app with the core inlined in the page: CycleWire starts while the page is still parsing, and each action's code loads as one module when someone reaches for it.

    A variant of CycleWire: The core is the classic-script build inlined in <head>, which starts from a JSON block that maps actions to URLs, instead of a module loaded with the page; the actions are one module each (esbuild) instead of Vite chunks. It tests the performance guide's advice to inline the core for the fastest activation.

  • CycleWire, intent only variant cyclewire@1.2.0+5acd100

    The CycleWire app with nothing fetched ahead of intent: the core loads up front, and every action's code when someone reaches for it, Add to cart included, on touch screens too.

    A variant of CycleWire: No <link rel="modulepreload"> for Add to cart, and start({ preload: 'intent' }), which turns off the look-ahead that fetches the code of the actions in view on touch screens. It shows what the two save an interaction, and what they cost a load.

  • CycleWire, requests from markup variant cyclewire@1.2.0+5acd100

    The CycleWire app with its interactions declared in markup with cyclewire/request, the way htmx builds them: links and forms fetch HTML fragments from the server and swap them in (the results, the header's cart summary, the quick view, the newsletter message), and <cw-stream> messages in an answer update the other parts of the page. The core loads up front, and the request action's code with the page.

    A variant of CycleWire: Every interaction is cyclewire/request in the page's markup instead of a hand-written action: links and forms fetch HTML fragments from the server and swap them in, <cw-stream> messages in an answer update the rest of the page, and only the quick view has a small action of its own, to open the dialog once the request has filled it. It shows what CycleWire costs, and how fast it is, used the way htmx is.

  • Hotwire (Turbo + Stimulus) @hotwired/turbo@8.0.23 @hotwired/stimulus@3.2.2

    The reference page from a plain Node server, with Turbo and Stimulus. Without JavaScript its links and forms load full pages; with Turbo, category links are Drive visits, the search results and the quick view load as Turbo Frames, add to cart and the newsletter answer with Turbo Streams, and two Stimulus controllers submit the search as you type and open the quick view dialog.

  • htmx htmx.org@2.0.11

    The reference page from a plain Node server, with htmx attributes on its links and forms. Without JavaScript they load full pages; with htmx they fetch HTML fragments (the results, the quick view, the cart header, the newsletter message) and swap them in.

  • Marko marko@6.3.56 @marko/run@0.11.13

    The page rendered per request by Marko Run's Node server and resumed in the browser by Marko 6, whose one client bundle holds only the code for what can change. A route handler loads the listing and the cart cookie; search and category are template state that toggles the cards' hidden attribute; the forms post to route handlers and are sent with fetch once the page runs; the quick view is a dialog on ?view=, filled from a JSON route when opened in the page.

  • Next.js next@16.3.6 react@19.3.0 react-dom@19.3.0

    App Router Server Components render the page per request from the URL and the cart cookie. Server Actions add to the cart and sign up for the newsletter, typing in the search box updates ?q= for the server to render the results, and the quick view is an intercepted route shown in a modal.

  • Next.js, client filtering variant next@16.3.6 react@19.3.0 react-dom@19.3.0

    The Next.js app with the list filtered in the browser: a Client Component gets the catalog from the page, keeps ?category= and ?q= in the URL with the native History API and reads them with useSearchParams(). The cart, the newsletter and the quick view are the next app's.

    A variant of Next.js: A category or a search does not render the page again on the server: one Client Component holds the whole catalog, filters it in the browser, and updates the URL with window.history.pushState() and replaceState(), which Next.js syncs with useSearchParams(). It shows what filtering in the browser saves a filter and a search, and what moving the list into a Client Component changes in a load.

  • Nuxt nuxt@4.5.2 vue@3.5.43

    The page rendered per request by Nuxt's Node server and hydrated by Vue. useFetch loads the products and the cart from Nitro server routes; search and category live in the URL and filter the list in the browser; the forms post to server routes and are sent with $fetch once hydrated; the quick view is a dialog on ?view=, filled with useAsyncData.

  • Qwik City @builder.io/qwik@1.20.1 @builder.io/qwik-city@1.20.1

    The page as a Qwik City app on its Node.js server adapter: rendered per request and resumed in the browser, nothing hydrates. Route loaders read the cart cookie and the URL, the forms are route actions, search and filters are signals, and the quick view fetches its product through server$.

  • SolidStart @solidjs/start@2.0.5 solid-js@1.9.15 @solidjs/router@1.0.0

    The page rendered per request by SolidStart 2 on Nitro's Node server and hydrated by Solid. Queries whose bodies run on the server load the products and the cart; search and category live in the URL and filter the list in the browser; the forms post to server actions and are sent by the router once hydrated, with the new cart in the same response; the quick view is a dialog on ?view=.

  • SvelteKit @sveltejs/kit@2.70.3 svelte@5.57.1

    The page rendered per request by SvelteKit's Node adapter and hydrated by Svelte 5. Search and category live in the URL and filter the list in the browser; add to cart and the newsletter are form actions enhanced with use:enhance; the quick view is a shallow-routed modal.

Read this first: what is measured, and the limits
  • Time to effect runs from the input (the finger or the button going down, or the last key) to the frame that shows the result. A full page load counts, as it does for people.
  • The early tap lands on "Add to cart" in the first frame after first paint, because people tap as soon as they see a button. Two more land one and two seconds later, to show when each page starts handling a tap itself.
  • Variants are other documented ways to build the page with a stack, such as CycleWire with its core inlined. A variant of CycleWire is never counted as another stack beating it.
  • What every build shares. The same text, data, stylesheet and images, rendered on the server for every request and working without JavaScript. Automatic checks enforce it.
  • Limits. Chromium only; network throttling per request inside the browser, cross-checked against Lighthouse on the same machine; shared CI machines; a modelled person: 80 ms taps and, on desktop, a pointer that rests on its target for 100 ms, which gives hover preloading a head start that touch screens do not have.
  • Corrections welcome. If an app does not follow its documentation, change it; the people behind a stack can add a response to it. The methodology explains every choice, and anyone can run it.
Works with

Bring your own backend. Keep your framework.

Recipes for each are in the integration guide, with guides for React, Vue and Svelte islands and popular libraries. Try them in the live examples. Tested on current Chromium, Firefox and WebKit; see browser support.

  • Laravel
  • Rails
  • Django
  • PHP
  • Astro
  • Vite
  • webpack
  • React islands
  • Vue
  • Svelte
  • Web Components
  • htmx
  • Turbo
  • Bootstrap
  • jQuery
  • DataTables
  • Flatpickr
  • SweetAlert2