Qwik City Port: Two Bundle Numbers (28.60 kB First-Paint, 44.92 kB Total) Because Resumability Breaks Single-Number Comparisons
Every other port in this series has one number: a gzip total. Qwik has two, because Qwik's whole model is "don't ship most of your JS until the user needs it." First-paint is 28.60 kB (−42% vs React). Total across all 24 lazy chunks is 44.92 kB (−8%). Neither number alone is comparable; both are part of the picture.
Entry #7 in the framework comparison series, and the one that broke my uniform scoreboard format. Every previous port in this series had one number: the total gzipped JS. Qwik doesn't, because Qwik's architecture is explicitly about not shipping most of your JS on first load.
🔗 Live demo: https://sen.ltd/portfolio/portfolio-app-qwik/
📦 GitHub: https://github.com/sen-ltd/portfolio-app-qwik
Resumability vs hydration
Qwik replaces hydration with what it calls resumability. The difference is structural:
- Hydration (React, Vue, Svelte, Solid): server renders HTML, then the client loads all component JS, re-executes everything, and attaches state handlers. The full application runtime has to arrive before the app is interactive.
- Resumability (Qwik): server renders HTML and serializes component state directly into the HTML. Component JS is loaded only when the user triggers something that needs it — a click, a scroll, a visible task.
That means first-paint doesn't need the filter logic, the sort logic, or the event handlers. They arrive lazily, one chunk at a time, when the user actually interacts.
Why there are two numbers
Build output:
dist/portfolio/portfolio-app-qwik/
├── index.html (SSG'd)
├── build/
│ ├── q-naDMFAHy.js (first-paint entry, ~3 kB)
│ ├── q-CI8cUXZK.js (bundle-graph loader, ~2 kB)
│ ├── q-KboGgnde.js (qwik runtime, ~24 kB)
│ ├── q-7reiGSdU.js (filter handler — lazy)
│ └── ...24 chunks, total 44.92 kB gzip
First-paint cost is ~28.60 kB gzip — entry + bundle-graph + the runtime's eager parts. That's what the user downloads before seeing the page.
Total cost if you interact with everything is 44.92 kB gzip. Filter, search, sort, language switch — each handler is its own small chunk that loads on first use.
The 16 kB gap between those numbers represents code that doesn't need to exist on first paint but will be needed eventually. For a typical visitor who just scrolls and reads, that 16 kB never loads. For a power user who exercises every filter, it all loads.
Which number do you compare against the other ports?
Neither answer is fully satisfying
- First-paint 28.60 kB puts Qwik roughly tied with Vue and SvelteKit, −42% from React
- Total 44.92 kB is −8% from React, closer to SvelteKit, larger than Svelte
Both are true. Both describe a different question:
- "How fast can the user see the page?" → first-paint wins
- "What's the total network cost to use every feature?" → total is what matters
I chose to report first-paint as Qwik's headline number on the series scoreboard, with a footnote, because that's the metric Qwik optimizes for and the one that's visible to real users. But it's a judgment call — readers comparing "bundle size" may reasonably want either.
The Qwik-specific APIs
// src/routes/index.tsx
import {
component$,
useSignal,
useComputed$,
useVisibleTask$,
} from '@builder.io/qwik'
import type { PortfolioData, Lang } from '../types'
import { loadPortfolioData } from '../data'
import { filterAndSort, type FilterState } from '../filter'
import { MESSAGES, detectDefaultLang } from '../i18n'
export default component$(() => {
const lang = useSignal<Lang>(detectDefaultLang())
const filter = useSignal<FilterState>({
query: '', category: 'all', stack: 'all', stage: 'all', sort: 'number',
})
const data = useSignal<PortfolioData | null>(null)
const loading = useSignal(true)
const loadError = useSignal<string | null>(null)
// Runs only on the client, on first visible, exactly once
useVisibleTask$(async () => {
try {
data.value = await loadPortfolioData()
} catch (e) {
loadError.value = String(e)
} finally {
loading.value = false
}
})
return (
<div>
{loading.value && <div>Loading...</div>}
{data.value && (/* ... */)}
</div>
)
})
Two things worth calling out:
component$(...)/useVisibleTask$(...)— the$suffix is load-bearing. It marks a boundary where Qwik's optimizer can split code into a lazy-loadable chunk. This is how resumability gets fine-grained: every$-suffixed function is a potential deferral point.useVisibleTask$instead ofuseTask$—useTask$runs during SSG/SSR, which means my original version tried tofetch('/portfolio/data.json')inside Node.js and immediately failed.useVisibleTask$runs only on the client after the element becomes visible, which is what you want for post-hydration data loading.
The SSG configuration was the hardest part of this port
The static adapter in adapters/static/vite.config.ts:
import { staticAdapter } from '@builder.io/qwik-city/adapters/static/vite'
import { extendConfig } from '@builder.io/qwik-city/vite'
import baseConfig from '../../vite.config'
export default extendConfig(baseConfig, () => ({
build: {
ssr: true,
rollupOptions: {
input: ['@qwik-city-plan', '@qwik-city-sw-register'],
},
},
plugins: [
staticAdapter({
origin: 'https://sen.ltd',
ssg: { log: 'debug' },
} as any),
],
}))
Plus the main vite.config.ts needs basePathname on the qwikCity plugin, not on staticAdapter:
export default defineConfig(() => ({
base: '/portfolio/portfolio-app-qwik/',
plugins: [
qwikCity({ basePathname: '/portfolio/portfolio-app-qwik/' }),
qwikVite(),
],
}))
I got this wrong on my first attempt — I put basePathname on staticAdapter, where it silently does nothing. The result was a successful build that produced zero rendered pages (only a default 404.html). Fixing it required reading the Qwik internals to find api.getBasePathname() and tracing where the static generator actually looks.
Similar story with useVisibleTask$ — I initially used useTask$ and got SSG-time fetch failures. Qwik's documentation around "what runs where" is the area that cost me the most time in the entire series.
Once it's set up right, the framework is great. But setup is a noticeably bumpier path than React or Vue.
Shared code is byte-identical
$ diff repos/portfolio-app-react/src/filter.ts repos/portfolio-app-qwik/src/filter.ts
# no output
Tests
14 Vitest cases on filter.ts, same suite as every other port.
Scoreboard
| Port | gzip | vs React |
|---|---|---|
| 021 React | 49.00 kB | — |
| 022 Vue | 28.76 kB | −41% |
| 023 Svelte | 18.92 kB | −61% |
| 024 Solid | 8.33 kB | −83% |
| 025 Nuxt | 52.01 kB | +7% |
| 026 SvelteKit | 32.50 kB | −33% |
| 027 Qwik | 28.60 kB (first-paint) | −42% |
* Qwik's number is first-paint synchronous JS gzip. All chunks total 44.92 kB. Resumability makes a single comparable bundle number impossible — the two apps literally measure different things.
Series
This is entry #27 in my 100+ public portfolio series, and #7 in the framework comparison.
- 📦 Repo: https://github.com/sen-ltd/portfolio-app-qwik
- 🌐 Live: https://sen.ltd/portfolio/portfolio-app-qwik/
- 🏢 Company: https://sen.ltd/
Next: Astro islands (028), the series record-holder at 3.17 kB — because Astro's default runtime is literally zero bytes.

Top comments (0)