v1.2.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-09-26 · INTEL(R) XEON(R) PLATINUM 8573C, 4 cores, GitHub's hosted runner · Chrome 153.0.8010.12 · commit 0794a56 · 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. Marko4.8 kB
  4. CycleWire, intent only5.9 kB
  5. CycleWire, inline6.9 kB
  6. CycleWire, requests from markup9.4 kB
  7. CycleWire12.3 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.8 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

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,584 ms0 ms1,365 ms760 ms790 ms777 ms1,379 ms1,624 ms page load
Vanilla JS control1.2 kB1,596 ms0 ms700 ms116 ms12 ms696 ms699 ms834 ms in the page
Alpine.js18.0 kB1,748 ms35 ms701 ms128 ms22 ms701 ms701 ms1,752 ms page load
Angular87.5 kB1,788 ms116 ms707 ms135 ms22 ms719 ms715 ms1,652 ms page load
Astro + Preact13.1 kB1,588 ms0 ms710 ms120 ms13 ms712 ms710 ms1,675 ms page load
CycleWire12.3 kB1,652 ms0 ms701 ms120 ms12 ms611 ms704 ms853 ms in the page
CycleWire, inline variant6.9 kB1,428 ms0 ms718 ms136 ms12 ms614 ms710 ms856 ms in the page
CycleWire, intent only variant5.9 kB1,616 ms0 ms1,217 ms620 ms13 ms644 ms707 ms1,819 ms in the page
CycleWire, requests from markup variant9.4 kB1,640 ms0 ms708 ms708 ms805 ms612 ms716 ms852 ms in the page
Hotwire (Turbo + Stimulus)31.9 kB1,688 ms0 ms731 ms775 ms853 ms748 ms727 ms1,626 ms page load
htmx15.4 kB1,560 ms0 ms713 ms751 ms838 ms717 ms712 ms866 ms in the page
Marko4.8 kB1,620 ms0 ms704 ms118 ms12 ms698 ms701 ms834 ms in the page
Next.js120.1 kB1,876 ms91 ms762 ms736 ms834 ms1,303 ms714 ms993 ms page load
Next.js, client filtering variant119.2 kB1,828 ms90 ms753 ms142 ms21 ms1,316 ms717 ms988 ms page load
Nuxt70.9 kB2,028 ms92 ms706 ms148 ms31 ms734 ms705 ms1,703 ms page load
Qwik City37.4 kB1,728 ms0 ms802 ms184 ms31 ms773 ms804 ms2,466 ms in the page
SolidStart31.1 kB1,580 ms0 ms709 ms127 ms14 ms719 ms707 ms1,671 ms page load
SvelteKit32.8 kB1,824 ms0 ms1,299 ms143 ms18 ms635 ms1,300 ms963 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,204 ms, CycleWire 1,268 ms
  • Largest Contentful Paint: htmx 1,560 ms, CycleWire 1,652 ms
  • JavaScript: Marko 4.8 kB, CycleWire 12.3 kB
  • HTML: Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB
  • Requests: Alpine.js 24, CycleWire 28
  • Main thread: Marko 351 ms, CycleWire 365 ms
  • Script: Qwik City 15 ms, CycleWire 26 ms
  • Bytes, repeat visit: Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB
  • JS heap: Marko 1075.4 kB, CycleWire 1121.2 kB
  • Event listeners: Marko 9, CycleWire 11
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,624 ms page load1,509 ms page load1,388 ms page load
Vanilla JS control834 ms in the page838 ms in the page704 ms in the page
Alpine.js1,752 ms page load837 ms in the page705 ms in the page
Angular1,652 ms page load1,517 ms page load1,381 ms page load
Astro + Preact1,675 ms page load1,514 ms page load709 ms in the page
CycleWire853 ms in the page839 ms in the page721 ms in the page
CycleWire, inline variant856 ms in the page849 ms in the page724 ms in the page
CycleWire, intent only variant1,819 ms in the page1,605 ms in the page1,223 ms in the page
CycleWire, requests from markup variant852 ms in the page839 ms in the page723 ms in the page
Hotwire (Turbo + Stimulus)1,626 ms page load868 ms in the page737 ms in the page
htmx866 ms in the page846 ms in the page719 ms in the page
Marko834 ms in the page832 ms in the page704 ms in the page
Next.js993 ms page load964 ms page load928 ms page load
Next.js, client filtering variant988 ms page load962 ms page load901 ms page load
Nuxt1,703 ms page load1,563 ms page load721 ms in the page
Qwik City2,466 ms in the page1,468 ms in the page819 ms in the page
SolidStart1,671 ms page load842 ms in the page710 ms in the page
SvelteKit963 ms page load1,435 ms in the page1,311 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,248 ms0.0003,458 ms377.9 kB2.5 kB23349 ms1.9 ms19 ms45 ms672 ms2.5 kB914.6 kB4
Vanilla JS control1,256 ms0.0003,465 ms379.0 kB2.5 kB24329 ms2.3 ms8.6 ms31 ms688 ms2.5 kB919.9 kB7
Alpine.js1,256 ms0.0003,465 ms397.0 kB3.8 kB24409 ms80 ms8.6 ms33 ms676 ms3.8 kB1972.6 kB118
Angular1,264 ms0.0004,522 ms466.9 kB4.1 kB25547 ms101 ms9.5 ms34 ms672 ms4.1 kB2919.3 kB126
Astro + Preact1,204 ms0.0003,576 ms394.0 kB5.5 kB33398 ms16 ms10 ms30 ms644 ms5.5 kB1414.2 kB117
CycleWire1,268 ms0.0003,502 ms390.5 kB2.8 kB28365 ms26 ms8.6 ms31 ms684 ms2.8 kB1121.2 kB11
CycleWire, inline variant1,292 ms0.0004,238 ms390.9 kB8.6 kB27418 ms62 ms9.4 ms31 ms696 ms8.6 kB1141.6 kB17
CycleWire, intent only variant1,256 ms0.0003,464 ms384.1 kB2.8 kB24343 ms12 ms8.5 ms32 ms660 ms2.8 kB1002.5 kB11
CycleWire, requests from markup variant1,260 ms0.0003,479 ms387.4 kB2.7 kB26368 ms23 ms8.6 ms32 ms688 ms2.7 kB1097.0 kB11
Hotwire (Turbo + Stimulus)1,252 ms0.0003,462 ms410.0 kB2.7 kB24362 ms21 ms9.3 ms35 ms672 ms2.7 kB1307.0 kB33
htmx1,512 ms0.0004,443 ms393.6 kB2.9 kB24361 ms34 ms8.7 ms31 ms692 ms2.8 kB1142.0 kB276
Marko1,256 ms0.0003,464 ms384.7 kB4.5 kB24351 ms19 ms8.6 ms32 ms656 ms4.5 kB1075.4 kB9
Next.js1,268 ms0.0005,936 ms507.5 kB6.7 kB42706 ms138 ms10 ms32 ms684 ms11.8 kB3060.0 kB510
Next.js, client filtering variant1,272 ms0.0005,890 ms503.6 kB5.4 kB39678 ms131 ms9.8 ms31 ms672 ms8.8 kB3008.1 kB510
Nuxt1,256 ms0.0004,834 ms453.5 kB4.2 kB31557 ms43 ms9.8 ms32 ms728 ms4.3 kB3315.1 kB124
Qwik City1,276 ms0.0003,701 ms422.6 kB8.9 kB51400 ms15 ms9.3 ms33 ms696 ms9.8 kB1123.9 kB17
SolidStart1,268 ms0.0004,205 ms410.6 kB4.2 kB28403 ms37 ms9.3 ms33 ms696 ms4.2 kB1521.7 kB17
SvelteKit1,308 ms0.0003,522 ms412.1 kB4.1 kB32434 ms23 ms9.4 ms33 ms704 ms3.6 kB1651.0 kB70

