Frontend

Qwik: Building Resumable Web Apps

Mayur Dabhi
Mayur Dabhi
June 10, 2026
14 min read

Qwik is a revolutionary JavaScript framework that fundamentally rethinks how web applications deliver interactivity. While React, Vue, Angular, and virtually every other modern framework require a process called "hydration" — where the browser re-executes your server-rendered JavaScript before the page becomes interactive — Qwik eliminates hydration entirely with a concept called resumability. The result is near-instant Time to Interactive (TTI) regardless of application complexity, because Qwik lazy-loads JavaScript only at the precise moment a user triggers an interaction. Created by Miško Hevery (the architect of AngularJS) and the team at Builder.io, Qwik reached its stable 1.0 release in 2023 and has since become one of the most exciting frameworks in the JavaScript ecosystem.

The O(1) Performance Promise

Qwik achieves O(1) JavaScript loading — the amount of JS required to make your page interactive stays constant regardless of app size. A 10-component app and a 1000-component app both deliver the same sub-1KB bootstrap payload. Traditional React apps routinely ship 200–500KB before any button click is possible.

Resumability vs Hydration: The Core Difference

To truly appreciate Qwik, you need to understand the problem it solves. Every server-rendered framework today — Next.js, Nuxt, SvelteKit, Astro with islands — still faces the hydration bottleneck when using component frameworks.

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–4 are pure overhead — work the server already did. On slow connections or low-end devices, this hydration penalty can take 3–10 seconds. The larger the app, the worse it gets.

Qwik's Resumability Model

Qwik serializes the entire application state — component tree, event handlers, data — into the HTML as attributes. When the browser receives this HTML, it can resume from exactly where the server left off:

  1. Server renders HTML with serialized state in q:* attributes
  2. A tiny ~1KB loader registers a global event listener — that's it
  3. Page is immediately visible and appears interactive
  4. User clicks a button — Qwik fetches only that button's handler code
  5. Interaction executes — subsequent lazy loads happen as needed

No boot. No re-rendering. No wasted work. Just execution on demand.

Traditional Hydration (React/Vue) Server Renders HTML Download JS 100–800KB Parse & Boot Framework init Hydrate Re-run render Interactive ✓ Finally usable Blocking: user waits 2–8+ seconds on slow devices Qwik Resumability Server HTML + state ~1KB Loader Event listener Interactive! Instant ✓ User Clicks Triggers load Only that JS Fetched & run Zero blocking — interactive from first byte Resumability = no replay, no hydration, no wasted work

Hydration vs Resumability: how page interactivity is achieved

Setting Up Your First Qwik Project

Qwik uses Qwik City as its meta-framework — think of it as the equivalent of Next.js for React or SvelteKit for Svelte. Qwik City adds file-based routing, layouts, server functions, and full-stack capabilities on top of Qwik's core.

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 $-suffixed function marks a lazy-loadable boundary — Qwik's optimizer splits these into separate chunks that are 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 $ suffix isn't just convention — Qwik's Vite optimizer statically analyzes your code and splits everything marked with $ into separate lazy-loaded chunks. It's a visible contract: "this boundary can be loaded separately." You'll see it on component$, onClick$, useTask$, useComputed$, and more.

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. Unlike React's model where component re-renders ripple up the tree, Qwik signals update only the specific DOM nodes that depend on them — no virtual DOM diffing required.

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. This is identical in concept to Next.js App Router or SvelteKit.

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 all child routes with shared UI — navigation, sidebars, footers. Nested layouts are fully supported.

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

Qwik City provides two primary mechanisms for server-side data: routeLoader$ for fetching data before rendering, and routeAction$ for handling form submissions and mutations.

routeLoader$ — Fetching Data

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
// Can access databases, secrets, file system — never exposed to 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>
  );
});

routeAction$ — Handling Mutations

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 validated — TypeScript knows the shape
    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

Qwik's ecosystem is still growing. If your project requires specific React component libraries (MUI, Chakra UI, Radix) or a large collection of third-party integrations, React/Next.js still has the edge. Qwik does support rendering React components via qwik-react as an escape hatch for using existing React libraries inside a Qwik app.

Performance Comparison

How does Qwik stack up against the modern meta-framework landscape? Here's an honest comparison across the dimensions that matter most:

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+
TTI on slow 3G Near-instant 3–12 seconds 3–10 seconds 1–6 seconds
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–High Medium Low–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 full-stack architecture — server renders, browser resumes

Deploying Qwik City

Qwik City ships with official adapters for every major deployment platform. You add an adapter package and it handles the platform-specific configuration:

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's resumability model makes it exceptionally well-suited for edge deployments. Since there's no hydration step, the browser doesn't need to wait for JavaScript to download before the page responds to interactions — ideal for users in regions far from your origin server.

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)

Key Takeaways and Next Steps

Qwik represents the most significant architectural shift in JavaScript frameworks since React introduced the virtual DOM. By replacing hydration with resumability, it solves the fundamental scaling problem that plagues every other framework: the more complex your app, the more JavaScript must run before it's interactive.

What You've Learned

  • Resumability vs Hydration: Qwik serializes state into HTML; browsers resume instead of replaying
  • O(1) JS Loading: Application complexity doesn't increase initial JavaScript payload
  • The $ Convention: Marks lazy-loadable boundaries; optimizer splits code automatically
  • Signals: Fine-grained reactivity with useSignal, useStore, useComputed$, useTask$
  • Qwik City: Full-stack meta-framework with file routing, routeLoader$, routeAction$
  • Edge-first: Resumability pairs perfectly with edge deployments for global performance

To continue your Qwik journey, explore the official documentation for topics like useContext() for cross-tree state sharing, useVisibleTask$() for browser-only effects, and the qwik-react integration for using existing React component libraries inside your Qwik app.

"The web's performance problem isn't that developers write slow code — it's that every framework forces you to ship all your code upfront. Qwik flips that assumption entirely."
— Miško Hevery, Creator of Qwik and AngularJS

Whether you're building a content-heavy marketing site, a complex SaaS dashboard, or a high-traffic e-commerce platform, Qwik's resumability model ensures your users experience instant interactivity regardless of device capability or network speed. That's a promise no hydration-based framework can match.

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.