← BACK TO ENGINEERING
Runtime 11 min read

Zero JavaScript on Load Cost Us 400 Milliseconds on the First Click. The Page Already Knew How to Get Them Back.

A page that ships no JavaScript is the fastest page there is. Until someone clicks.

This month we moved 16 public sites on Vibe from React to Qwik 2. The page arrives already drawn. It loads one or two scripts, 1 to 7 kilobytes, instead of twenty-two. On a phone profile the first paint went from 2.0 seconds to 1.1.

And the first click went from 40 milliseconds to 420. On a busy afternoon in production, up to 1.6 seconds.

Nothing was broken. Every handler ran, every page looked the same. Resumability did exactly what it promises: it moved the cost of JavaScript from the load to the first interaction. We had read that sentence a dozen times. We hadn't measured it.

This is how we found the 400 milliseconds, why Qwik's own preloader didn't get them back, and how a 1 KB script that reads what the page already knows did. Plus two things a deploy quietly undid, and a benchmark that lied to us for an afternoon.


I – Two Engines, One Layout

Most new sites on Vibe are rendered by our own page engine. Until this month it had one renderer, React: the server sends the shell, the browser downloads the engine, fetches the data and draws.

We added a second renderer, Qwik 2, chosen per site. Same layout, same stylesheets, same markup contract. The only difference is where the work happens.

The same page on two engines: React downloads 22 scripts before drawing; Qwik sends the page drawn and runs nothing until the first click

With React, a print shop's home page downloads 22 scripts, about a megabyte uncompressed, before the visitor sees a product. Every button works the moment it appears, because the code is already there.

With Qwik, the server renders the page with its data and sends it finished. The browser gets the HTML, a serialized snapshot of the state, and a tiny loader that listens for events. No hydration. No framework boot. Two scripts, 14 kilobytes.

When the visitor clicks, the loader looks up which code handles that click, downloads it, and resumes from the snapshot. That last sentence is where this story lives.


II – A Referee for 22 Sites

Switching an engine is one API call. No new version, no deploy. That made it easy to switch, and dangerous to switch blindly.

So we wrote a referee. For each site, in production, as an anonymous visitor: render it on React and keep that as the baseline. Switch the engine. Render it on Qwik. Compare. Any failure switches it straight back.

The referee: render on React, switch, render on Qwik, compare five checks; 19 sites stayed, 3 went back

Five checks. The same HTTP status. At least 90 percent of the same words on the page. No console error that React didn't have. An in-app link that navigates without a full page load. A first click that still does its job, here the colour mode toggle.

19 of 22 passed. Three went back.

The three are the interesting part, because none of them threw an error. One was a page that had moved: React redirected the visitor in the browser, and a page that runs no JavaScript on load never redirects. One was a choice control that rendered each option's label but dropped its price and duration. One was a select that printed every option where React showed only the chosen one.

A list of supported components would have approved all three. The text comparison caught them. If you migrate between renderers, compare what the visitor reads, not just whether the page crashed.

The 19 that stayed had 95 to 99.8 percent identical text, zero new errors and client-side navigation intact.

Two of them were still broken. A barbershop and a tailor were live without their hero pictures, their product photos and their price-list layout. Pictures have no text, so a text referee cannot see them go. We added a second referee that compares what the visitor sees: the number of loaded pictures, the page height, and the share of pixels that differ on the first screen. It sent the two back to React, plus a third that had lost its brand colours, and it led us to the renderer gaps behind them. With those fixed, the same pages come out pixel for pixel identical to React over their full height.

The first referee also timed one more thing.


III – The Click That Waited for Five Downloads

On the React version of the print shop, toggling dark mode takes 30 to 45 milliseconds from click to repaint. On Qwik it took 420.

We traced the network during that one click.

What one click waited for: five chunk downloads, one after another; the page reacts at 420 ms

The click lands. The loader fetches the Qwik core. The core imports the preloader. The core reads the snapshot, finds that the handler lives in a 300-byte chunk, and fetches it. That chunk statically imports the module with our event handlers, which statically imports the shared functions they use, 50 kilobytes compressed.

Five downloads, one after another. A browser only discovers a module's imports after the module has arrived and been parsed, so each level of the graph waits for the one before it. With a 60 millisecond latency to our servers, that is about 330 milliseconds of waiting on the network, plus the work of resuming.

In production, with the sites switched and real traffic, the referee measured first clicks between 0.6 and 1.6 seconds. Google calls anything over 500 milliseconds of interaction delay "poor."

We had traded a slow start for a slow first touch. On a landing page, the first touch is the one that matters.


IV – The Preloader That Guessed Wrong

Qwik 2 ships a preloader for exactly this. It reads the bundle graph and preloads the bundles most likely to be needed, with link hints in the HTML and a background loader once the page is idle. We had turned it off, because we wanted zero JavaScript before the first paint.

So we turned it back on and measured, on a local copy of the print shop: the real page document, the real production API behind it, Qwik bundles built locally, Chromium with 60 milliseconds of latency.

Variant JS before load First click
Preloader off 2 files, 14 KB 391–723 ms
Preloader, no link hints 6 files, 225 KB 300–400 ms
Preloader, default hints 10 files, 334 KB 338–351 ms

It loaded up to 334 kilobytes before the load event and still missed the chain. The trace after the click showed the same requests: the handler module and the shared functions, fetched on demand, in sequence.

The preloader predicts by probability over the whole application's bundle graph. The handler behind a colour mode toggle on one particular page was never likely enough to make its cut. It's a reasonable heuristic for an app with routes. It's the wrong one for a page that already knows what it contains.


V – The Page Already Knows

Here is the thing we had overlooked. A Qwik page doesn't need to guess which code it might run. It carries the list.

