Skip to main content
Advertisement

19.1 Introduction to Qwik

Qwik is a next-generation JavaScript framework designed to fundamentally change how web performance works. It eliminates the "hydration" cost that plagues existing frameworks and guarantees instant initial loading regardless of application size.


What is Qwik?

Qwik is an open-source web framework created by Misko Hevery in 2021. Misko is the developer who built AngularJS (now Angular) at Google and is a world-renowned authority on framework design.

After years of working with Angular and React, he ran into one fundamental question:

"Why do we need to re-execute on the browser what the server has already rendered as HTML?"

That question was the starting point for Qwik.

Qwik's Core Philosophy

The core of Qwik is Resumability. The server serializes the full application state into HTML, and the browser resumes from that state — it does not re-execute from scratch.

Traditional approach (Hydration):
Server: render HTML → Browser: display HTML → download JS → parse JS →
re-execute app (re-render) → attach event handlers → interactive

Qwik approach (Resumability):
Server: render HTML + serialize state → Browser: display HTML → immediately interactive
(JS is only loaded when the user actually interacts)

Resumability vs Hydration

What is Hydration?

Hydration is the process of turning "static" server-rendered HTML into something dynamic in the browser.

1. Server generates HTML and sends it to the browser
2. Browser displays the HTML on screen (fast — FCP achieved)
3. Browser downloads the JavaScript bundle (can be slow)
4. JavaScript is parsed and executed
5. Virtual DOM is reconstructed
6. Virtual DOM is compared against the real DOM (reconciliation)
7. Event handlers are attached to DOM nodes
8. Only now can the user click and type (TTI achieved)

Steps 3 through 8 represent the "hydration cost." The larger the app, the higher that cost.

The Problem with Hydration

// Example of the hydration problem in Next.js (React)
// HTML rendered on the server:
// <div id="root"><h1>Hello</h1><button>Click</button></div>

// What the browser has to do:
import React from 'react'; // ~40KB
import ReactDOM from 'react-dom'; //
import App from './App'; // entire app code

// Re-execute the entire app
ReactDOM.hydrateRoot(
document.getElementById('root'),
<App /> // already rendered on the server, but re-executed in the browser
);

// Result: hundreds of KB of JS must run just to handle a button click

Real-world cost:

  • Hydration on Amazon can take several seconds
  • 3–5× slower on low-end devices (mid-to-low-range mobile)
  • TTI (Time to Interactive) delay = higher bounce rates
  • Lower Core Web Vitals scores

How Resumability Works

Qwik has the server embed the entire execution state of the app inside the HTML.

<!-- Example HTML generated by Qwik -->
<html>
<body>
<div q:container="paused" q:version="1.0" q:render="ssr">
<!-- component tree is serialized -->
<button
on:click="./chunk-abc123.js#Counter_onClick"
q:id="1"
>
Count: 0
</button>
</div>

<!-- state and context serialized as JSON -->
<script type="qwik/json">
{
"refs": {"1": "0"},
"ctx": {},
"objs": [0]
}
</script>

<!-- event map serialization -->
<script id="qwikloader">/* ~1KB loader */</script>
</body>
</html>

When the browser receives this HTML:

  1. Immediately displays on screen (FCP)
  2. Only the qwikloader (~1KB) runs
  3. When the user clicks the button, only the handler code for that button is lazily loaded
  4. State is already in the HTML, so no re-execution is needed

O(1) Loading

Qwik's most revolutionary property is O(1) initial loading.

Loading Complexity of Existing Frameworks

React/Next.js: O(n) — hydration time grows proportionally with app size
Vue/Nuxt: O(n) — same
Svelte: O(n) — smaller bundles but still requires hydration
Angular: O(n) — largest bundle, slowest hydration

Qwik: O(1) — initial load time independent of app size

Why O(1) Is Possible

// Qwik component example
import { component$, useSignal } from '@builder.io/qwik';

export default component$(() => {
const count = useSignal(0);

// The $ on onClick$ = split this function into a separate chunk
// The browser does not download this code until a click occurs
return (
<button onClick$={() => count.value++}>
Count: {count.value}
</button>
);
});

After the Qwik Optimizer (build tool) transforms the above:

// Main chunk (no initial load needed — qwikloader handles it)
// chunk-main.js (nearly empty)

// Event handler chunk (only loaded on click)
// chunk-abc123.js
export const Counter_onClick = () => {
// count.value++ code
};

Result: Whether there are 100 or 1000 buttons, only the ~1KB qwikloader runs on initial page load.


