Frontend

Qwik: Building Resumable Web Apps

Mayur Dabhi
Mayur Dabhi
June 10, 2026
14 min read

You server-render a React page, it shows up fast, and then nothing happens when you click a button for a second or two. The HTML is there, but the framework is still downloading and re-running all your components in the browser so it can attach event handlers. That step is called hydration, and on a cheap Android phone it's often the slowest part of the whole page load.

Qwik takes a different route. It skips hydration completely and uses something it calls resumability: the server writes enough information into the HTML that the browser can pick up where the server stopped, and JavaScript for a handler is only downloaded when someone actually triggers it. Qwik comes from Miško Hevery (who created AngularJS) and the Builder.io team, and it hit 1.0 in 2023. Below I'll walk through how it works, what the code looks like, and where I think it's worth using.

Why people call it O(1)

The JavaScript needed to make a Qwik page interactive doesn't grow with the app. A page with 10 components and one with 1,000 both start with the same tiny loader (around 1KB). A typical React app ships a few hundred KB before the first click does anything.

Resumability vs Hydration: The Core Difference

Next.js, Nuxt and SvelteKit all hydrate. Astro reduces it with islands, but each interactive island still hydrates. So it's worth looking at what that actually costs.

How Traditional Hydration Works

When a user visits a server-rendered React or Vue page, the following sequence happens before the page is usable:

  1. Server renders HTML: the page is sent to the browser and painted
  2. Browser downloads the full JS bundle: could be 100KB to 1MB+
  3. Framework boots: parses and executes all component code
  4. Framework re-runs rendering: reconstructs the virtual DOM in memory
  5. Event listeners are attached: page finally becomes interactive

Steps 2 to 4 repeat work the server already did. On a fast laptop you barely notice. On a slow phone over a bad connection it can add several seconds, and it gets worse as the app grows.

Qwik's Resumability Model

Qwik serializes the application state (component boundaries, references to event handlers, data) into the HTML. The browser doesn't rebuild anything. It just resumes:

  1. Server renders HTML with serialized state in q:* attributes
  2. A tiny ~1KB loader registers one global event listener, and that's all it does up front
  3. Page is immediately visible and appears interactive
  4. User clicks a button, and Qwik fetches only that button's handler
  5. The handler runs, and later interactions load their own code the same way

There's a trade-off hiding here. The first click on something new needs a network request for its code. Qwik prefetches likely chunks in the background (via a service worker), so in practice it's usually fine, but on a very flaky connection you can feel it.

Traditional Hydration (React/Vue) Server Renders HTML Download JS 100-800KB Parse & Boot Framework init Hydrate Re-run render Interactive ✓ Finally usable Blocking: several seconds on slow devices Qwik Resumability Server HTML + state ~1KB Loader Event listener Interactive! Instant ✓ User Clicks Triggers load Only that JS Fetched & run No hydration step before interaction Resumability = no replay, no hydration, no wasted work

Hydration vs Resumability: how page interactivity is achieved

Setting Up Your First Qwik Project

You'll almost never use bare Qwik. You use Qwik City, which is to Qwik what Next.js is to React: file-based routing, layouts, server data loading and form actions.

Prerequisites

1

Create a new Qwik City app

Use the official scaffolding CLI to bootstrap a new project with TypeScript support.

Terminal
npm create qwik@latest

# Interactive prompts will ask:
# ? Where would you like to create your new project? › ./my-qwik-app
# ? Select a starter › Empty App (Qwik City)
# ? Would you like to install npm dependencies now? › Yes

cd my-qwik-app
npm start   # Starts dev server at http://localhost:5173
2

Explore the project structure

Qwik City follows a convention-based structure under src/routes/ for routing.

Project Structure
my-qwik-app/
├── public/                 # Static assets
├── src/
│   ├── components/         # Reusable components
│   │   └── router-head/    # <head> management
│   ├── routes/             # File-based routing (Qwik City)
│   │   ├── index.tsx       # → / (home page)
│   │   ├── layout.tsx      # Root layout wrapping all routes
│   │   └── about/
│   │       └── index.tsx   # → /about
│   ├── root.tsx            # Root component (html, head, body)
│   └── entry.ssr.tsx       # Server entry point
├── vite.config.ts
├── tsconfig.json
└── package.json
3

Run the development server

npm start starts Vite with Qwik's dev mode, including hot module replacement and server-side rendering in development.

