Qwik vs React: Resumability vs Hydration

8 min read1469 words

After profiling our e-commerce site, I found users waited 3.2 seconds for the "Add to Cart" button to become interactive. The culprit was React's hydration—downloading and executing JavaScript to make server-rendered HTML functional. I rebuilt a prototype in Qwik and that same button worked instantly. Here's what I learned about the fundamental difference between these approaches.

The Hydration Tax

React's server-side rendering follows this pattern:

  1. Server renders HTML and sends it to the browser
  2. Browser displays the HTML immediately (fast First Contentful Paint)
  3. Browser downloads the JavaScript bundle
  4. React executes the bundle and "hydrates"—recreating the component tree and attaching event listeners
  5. Application becomes interactive

The gap between steps 2 and 5 is the hydration tax. Users see content but can't interact. On our e-commerce site with 180KB of JavaScript, this gap averaged 3.2 seconds on mobile devices.

// React component - requires full hydration
function ProductCard({ product }: { product: Product }) {
  const [quantity, setQuantity] = useState(1);
  const [isAdding, setIsAdding] = useState(false);
 
  const handleAddToCart = async () => {
    setIsAdding(true);
    await addToCart(product.id, quantity);
    setIsAdding(false);
  };
 
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h2>{product.name}</h2>
      <p>${product.price}</p>
 
      <select
        value={quantity}
        onChange={(e) => setQuantity(Number(e.target.value))}
      >
        {[1, 2, 3, 4, 5].map(n => (
          <option key={n} value={n}>{n}</option>
        ))}
      </select>
 
      <button onClick={handleAddToCart} disabled={isAdding}>
        {isAdding ? 'Adding...' : 'Add to Cart'}
      </button>
    </div>
  );
}

Even though this component renders quickly on the server, the button does nothing until React hydrates. The entire component tree—not just this component—must hydrate before any interactivity works.

Resumability: Qwik's Alternative

Qwik takes a different approach. Instead of replaying JavaScript to reconstruct state, it serializes the application state into the HTML. The browser can resume exactly where the server left off.

// Qwik component - resumes without hydration
import { component$, useSignal, $ } from '@builder.io/qwik';
 
export const ProductCard = component$<{ product: Product }>(({ product }) => {
  const quantity = useSignal(1);
  const isAdding = useSignal(false);
 
  const handleAddToCart = $(async () => {
    isAdding.value = true;
    await addToCart(product.id, quantity.value);
    isAdding.value = false;
  });
 
  return (
    <div class="product-card">
      <img src={product.image} alt={product.name} />
      <h2>{product.name}</h2>
      <p>${product.price}</p>
 
      <select
        value={quantity.value}
        onChange$={(e) => {
          quantity.value = Number((e.target as HTMLSelectElement).value);
        }}
      >
        {[1, 2, 3, 4, 5].map(n => (
          <option key={n} value={n}>{n}</option>
        ))}
      </select>
 
      <button onClick$={handleAddToCart} disabled={isAdding.value}>
        {isAdding.value ? 'Adding...' : 'Add to Cart'}
      </button>
    </div>
  );
});

Notice the $ suffix on event handlers and functions. This tells Qwik to treat these as lazy-loadable chunks. The button's click handler doesn't load until the user actually clicks it.

How Serialization Works

When Qwik renders on the server, it serializes state and event handler references into the HTML:

<!-- Qwik's server output (simplified) -->
<div class="product-card" q:container="paused">
  <button
    on:click="./chunk-abc123.js#handleAddToCart[0]"
    q:obj="{ isAdding: false }"
  >
    Add to Cart
  </button>
</div>
 
<script type="qwik/json">
{
  "signals": [{ "id": 0, "value": 1 }],
  "refs": [...]
}
</script>

When a user clicks the button, Qwik:

  1. Downloads only the chunk containing handleAddToCart
  2. Deserializes the signal values from the embedded JSON
  3. Executes the handler with full access to current state

No replay. No full bundle download. The code for other components never loads unless needed.

Performance Comparison

I tested both approaches on the same product listing page with 24 products:

| Metric | React (Next.js 15) | Qwik | |--------|-------------------|------| | First Contentful Paint | 0.8s | 0.7s | | Time to Interactive | 3.4s | 0.9s | | Total JavaScript | 186KB | 12KB (initial) | | Lighthouse Performance | 72 | 98 |

The dramatic difference in Time to Interactive comes from Qwik loading only the tiny runtime (~1KB) initially. Component code loads on-demand.

The Signal Difference

React uses state that triggers component re-renders:

// React - state change re-renders entire component
const [count, setCount] = useState(0);
 
// When setCount is called:
// 1. Component function re-executes
// 2. Virtual DOM diff runs
// 3. Real DOM updates

Qwik uses signals that update surgically:

// Qwik - signal change updates only the binding
const count = useSignal(0);
 