Deep Dive: Hydration Problems in SSR Frameworks

Problem 1: Double Rendering

Server: render entire app → send HTML
Browser: render entire app again (for hydration)
→ wasted CPU, wasted time

Problem 2: Waterfall Loading

Receive HTML → load CSS → download JS bundle → parse JS →
initialize React → build component tree → diff DOM → bind events
↑ this entire sequence happens sequentially

Problem 3: Limits of Partial Hydration

Some frameworks implement partial hydration via "islands architecture," but:

// Astro's islands approach (partial hydration)
---
import Counter from './Counter.jsx';
---
<html>
<body>
<h1>Static content</h1>
<!-- only this component is hydrated -->
<Counter client:visible />
</body>
</html>

// Problem: if the Counter component is complex, its hydration cost is still O(n)
// Plus, sharing state between components is difficult

Qwik subdivides this not at the component level but at the event handler level.


Qwik's Serialization

What Can and Cannot Be Serialized

import { component$, useStore, useSignal } from '@builder.io/qwik';

export default component$(() => {
// Serializable — primitives
const name = useSignal('Alice');
const age = useSignal(30);

// Serializable — plain objects
const user = useStore({
name: 'Alice',
email: 'alice@example.com',
preferences: {
theme: 'dark',
language: 'en'
}
});

// Not serializable — functions (use QRL instead)
// const handler = () => { ... }; // don't do this
// onClick$={() => { ... }} // use $ sign instead

// Not serializable — class instances
// const date = useSignal(new Date()); // use with care

return <div>{user.name}</div>;
});

How State Is Serialized into HTML

<!-- useStore data is embedded in the HTML -->
<script type="qwik/json">
{
"refs": {
"1": "#0", // signal reference
"2": "#1" // store reference
},
"ctx": {},
"objs": [
"Alice", // index 0: name signal value
{ // index 1: user store value
"name": "Alice",
"email": "alice@example.com",
"preferences": {"theme": "dark", "language": "en"}
}
]
}
</script>

Lazy Execution — The Role of the $ Sign

$ is one of the most important concepts in Qwik.

What the $ Sign Means

// Regular function without $ — included in the bundle
const normalFunction = () => {
console.log('always loaded');
};

// Function with $ — split into a separate chunk, loaded only when needed
const lazyFunction = $(() => {
console.log('loaded only when needed');
});

Where the $ Sign Is Used

import {
component$, // component definition
useTask$, // side effects
useVisibleTask$, // client-only tasks
useComputed$, // computed values
$ // lazy-load any function
} from '@builder.io/qwik';

export const MyComponent = component$(() => {
// every $ function is split into a separate JS chunk

useTask$(({ track }) => {
// runs on both server and client, reactive tracking
});

useVisibleTask$(() => {
// runs only when the component enters the viewport
// client-only code (window, document, etc.)
});

return (
<div>
<button onClick$={() => alert('Clicked!')}>
Handler code loads on click
</button>
</div>
);
});

What the Optimizer Does with $ Transformations

// Before transformation (code written by the developer)
export const Counter = component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
);
});

// After transformation (code generated by Qwik Optimizer)
// --- chunk-counter.js ---
export const Counter = componentQrl(qrl(() => import('./chunk-counter_render.js'), 'Counter_render'));

// --- chunk-counter_render.js ---
export const Counter_render = () => {
const count = useSignal(0);
return jsx('button', {
'onClick$': qrl(() => import('./chunk-counter_onClick.js'), 'Counter_onClick'),
children: count.value
});
};

// --- chunk-counter_onClick.js ---
export const Counter_onClick = (count) => {
count.value++;
};

Basic Component Examples

Hello World

// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import type { DocumentHead } from '@builder.io/qwik-city';

export default component$(() => {
return (
<main>
<h1>Hello, Qwik!</h1>
<p>Welcome to the next-generation web framework.</p>
</main>
);
});

export const head: DocumentHead = {
title: 'Getting Started with Qwik',
meta: [
{
name: 'description',
content: 'My first page built with Qwik',
},
],
};

Counter Component

// src/components/counter/counter.tsx
import { component$, useSignal } from '@builder.io/qwik';

interface CounterProps {
initialValue?: number;
step?: number;
}

export const Counter = component$<CounterProps>(({
initialValue = 0,
step = 1
}) => {
const count = useSignal(initialValue);

return (
<div class="counter">
<h2>Counter</h2>
<div class="controls">
<button
onClick$={() => count.value -= step}
disabled={count.value <= 0}
>
-{step}
</button>
<span class="value">{count.value}</span>
<button onClick$={() => count.value += step}>
+{step}
</button>
</div>
<p>Current value: {count.value}</p>
</div>
);
});