Writing Qwik Components

Qwik components look familiar if you know React, but the $ suffix is the key innovation. Every function ending in $ marks a lazy-loadable boundary. Qwik's optimizer splits each one into its own chunk, fetched only when needed.

Your First Component

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

// component$() wraps all Qwik components
// The $ marks this as a lazy-loadable chunk
export const Counter = component$(() => {
  // useSignal creates reactive state (like useState in React)
  const count = useSignal(0);

  return (
    <div>
      <p>Count: {count.value}</p>
      {/* onClick$ marks this handler as lazy-loadable */}
      <button onClick$={() => count.value++}>
        Increment
      </button>
      <button onClick$={() => count.value--}>
        Decrement
      </button>
    </div>
  );
});

A few things to notice:

Why the Dollar Sign?

The $ isn't just a naming style. Qwik's Vite optimizer statically analyzes your code and splits everything marked with $ into separate lazy-loaded chunks. Think of it as a marker that says "this can be loaded separately". It shows up on component$, onClick$, useTask$, useComputed$, and a few others. The catch: anything a $ function closes over has to be serializable. If you capture a class instance or a function that isn't wrapped in $(), the optimizer throws an error at build time, and that's the first wall most people hit.

Passing Props

Props and Types
import { component$, Slot } from '@builder.io/qwik';