// When count.value changes:
// 1. Only the specific DOM text node updates
// 2. No component re-execution
// 3. No diffing

This matters for performance in complex UIs. A parent component's state change in React triggers child re-renders. In Qwik, signals update independently of the component tree.

State Management Built-In

React typically needs external libraries for complex state:

// React with Zustand
import { create } from 'zustand';
 
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),
}));
 
function CartButton() {
  const items = useCartStore((state) => state.items);
  return <span>{items.length} items</span>;
}

Qwik's signals work across components without additional libraries:

// Qwik with context and signals
import { createContextId, useContextProvider, useContext, useSignal } from '@builder.io/qwik';
 
export const CartContext = createContextId<Signal<CartItem[]>>('cart');
 
export const CartProvider = component$(() => {
  const cart = useSignal<CartItem[]>([]);
  useContextProvider(CartContext, cart);
  return <Slot />;
});
 
export const CartButton = component$(() => {
  const cart = useContext(CartContext);
  return <span>{cart.value.length} items</span>;
});

Signals in context remain resumable. The cart state serializes to HTML and resumes without hydration.

When Qwik Makes Sense

Based on my experience, Qwik excels in these scenarios:

Content-heavy sites with sparse interactivity: Marketing pages, blogs, documentation sites. Most content is static; only a few elements need JavaScript. Qwik loads code only for those elements.

E-commerce product pages: Fast Time to Interactive directly impacts conversion rates. Studies show each 100ms improvement increases conversions by 1%. Our Qwik prototype showed 22% better add-to-cart rates.

Mobile-first applications: Limited bandwidth and CPU make hydration particularly painful. Qwik's lazy loading adapts to device capabilities.

SEO-critical pages: Search engines increasingly consider Core Web Vitals. Qwik's architecture naturally produces better scores.

When React Remains Better

React still wins in several areas:

Complex single-page applications: Dashboards, admin panels, and apps where users stay engaged for extended sessions. The initial hydration cost amortizes over time.

Ecosystem requirements: Need React Native? React Query? A specific component library? React's ecosystem is vastly larger. Qwik has fewer ready-made solutions.

Team familiarity: React developers are everywhere. Qwik requires learning new patterns. The $ syntax and resumability model take adjustment.

Rapid prototyping: React's ecosystem means faster initial development. You can find a library for almost anything.

Migration Considerations

Moving from React to Qwik isn't a simple port. The mental model differs:

Component boundaries change: In React, components are render boundaries. In Qwik, $ functions define loading boundaries. You'll structure code differently.

// React - component defines boundary
function SearchResults({ query }) {
  const results = useQuery(query);
  return <ResultsList results={results} />;
}
 
// Qwik - $ defines loading boundary
export const SearchResults = component$(({ query }) => {
  const results = useSignal([]);
 
  useVisibleTask$(async () => {
    // This code loads only when component is visible
    results.value = await fetchResults(query);
  });
 
  return <ResultsList results={results.value} />;
});

State flows differently: React's unidirectional data flow with props differs from Qwik's signal-based reactivity. State that would prop-drill in React often uses signals or context in Qwik.

Event handlers are async by default: The $ functions in Qwik are lazy-loaded, meaning they're inherently asynchronous. Design accordingly.

Ecosystem Reality in 2025

Qwik's ecosystem has grown but remains smaller than React's:

UI Libraries: Qwik UI provides headless components. Some teams use Tailwind CSS with custom components. No equivalent to shadcn/ui or Radix.

Data Fetching: Built-in useResource$ and routeLoader$ handle most needs. No React Query equivalent with the same feature depth.

Testing: Vitest works well. Testing patterns are documented but less comprehensive than React Testing Library.

Community: Active Discord, growing adoption among performance-focused teams. Smaller than React's ecosystem by an order of magnitude.

Hybrid Approaches

You don't have to choose exclusively. Some patterns combine frameworks:

Qwik for landing pages, React for app: Use Qwik for marketing pages and React (with Next.js) for the authenticated application. Different tools for different performance requirements.

Micro-frontends: Critical paths use Qwik components. Complex features use React. Module federation enables this architecture.

Gradual migration: Qwik can render React components via qwik-react. Migrate page by page while keeping React components working.

The Fundamental Tradeoff

React optimizes for developer experience and ecosystem. The hydration model is simpler to reason about—your component runs the same on server and client. The cost is paid at runtime.

Qwik optimizes for user experience and performance. The resumability model requires different thinking about code organization. The benefit is near-instant interactivity.

For our e-commerce site, the 2.5-second improvement in Time to Interactive translated to measurable business impact. For an internal dashboard used by 50 employees, React's ecosystem benefits would outweigh the performance difference.

The decision isn't about which framework is "better." It's about which tradeoffs align with your specific requirements. Qwik proves that hydration isn't the only path, and for certain applications, resumability delivers compelling results.