Simple Todo App

// src/components/todo/todo.tsx
import { component$, useStore } from '@builder.io/qwik';

interface Todo {
id: number;
text: string;
done: boolean;
}

interface TodoState {
items: Todo[];
input: string;
nextId: number;
}

export const TodoApp = component$(() => {
const state = useStore<TodoState>({
items: [],
input: '',
nextId: 1,
});

return (
<div class="todo-app">
<h1>Todo List</h1>

<div class="add-todo">
<input
type="text"
value={state.input}
onInput$={(ev) => {
state.input = (ev.target as HTMLInputElement).value;
}}
placeholder="Enter a task..."
/>
<button
onClick$={() => {
if (state.input.trim()) {
state.items.push({
id: state.nextId++,
text: state.input.trim(),
done: false,
});
state.input = '';
}
}}
>
Add
</button>
</div>

<ul>
{state.items.map((todo) => (
<li key={todo.id} class={todo.done ? 'done' : ''}>
<input
type="checkbox"
checked={todo.done}
onChange$={() => {
todo.done = !todo.done;
}}
/>
<span>{todo.text}</span>
<button
onClick$={() => {
const idx = state.items.indexOf(todo);
state.items.splice(idx, 1);
}}
>
Delete
</button>
</li>
))}
</ul>

<p>
Completed: {state.items.filter(t => t.done).length} / {state.items.length}
</p>
</div>
);
});

Framework Comparison

FeatureReact/Next.jsSvelteKitSolid.jsQwik/QwikCity
Initial JS sizeLarge (~100KB+)Small (no runtime)Medium (~25KB)Minimal (~1KB)
HydrationFull hydrationFull hydrationFull hydrationNone (resumable)
Rendering modelVirtual DOM diffingCompile-time reactivityFine-grained reactivityFine-grained reactivity + lazy
SSRSupported (Next.js)Built-inSupported (SolidStart)Built-in
Learning curveMediumEasyMediumSteep ($ concept)
MaturityVery highHighMediumGrowing
EcosystemVastLargeMediumGrowing
TypeScriptExcellentExcellentExcellentExcellent
Core Web VitalsApp-size dependentGoodGoodBest
DX (Dev Experience)Very goodVery goodGoodGood (improving)
Notable usersMeta, VercelVercel-backedBuilder.io

Bundle Size Comparison (same app)

Framework          Initial JS (gzipped)   Hydration cost
─────────────────────────────────────────────────────────
React + Next.js ~130KB O(n) proportional to app size
Vue 3 + Nuxt ~75KB O(n) proportional to app size
Svelte + SvelteKit ~50KB O(n) proportional to app size
Solid + SolidStart ~25KB O(n) proportional to app size
Qwik + QwikCity ~1KB O(1) independent of app size

When to Use Qwik

1. Content-heavy websites

- News and media platforms
- Marketing landing pages
- Blogs and documentation sites
- E-commerce product listing pages
→ Qwik's benefits are maximized when content is rich but interactions are sparse

2. Projects where performance is critical

- Services where Core Web Vitals directly impact SEO and revenue
- Apps with many users on low-end devices (emerging markets)
- Apps that need to support 3G/4G network conditions

3. Large-scale apps

- Hydration cost of traditional frameworks grows with app size
- Qwik maintains O(1) loading regardless of scale

Less Suitable Cases

1. Highly interactive SPAs

- Real-time collaboration tools (Figma, Google Docs style)
- Complex drag-and-drop interfaces
→ These load a lot of JS anyway, so Qwik's advantages are diluted

2. Small-scale projects

- A single simple landing page → Astro or plain HTML is simpler
- A team already proficient in React → weigh learning cost vs performance gain

3. When a mature ecosystem is required

- Need the vast React ecosystem (libraries, tools, community)
- Need to reuse many existing React components
→ Qwik cannot use React components directly (some can be wrapped with Qwikify$)

Practical Example: Search Autocomplete

// src/components/search/search-autocomplete.tsx
import {
component$,
useSignal,
useStore,
useTask$,
$
} from '@builder.io/qwik';

interface SearchResult {
id: number;
title: string;
category: string;
}