interface ButtonProps {
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

// Props are typed just like React
export const Button = component$<ButtonProps>(({ variant = 'primary', disabled = false }) => {
  return (
    <button
      class={`btn btn-${variant}`}
      disabled={disabled}
    >
      {/* Slot is the equivalent of children in React */}
      <Slot />
    </button>
  );
});

// Usage
export const App = component$(() => (
  <Button variant="primary">Click Me</Button>
));

State Management with Signals

Qwik's reactivity system is built on fine-grained signals. In React a state change re-runs the component (and often its children). In Qwik a signal change updates only the DOM nodes that read that signal. There's no virtual DOM diff.

useSignal: reactive primitive for a single value (string, number, boolean, or object reference):

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

export const ThemeToggle = component$(() => {
  const isDark = useSignal(true);
  const username = useSignal('');

  return (
    <div>
      {/* Reading: access .value */}
      <p>Theme: {isDark.value ? 'Dark' : 'Light'}</p>

      {/* Writing: assign to .value */}
      <button onClick$={() => (isDark.value = !isDark.value)}>
        Toggle Theme
      </button>

      <input
        value={username.value}
        onInput$={(e) => (username.value = (e.target as HTMLInputElement).value)}
        placeholder="Enter name"
      />
      <p>Hello, {username.value || 'stranger'}!</p>
    </div>
  );
});

useStore: reactive proxy for complex object state (like useReducer or Zustand):

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

interface CartState {
  items: { id: number; name: string; qty: number }[];
  total: number;
}

export const ShoppingCart = component$(() => {
  const cart = useStore<CartState>({
    items: [],
    total: 0,
  });

  const addItem = $((item: { id: number; name: string; price: number }) => {
    const existing = cart.items.find(i => i.id === item.id);
    if (existing) {
      existing.qty++;
    } else {
      cart.items.push({ id: item.id, name: item.name, qty: 1 });
    }
    cart.total += item.price;
  });

  return (
    <div>
      <p>{cart.items.length} items, ${cart.total.toFixed(2)}</p>
      {cart.items.map(item => (
        <div key={item.id}>{item.name} × {item.qty}</div>
      ))}
    </div>
  );
});

useComputed$: derived state that automatically recalculates when dependencies change:

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

export const PriceCalculator = component$(() => {
  const price = useSignal(100);
  const quantity = useSignal(3);
  const discount = useSignal(10); // percentage

  // Automatically recomputes when price, quantity, or discount change
  const total = useComputed$(() => {
    const subtotal = price.value * quantity.value;
    const discountAmount = subtotal * (discount.value / 100);
    return subtotal - discountAmount;
  });

  return (
    <div>
      <input type="number" bind:value={price} /> × {quantity.value}
      <p>After {discount.value}% discount: ${total.value.toFixed(2)}</p>
    </div>
  );
});

useTask$: side effects that run when tracked signals change (like useEffect):

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

export const DataFetcher = component$(() => {
  const userId = useSignal(1);
  const userData = useSignal<{ name: string } | null>(null);
  const loading = useSignal(false);

  // Runs on server AND client when userId changes
  useTask$(async ({ track, cleanup }) => {
    track(() => userId.value); // track this signal

    if (isServer) return; // skip on server-side render

    loading.value = true;
    const controller = new AbortController();
    cleanup(() => controller.abort()); // cleanup on re-run

    const res = await fetch(`/api/users/${userId.value}`, {
      signal: controller.signal,
    });
    userData.value = await res.json();
    loading.value = false;
  });

  return (
    <div>
      {loading.value ? <p>Loading...</p> : <p>{userData.value?.name}</p>}
      <button onClick$={() => userId.value++}>Next user</button>
    </div>
  );
});

Routing with Qwik City

Qwik City uses file-based routing under src/routes/. Every index.tsx becomes a route, and folders represent URL segments. If you've used the Next.js App Router or SvelteKit, this will feel familiar.

Route Files and Conventions

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

// Default export is the page component
export default component$(() => {
  return (
    <main>
      <h1>Welcome to Qwik!</h1>
      <p>Built with resumability for instant performance.</p>
    </main>
  );
});

// Export head for <title> and meta tags
export const head: DocumentHead = {
  title: 'Home | My Qwik App',
  meta: [
    { name: 'description', content: 'A fast Qwik application' },
  ],
};
src/routes/blog/[slug]/index.tsx (dynamic route)
import { component$ } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';

export default component$(() => {
  // Access route params and URL info
  const loc = useLocation();
  const slug = loc.params.slug;

  return (
    <article>
      <h1>Post: {slug}</h1>
      <p>URL: {loc.url.pathname}</p>
    </article>
  );
});

// Route file naming conventions:
// src/routes/index.tsx           → /
// src/routes/about/index.tsx     → /about
// src/routes/blog/[slug]/index.tsx  → /blog/:slug
// src/routes/(auth)/login/index.tsx → /login (grouped, no URL impact)
// src/routes/blog/[...path]/index.tsx → /blog/*

Layouts

A layout.tsx file in any route folder wraps every route below it, so it's where navigation, sidebars and footers go. Layouts can be nested.

src/routes/layout.tsx (root layout)
import { component$, Slot } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';

export default component$(() => {
  return (
    <>
      <header>
        <nav>
          {/* Link uses client-side navigation */}
          <Link href="/">Home</Link>
          <Link href="/about">About</Link>
          <Link href="/blog">Blog</Link>
        </nav>
      </header>

      <main>
        {/* Slot renders the matched child route */}
        <Slot />
      </main>

      <footer>
        <p>© 2026 My Qwik App</p>
      </footer>
    </>
  );
});

Server-Side Data Loading

Two functions cover most server work: routeLoader$ to fetch data before the page renders, and routeAction$ for form submissions and other writes. Both always run on the server, so database calls and secrets stay there.

Loading data with routeLoader$

src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

// Runs on the server before the component renders
// Safe place for DB queries and secrets, none of this is sent to the client
export const usePost = routeLoader$(async ({ params, status }) => {
  const post = await db.posts.findUnique({
    where: { slug: params.slug },
  });

  if (!post) {
    status(404);  // Set HTTP status code
    return null;
  }

  return {
    id: post.id,
    title: post.title,
    content: post.content,
    publishedAt: post.publishedAt.toISOString(),
  };
});

export default component$(() => {
  // usePost() returns a Signal containing the loader's data
  const post = usePost();

  if (!post.value) {
    return <p>Post not found</p>;
  }

  return (
    <article>
      <h1>{post.value.title}</h1>
      <time>{post.value.publishedAt}</time>
      <div dangerouslySetInnerHTML={post.value.content} />
    </article>
  );
});

Handling forms with routeAction$

src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city';

// Server action with Zod validation
export const useContactForm = routeAction$(
  async (data, { redirect }) => {
    // data is already validated here, and typed from the zod schema
    await sendEmail({
      to: 'owner@example.com',
      subject: `Message from ${data.name}`,
      body: data.message,
    });

    throw redirect(302, '/contact/thanks');
  },
  zod$({
    name: z.string().min(2),
    email: z.string().email(),
    message: z.string().min(10),
  })
);

export default component$(() => {
  const action = useContactForm();

  return (
    {/* Form automatically POSTs to the action */}
    <Form action={action}>
      <input name="name" placeholder="Your name" />
      {action.value?.fieldErrors?.name && (
        <p class="error">{action.value.fieldErrors.name}</p>
      )}
      <input name="email" type="email" placeholder="Email" />
      <textarea name="message" placeholder="Message" />
      <button type="submit" disabled={action.isRunning}>
        {action.isRunning ? 'Sending...' : 'Send Message'}
      </button>
    </Form>
  );
});
Ecosystem Maturity

The ecosystem is small compared to React. If your project depends on MUI, Chakra, Radix or a pile of React-only integrations, Next.js is the safer choice. qwik-react lets you render React components inside Qwik, but those components hydrate the normal way, so you lose the main benefit for those parts of the page.

How it compares to Next.js, Nuxt and SvelteKit

The payload numbers below are rough ranges for typical apps, not benchmarks. Your real numbers depend far more on what you put in the page than on the framework.

Feature Qwik City Next.js 14 Nuxt 3 SvelteKit
Hydration Required No (resumable) Yes Yes Yes
Initial JS payload ~1KB fixed 70-400KB+ 60-350KB+ 30-200KB+
Interactive on slow devices Almost immediately After hydration After hydration After hydration (lighter)
Reactivity model Fine-grained signals Virtual DOM Virtual DOM Compiled (no VDOM)
Language TypeScript / JSX TypeScript / JSX TypeScript / Vue SFC TypeScript / Svelte
Server functions routeLoader$, routeAction$ Server Actions, RSC useFetch, server routes load(), actions
Ecosystem size Growing Very large Large Medium
Learning curve Medium ($ syntax) Medium to high Medium Low to medium
Qwik City Application Architecture Browser ~1KB qwik loader Global event interceptor Serialized State q:* HTML attributes Lazy JS Chunks Fetched on interaction Signals → Fine-grained DOM updates Server (SSR) Qwik City Router File-based routing routeLoader$ Server data fetching routeAction$ Form mutations HTML serialization + state snapshot Data Layer Database / ORM External APIs Cache / Edge KV Deployment targets: Node.js, Vercel, Cloudflare Workers, Netlify

Qwik City architecture: the server renders, the browser resumes

Deploying Qwik City

Deployment works through adapters. You run one command and it adds the config for that platform:

Adding a Deployment Adapter
# Vercel (recommended for most projects)
npm run qwik add vercel-edge

# Cloudflare Workers / Pages (best for global edge performance)
npm run qwik add cloudflare-pages

# Netlify
npm run qwik add netlify-edge

# Node.js / Express (self-hosted)
npm run qwik add express

# AWS Lambda (serverless)
npm run qwik add aws-lambda

# Static Site Generation (no server)
npm run qwik add static

# After adding an adapter, build for production:
npm run build

Qwik works well on edge platforms like Cloudflare Pages, because the HTML is rendered close to the user and there's no big bundle to wait for afterwards. If your data lives in a single database region, though, the edge function still has to call back to it, so don't expect miracles for data-heavy pages.

Qwik API Quick Reference

API Purpose React Equivalent
component$() Define a lazy component function Component()
useSignal() Reactive primitive value useState()
useStore() Reactive object state useReducer()
useComputed$() Derived reactive value useMemo()
useTask$() Side effects useEffect()
useResource$() Async data with loading state use(promise)
routeLoader$() Server data loading getServerSideProps
routeAction$() Server mutations Server Actions
<Slot /> Render child content {children}
<Link /> Client-side navigation <Link /> (Next.js)

When I'd pick Qwik (and when I wouldn't)

Qwik makes the most sense when first-load speed directly affects money: landing pages, e-commerce product pages, content sites with a lot of mobile traffic from slower networks. For those, removing hydration is a real, measurable win in Core Web Vitals.

I'd skip it for an internal dashboard where users sit on fast machines and keep the tab open all day. Hydration cost is paid once and then forgotten, and you'd be giving up React's ecosystem for a benefit nobody notices. Same if your team leans heavily on React component libraries.

If you want to try it, build one real page with it: something with a form, a list and a bit of client state. You'll run into the $ serialization rules within an hour, and that tells you quickly whether the model fits how you think. After that, read up on useContext() for shared state and useVisibleTask$() for code that must run in the browser (use it sparingly, since it runs eagerly and works against the whole point).

Qwik JavaScript Performance Resumability Frontend Web Framework
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.