15 iterations on 2026-09-26 · AMD EPYC 7763 64-Core Processor, 4 cores, GitHub's hosted runner · Chrome 153.0.8010.12 · commit 0794a56 · 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, inline4.1 kB
  4. Marko4.8 kB
  5. CycleWire, intent only5.9 kB
  6. CycleWire, requests from markup9.2 kB
  7. CycleWire9.6 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 kB376 ms0 ms427 ms267 ms315 ms272 ms433 ms509 ms page load
Vanilla JS control1.2 kB380 ms0 ms250 ms86 ms9.9 ms251 ms251 ms284 ms in the page
Alpine.js17.9 kB376 ms0 ms250 ms100 ms28 ms251 ms251 ms289 ms in the page
Angular87.5 kB388 ms0 ms250 ms101 ms23 ms266 ms251 ms299 ms in the page
Astro + Preact13.1 kB388 ms0 ms251 ms87 ms11 ms251 ms251 ms521 ms page load
CycleWire9.6 kB380 ms0 ms250 ms99 ms6.9 ms115 ms250 ms283 ms in the page
CycleWire, inline variant4.1 kB404 ms0 ms266 ms116 ms7.1 ms116 ms251 ms299 ms in the page
CycleWire, intent only variant5.9 kB380 ms0 ms249 ms98 ms11 ms115 ms250 ms382 ms in the page
CycleWire, requests from markup variant9.2 kB384 ms0 ms250 ms250 ms364 ms116 ms250 ms284 ms in the page
Hotwire (Turbo + Stimulus)31.9 kB380 ms0 ms265 ms182 ms385 ms215 ms266 ms300 ms in the page
htmx15.3 kB428 ms0 ms250 ms255 ms370 ms251 ms251 ms285 ms in the page
Marko4.8 kB384 ms0 ms250 ms87 ms9.8 ms251 ms251 ms284 ms in the page
Next.js120.1 kB388 ms0 ms255 ms251 ms371 ms416 ms250 ms450 ms in the page 13/15, page load 2/15
Next.js, client filtering variant119.2 kB388 ms0 ms254 ms100 ms6.5 ms416 ms250 ms367 ms in the page
Nuxt70.9 kB384 ms0 ms250 ms117 ms27 ms252 ms251 ms284 ms in the page
Qwik City37.4 kB392 ms0 ms284 ms104 ms28 ms267 ms284 ms500 ms in the page
SolidStart31.1 kB392 ms0 ms250 ms100 ms12 ms99 ms250 ms284 ms in the page
SvelteKit32.7 kB396 ms0 ms401 ms115 ms7.7 ms98 ms400 ms523 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 380 ms
  • JavaScript: Marko 4.8 kB, CycleWire 9.6 kB
  • HTML: Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB
  • Requests: Alpine.js 40, CycleWire 42
  • Layout: htmx 9.4 ms, CycleWire 10 ms
  • Quick view: SvelteKit 98 ms, CycleWire 115 ms
  • Bytes, repeat visit: Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB
  • Event listeners: Marko 9, CycleWire 11
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 control509 ms page load428 ms page load443 ms page load
Vanilla JS control284 ms in the page250 ms in the page250 ms in the page
Alpine.js289 ms in the page251 ms in the page250 ms in the page
Angular299 ms in the page250 ms in the page250 ms in the page
Astro + Preact521 ms page load250 ms in the page251 ms in the page
CycleWire283 ms in the page249 ms in the page249 ms in the page
CycleWire, inline variant299 ms in the page266 ms in the page266 ms in the page
CycleWire, intent only variant382 ms in the page248 ms in the page248 ms in the page
CycleWire, requests from markup variant284 ms in the page250 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.js450 ms in the page 13/15, page load 2/15256 ms in the page256 ms in the page
Next.js, client filtering variant367 ms in the page254 ms in the page254 ms in the page
Nuxt284 ms in the page250 ms in the page250 ms in the page
Qwik City500 ms in the page283 ms in the page283 ms in the page
SolidStart284 ms in the page250 ms in the page250 ms in the page
SvelteKit523 ms in the page401 ms in the page402 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 control376 ms0.0001,033 ms677.0 kB2.5 kB39103 ms0.6 ms6.3 ms17 ms232 ms2.4 kB864.9 kB4
Vanilla JS control380 ms0.0001,040 ms678.2 kB2.5 kB4093 ms0.7 ms2.3 ms9.8 ms236 ms2.5 kB870.2 kB7
Alpine.js376 ms0.0001,036 ms696.2 kB3.8 kB40118 ms25 ms2.4 ms10 ms268 ms3.7 kB1929.5 kB118
Angular388 ms0.0001,047 ms766.1 kB4.1 kB41148 ms29 ms2.5 ms9.9 ms300 ms4.1 kB2877.9 kB126
Astro + Preact348 ms0.0001,055 ms693.2 kB5.5 kB49112 ms4.4 ms2.4 ms10 ms252 ms5.5 kB1373.0 kB117
CycleWire380 ms0.0001,041 ms687.0 kB2.8 kB42102 ms4.4 ms2.3 ms10 ms236 ms2.8 kB958.3 kB11
CycleWire, inline variant404 ms0.0001,055 ms687.2 kB8.6 kB41118 ms16 ms2.1 ms9.5 ms252 ms8.6 kB1024.8 kB11
CycleWire, intent only variant380 ms0.0001,034 ms683.2 kB2.8 kB40101 ms4.2 ms2.3 ms10 ms236 ms2.8 kB953.2 kB11
CycleWire, requests from markup variant384 ms0.0001,038 ms686.4 kB2.7 kB41101 ms4.5 ms2.4 ms10 ms236 ms2.7 kB957.0 kB11
Hotwire (Turbo + Stimulus)380 ms0.0001,038 ms709.1 kB2.7 kB40103 ms5.7 ms2.5 ms11 ms256 ms2.7 kB1257.5 kB33
htmx428 ms0.0001,238 ms692.7 kB2.9 kB40107 ms15 ms2.4 ms9.4 ms248 ms2.8 kB1093.2 kB276
Marko384 ms0.0001,040 ms683.8 kB4.5 kB40109 ms7.5 ms2.4 ms10 ms240 ms4.5 kB1033.9 kB9
Next.js388 ms0.0001,753 ms809.3 kB6.7 kB74233 ms46 ms2.8 ms11 ms300 ms14.6 kB3125.1 kB510
Next.js, client filtering variant388 ms0.0001,739 ms805.8 kB5.4 kB71226 ms45 ms2.7 ms10 ms308 ms11.8 kB3090.5 kB510
Nuxt384 ms0.0001,685 ms752.7 kB4.2 kB47172 ms12 ms2.7 ms11 ms276 ms4.3 kB3341.9 kB124
Qwik City392 ms0.0001,078 ms721.7 kB8.9 kB67122 ms4.1 ms2.5 ms11 ms240 ms9.8 kB1074.6 kB17
SolidStart392 ms0.0001,058 ms709.8 kB4.2 kB44116 ms12 ms2.3 ms11 ms244 ms4.1 kB1480.6 kB17
SvelteKit396 ms0.0001,050 ms711.3 kB4.1 kB48129 ms5.2 ms2.5 ms11 ms260 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+0794a56

    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+0794a56 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+0794a56

    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+0794a56

    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