export const SearchAutocomplete = component$(() => {
const query = useSignal('');
const state = useStore<{
results: SearchResult[];
loading: boolean;
selected: SearchResult | null;
}>({
results: [],
loading: false,
selected: null,
});

// useTask$ runs whenever query.value changes
useTask$(async ({ track, cleanup }) => {
const q = track(() => query.value);

if (q.length < 2) {
state.results = [];
return;
}

state.loading = true;

// cancel previous request with AbortController
const controller = new AbortController();
cleanup(() => controller.abort());

try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
state.results = await res.json();
} catch (e) {
if ((e as Error).name !== 'AbortError') {
console.error('Search error:', e);
}
} finally {
state.loading = false;
}
});

const selectResult = $((result: SearchResult) => {
state.selected = result;
query.value = result.title;
state.results = [];
});

return (
<div class="search-container">
<input
type="search"
value={query.value}
onInput$={(ev) => {
query.value = (ev.target as HTMLInputElement).value;
}}
placeholder="Search..."
/>

{state.loading && <span class="spinner">Searching...</span>}

{state.results.length > 0 && (
<ul class="autocomplete-list">
{state.results.map((result) => (
<li
key={result.id}
onClick$={() => selectResult(result)}
>
<strong>{result.title}</strong>
<small>{result.category}</small>
</li>
))}
</ul>
)}

{state.selected && (
<div class="selected">
Selected: {state.selected.title}
</div>
)}
</div>
);
});

Pro Tips

Tip 1: Be Aware of Serialization Boundaries

// Bad: managing non-serializable values as state
const state = useStore({
date: new Date(), // Date objects require care
fn: () => {}, // functions are never allowed
element: document.body, // DOM references are not allowed
});

// Good: use serializable forms
const state = useStore({
dateString: new Date().toISOString(), // use a string
element: useSignal<Element>(), // use useSignal for refs
});
// use the $ sign for functions
const handleClick = $(() => {});

Tip 2: The Golden Rule of $ — Closure Rules

// Bad: capturing non-serializable values in a closure
const MyComponent = component$(() => {
const localObj = { value: 42 }; // plain object

// this closure captures localObj, but
// localObj is not serialized → potential error
return <button onClick$={() => console.log(localObj.value)}>Click</button>;
});

// Good: manage with Signal/Store
const MyComponent = component$(() => {
const value = useSignal(42); // serializable

return <button onClick$={() => console.log(value.value)}>Click</button>;
});

Tip 3: Integrating React Components with Qwikify$

// Using React ecosystem components in Qwik
import { qwikify$ } from '@builder.io/qwik-react';
import { SomeReactComponent } from 'some-react-library';

// Wrap a React component as a Qwik component
export const QwikSomeComponent = qwikify$(SomeReactComponent, {
eagerness: 'hover', // hydrate on mouse hover
});

// Or without eagerness (hydrate on click)
export const QwikSomeComponent2 = qwikify$(SomeReactComponent);

Tip 4: Server/Client Code Branching

import { component$, useVisibleTask$, isServer } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';

// define a server-only function with server$
const getServerData = server$(async function() {
// this code is never exposed to the client
const secret = process.env.API_SECRET;
const data = await fetch(`https://api.example.com/data?key=${secret}`);
return data.json();
});

export const SecureComponent = component$(() => {
useVisibleTask$(async () => {
// called from the client, but executed on the server
const data = await getServerData();
console.log(data);
});

return <div>Secure component</div>;
});

Tip 5: Performance Monitoring

// Measuring Qwik app performance
// Browser DevTools → Network tab — check:
// 1. Initial HTML request size
// 2. qwikloader.js size (~1KB)
// 3. Chunks loaded on user interaction

// Chrome DevTools Performance tab:
// - FCP (First Contentful Paint): fast with Qwik
// - TTI (Time to Interactive): nearly instant with Qwik
// - TBT (Total Blocking Time): nearly 0 with Qwik

Tip 6: Bundle Analysis for Chunk Optimization

# Analyze bundle after build
npm run build
# Check chunk sizes in the dist/ folder

# If a chunk is too large → split into smaller components
# If there are too many chunks → group related features together

Summary

ConceptDescription
ResumabilityServer serializes state into HTML; browser resumes without restarting
O(1) loadingInitial JS load (~1KB) independent of app size
$ signOptimizer directive that splits a function into a separate chunk
SerializationMechanism that embeds app state as JSON inside HTML
Lazy executionEvent handlers are only loaded when the actual event fires
QRLQwik URL Reference — a reference to a lazily loaded function

Qwik is a framework born from a fundamental rethinking of web performance. It is the answer to the question: "What if hydration weren't necessary?" The next chapter covers setting up a Qwik development environment and starting a real project.

Advertisement