Every interactive element has an event attribute: q-e:click="q-CGel0FXI.js#_run#6". That one names the Qwik core, which dispatches the event, not the handler itself. The handler is in the serialized state, <script type="qwik/state">, as a QRL: a chunk file name and a symbol. Every handler, render function and task the container can resume is named there.

So the fix is a small inline script, about 1 kilobyte, that runs after the load event, when the browser is idle. It collects every chunk name from the event attributes and the state, and calls import() on each one.

The idle warm-up: paint with no JS, then after load and idle, import the 12 to 20 chunks the page names in its attributes and state; the click runs from memory

Three details make it work.

import(), not modulepreload. A preload hint fetches one file. An import() fetches the module and its whole static import graph, in parallel per level, and puts it in the module map. When the loader later imports the same URL, it gets the module that is already there.

After load, on idle. The page still paints with no JavaScript, and images and fonts still have the network to themselves. The warm-up uses the time a visitor spends reading.

The page's own list, not a prediction. Twelve to twenty chunks for our pages, exactly the ones this page can run. Nothing for routes the visitor isn't on.

Same bench, same page:

First click in milliseconds at load, after 0.3 s and after 1.5 s: React 295, 68, 66; Qwik without warm-up 723, 427, 391; Qwik with warm-up 264, 111, 98

98 milliseconds after a second and a half, with the same 14 kilobytes before load. A click right at load is now faster on Qwik than on React, 264 against 295, because React is still hydrating at that moment. Once both have settled, React keeps an edge of about 30 milliseconds. That's the cost of resuming from a snapshot. We'll take it.

On a phone profile, four times slower CPU and 10 megabits: React painted at 2.0 seconds and answered a click after 1.5 seconds in 204 milliseconds. Qwik with the warm-up painted at 1.1 seconds and answered in 268.

Then we shipped it. Measured in production after the deploy: one or two scripts before load, 12 to 20 chunks warmed after it, first click 56 to 90 milliseconds. Down from 0.6 to 1.6 seconds the same morning.


VI – Two Things a Deploy Quietly Undid

Between the switch and the fix, two deploys went out. Each one broke something without a single error in the logs.

Two deploy traps: settings rebuilt from git dropped the engine chosen in production; replaced chunks would 404 in tabs opened before the deploy

The setting that didn't survive. Some of our sites are system sites, versioned in git. On every deploy, the platform rebuilds their settings from git and keeps a short list of production-only fields: owner, creation date, visit count. The engine choice wasn't on that list. A restart later, a template we had switched was quietly back on React. Now an owner's choice made in production is kept, and git only provides the default until one is made. The next deploy was the test: every switched site was still on Qwik.

The chunk that wouldn't exist. Qwik chunk names are content hashes. Our sync replaced the whole folder on every engine update. A visitor who opened a page before the deploy and clicked after it would resume with chunk names that no longer existed. That's a 404 on the first click, the one interaction we had just made fast. Now the sync keeps the previous release's chunks for one more release. Hashed names never collide, so both sets are served side by side: 120 new files, 100 kept.

Resumability makes this worse than it is for a single-page app. A React tab that's already loaded rarely needs another chunk. A Qwik tab hasn't downloaded anything yet. Every open tab is a promise to serve the exact files it was rendered with.


VII – The Benchmark That Lied

Halfway through, the phone-profile bench said Qwik's Largest Contentful Paint was 10 seconds, against 1.9 for React.

That would have ended the experiment. It was wrong.

We read LCP 50 milliseconds after the load event, in a desktop-sized window. On the Qwik page, every image is in the HTML from the first byte, so the load event waits for the ones in view, and in a 1280-pixel window that is most of the product grid fighting for 10 megabits. On the React page, the images are inserted by JavaScript, and the load event had already fired. At the moment we looked, React's hero hadn't painted yet. We were measuring when we looked, not when the page finished.

Read again once the page had settled, in a phone-sized window, on the same network and CPU:

Engine First paint Largest paint
React 2.0 s 6.1 s
Qwik + warm-up 1.1 s 4.6 s

Both numbers are bad, and the culprit isn't either engine. The print shop's home page loads 18 images, 23 megabytes of PNG, about 850 kilobytes each. The hero, the largest element, is 2.3 megabytes and marked loading="lazy", which tells the browser to deprioritize the one image that decides the metric. That's the next fix, for every site and both engines: resized WebP or AVIF with srcset, and never lazy on the first image.

A renderer can't save you from your images. But a benchmark that reads the metric too early can convince you the renderer is the problem.


VIII – What We'd Tell You

Resumability moves the cost, it doesn't remove it. Zero JavaScript on load means the first interaction pays for the code, as a chain of round trips the browser can only discover one level at a time. Measure the first click, not just the paint.

The page knows better than the bundle graph. A Qwik container names every chunk it can resume in its event attributes and its serialized state. Importing exactly those, once the browser is idle after load, keeps the zero-JavaScript paint and makes the first click almost as fast as a hydrated app.

Use import() to warm a module graph. A preload hint fetches one file. An import fetches the graph.

Compare what the visitor reads and what they see. Comparing the words caught three broken pages that threw no errors. It also let two pages through without their pictures. Count the pictures and compare the pixels too.

Deploys undo things. Check that settings changed at runtime survive the next deploy. Keep the previous release's chunks for tabs that are still open.

Read the metric when the page is done. Our scariest number was a measurement taken too early.

Sixteen sites now paint twice as fast and answer the first click in under 100 milliseconds, with more to follow as the renderer fixes ship. The fix was 1 kilobyte, and the list it needed was already in the page.

"Simplicity is the ultimate sophistication."