Choosing Between Qwik's $ Lazy-Loading and Eager Imports for Component Rendering
A decision guide for Qwik developers: when to use $ lazy-loading syntax versus eager imports, with a comparison table, trade-offs, a concrete implementation example, and validation steps to verify chunk splitting works correctly.
21 May 2026, 14:59 UTC

The Decision: When to Defer Component Loading
Qwik's $ syntax lets you mark components for lazy loading, splitting them into separate JavaScript chunks that load only when the user interacts. The core decision is simple: use $ for non-critical UI below the fold, and eager imports for anything that must be interactive on first paint. This keeps your initial JavaScript payload under 50 KB while avoiding hydration mismatches during server-side rendering.
Constraints That Shape the Choice
- Initial JS budget: Target under 50 KB gzipped for the main bundle.
- Hydration safety: Lazy components must not reference browser-only globals (like
windowordocument) during server rendering. - SSR compatibility: Both approaches must work with Qwik's resumability model—no hydration replay needed.
- Chunk overhead: Too many
$splits increase network round-trips and can negate size savings.
Comparison: Eager Import vs. $ Lazy-Load vs. Manual Dynamic Import
| Option | Bundle Impact | Hydration Cost | Complexity |
|---|---|---|---|
| Eager import | Higher initial JS—all code in main bundle | Immediate hydration on load | Low—standard import syntax |
$ lazy-load | Lower initial JS; component moves to separate chunk | Deferred until user interaction triggers load | Moderate—requires component$ wrapper and serializable props |
Manual import() | Similar to $—separate chunk | Similar deferred hydration | Higher—needs route-level config, error boundaries, loading states |
Trade-Offs in Practice
$ lazy-load reduces Time-to-Interactive (TTI) and memory footprint by keeping non-essential code off the critical path. The cost is a small indirection: the component must be pure (no side effects on import) and its props must be serializable across the server/client boundary. Over-splitting—wrapping every leaf component in $—creates dozens of tiny chunks that increase request latency more than they save bytes.
Eager imports guarantee immediate interactivity. Use them for headers, primary navigation, hero CTAs, and any UI the user expects to work instantly. The payload cost is linear: every eager component adds to the main bundle.
Manual import() gives you full control over loading states and error handling but duplicates what $ already handles. Reserve it for route-level code splitting or when you need custom suspense boundaries.
Concrete Implementation: Lazy-Loading a Button Component
This pattern works in any Qwik component file (e.g., src/routes/index.tsx). Run it in your project root with npm run dev or npm run build.
import { component$ } from '@builder.io/qwik';
// Lazy-loaded child: separate chunk, loads on interaction
const LazyButton = component$(() =>
import('./button').then((m) => m.Button)
);
// Parent component: eager by default
export default component$(() => {
return (
<div>
<h1>Welcome</h1>
<p>Primary content loads immediately.</p>
<LazyButton />
</div>
);
});
What happens: LazyButton is wrapped in component$ with a factory function that returns a dynamic import. Qwik's optimizer extracts ./button into its own chunk (e.g., button-[hash].js). The parent renders on the server with a placeholder; when the user clicks or hovers the button area, the chunk loads and the component hydrates.
Requirements: The Button export must be a Qwik component (component$ or component_$). Its props must be serializable—no functions, class instances, or DOM references passed from the parent.
Validation Steps
- Build and inspect chunks: Run
npm run buildin your project root. Opendist/and verify a separate file likebutton-[hash].jsexists alongside the main entry chunk. The main bundle should stay under your size target (check withgzip-size dist/client/entry.[hash].js). - Check coverage in DevTools: Open the preview build (
npm run preview), load the page in Chrome, and open DevTools → Coverage. Reload. The lazy button's chunk should show 0% usage until you interact with it. - Manual interaction test: Click the button area. The network tab should show the lazy chunk loading. No console warnings about hydration mismatches or missing symbols should appear.
Limitations and Gotchas
- Server-only globals: If
./buttonimports a library that toucheswindowat module evaluation time, the server render will crash. Move browser-only code insideuseClientEffect$or guard withimport.meta.env.SSRchecks. - Prop serialization: Passing a callback like
onClick$={$(() => console.log('click'))}works because Qwik serializes the$reference. Passing a plain arrow functiononClick={() => ...}will fail at the boundary. - Chunk count: Aim for fewer than 10–15 lazy chunks per route. Beyond that, combine related components or use route-level splitting instead.
Quick Decision Checklist
- Is this component above the fold and interactive on first paint? → Eager import.
- Is it below the fold, heavy, or conditionally rendered? →
$lazy-load. - Do you need custom loading UI or error recovery? → Manual
import()with<Suspense>. - Does the component use browser globals at import time? → Fix the component first, then decide.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.