
I read about what changed in Qwik 2.0 RC and the design philosophy behind trimming even HTML comments
This page has been translated by machine translation. View original
Introduction
Hello, I'm Morishige from Classmethod's Manufacturing Business Technology Department.
The front-end framework Qwik reached the 2.0 RC (Release Candidate) on October 1, 2026. It has been published on npm as @qwik.dev/core@2.0.0-rc.0, and you can try it with bun create qwik@rc.
Incidentally, on the same October 1, 2026 (UTC), SvelteKit 3 and Remix 3.0.0 also released their official versions. Looking at npm publication times, Qwik's RC was at 15:49, SvelteKit 3 at 17:22, and Remix 3.0.0 at 22:58—three major releases within about 7 hours of each other. I don't think it was intentional, but both Qwik 2 and SvelteKit 3 are built on Vite 8, which came out in March 2026. Remix 3, on the other hand, re-emerged as a single-dependency package built on web primitives, independent of both React and Vite. The coincidence of these framework rewrites landing on the same day feels like equal parts chance and inevitability.
I played with Qwik back in v1, and I was quite taken with the idea of resumability—launching without hydration. However, after the design article "Towards Qwik 2.0" came out in February 2024, progress moved to alpha in November 2024 and beta in June 2025, then stretched on for a long time. It took about 2 years and 8 months to reach RC. During that time, the v1 line kept receiving updates through 1.20.1 (September 23, 2026), so it wasn't stalled—but I was one of those people wondering when v2 would finally ship for production.
To digress further, I feel like I saw Qwik's name most often around the signals debate of 2023. In late 2022, Preact and Qwik adopted signals, Angular followed in February 2023, and Svelte went signals-based with runes in version 5. I also recall "useSignal() is the Future of Web Frameworks" by Miško Hevery on the Builder.io blog (February 2023) circulating frequently in my timeline. React, on the other hand, chose not to adopt signals as an API, instead assigning that role to the compiler (then called Forget, now React Compiler). Subsequently, a TC39 proposal to bring signals to JavaScript as a standard entered Stage 1 in April 2024. Its design lists maintainers from Angular, Vue, Solid, Preact, Svelte, Qwik, and others. However, as of October 2026, it remains at Stage 1. My read of the situation is that the debate has quieted down, and signals have become standard equipment in everything except React. Qwik v2 also begins with a ground-up rewrite of that signals implementation.
Reading the RC announcement, v2 is less a feature-addition release and more a release that decided the HTML comment markers central to v1's machinery were "heavy," discarded them, and repacked the state and DOM snapshot into a string at the end of the HTML. You can see an almost maniacal (meant as a compliment) obsession with keeping JavaScript from running until the user touches something, going this far for that single goal.
In this article, I'll walk through the design Qwik refused to compromise on, the weight of the mechanism that supported it in v1, and how v2 rebuilt it—by building the v1 and v2 starters with the same configuration, examining and comparing the HTML output. I hope that even people unfamiliar with Qwik come away with a sense of just how far a framework can push the idea of "not letting the browser do extra work."
Qwik's non-negotiable resumability, and the weight of the comment markers that supported it in v1
In typical framework SSR, after generating HTML on the server, the browser loads and executes the same component code, reattaches event listeners, and restores the component tree and state. This is hydration, and the more the app grows, the more JavaScript must be executed at startup.
Qwik doesn't do this hydration. In the official documentation's terms, execution is "paused" on the server and "resumed" in the browser. Listeners are written into the HTML as attributes like on:click="./chunk.js#handler", and component boundaries, app state, and the subscription relationships between which state affects which component are all serialized into the HTML. The only thing that runs first in the browser is a small script called Qwikloader. It registers a single global listener on the document and imports the relevant chunk only after looking at the clicked element's attributes.
What makes this work is the Optimizer. It finds locations in code marked with $ (component$, onClick$, useComputed$, etc.) and extracts their contents into separate chunks. Even without the developer designing code splitting, lazy-load boundaries are automatically created at the granularity of closures. The official documentation's "Think Qwik" page says "bundle size should not be something that developers should think about." The v1.0 announcement also promised to keep the cost of initial JavaScript constant. The RC announcement describes this as starting to respond in O(1), like video streaming.

Top row is typical SSR with hydration; bottom row is Qwik. Qwik uses listeners and state already in the HTML as-is, and the page is interactive as soon as Qwikloader runs. Only the chunk that was pressed is fetched.
What carried the "listeners and state in the HTML" that made the bottom row of the diagram above work were the comments written throughout the HTML in v1.
<main>
<!--qv q:id=7 q:key=counter-->
Count:
<!--t=8-->123<!---->
!
<button on:click="...">+1</button>
<!--/qv-->
</main>
<script type="qwik/json">{...}</script>
<!--qv--> marks component boundaries, and <!--t=8--> marks the position of text updated by signals. The RC announcement calls these "cute little comments" while frankly acknowledging their weight: they required a single ordered pass to write out, placed burdens on CPU and memory, made reactivity slower than the average framework, made out-of-order streaming impossible, and caused slow fetches to block everything below them.
v2 removed the comments and replaced them with a string at the end of the HTML
v2's answer was to discard the carriers of those markers entirely.
<main>
Count: 123!
<button on:click="...">+1</button>
</main>
<script type="qwik/state">[...]</script>
<script type="qwik/vnode">...</script>
The comments are gone from the body, replaced by two scripts at the end of the HTML: qwik/state (state) and qwik/vnode (virtual node information). On the browser side, the virtual tree is held in memory as small VNode objects, and when a signal changes, only the relevant text node or attribute is updated directly—without re-parsing the HTML. Large updates are split up, and a scheduler yields control back to the browser every 15 ms, so clicks and typing aren't blocked during long rendering work.

Left is v1, right is v2 HTML. v1 embeds boundary and update position markers as comments in the body; v2 makes the body plain HTML and consolidates everything into qwik/state and qwik/vnode at the end.
This approach was explained in advance in the February 2024 design article "Towards Qwik 2.0": packing vnode information into an encoded string of about 9 characters, using depth-first sequential numbers instead of ID attributes for element references, lazy-generating vnodes only when needed, and storing them as arrays rather than objects to help JIT optimization. Every one of these decisions points toward the same goal: "don't make the browser do extra work."
Comparing the announcement and design article content alongside v1 gives the following:
| Aspect | v1 | v2 |
|---|---|---|
| Packages | @builder.io/qwik, @builder.io/qwik-city |
@qwik.dev/core, @qwik.dev/router |
| State location | Single <script type="qwik/json"> |
Two scripts: qwik/state and qwik/vnode |
| Boundary and text markers | HTML comments | Encoded string at the end |
| Element references | q:id attribute |
Depth-first sequential numbers |
| vnode | Built by traversing HTML | Generated on demand |
| Updates | Re-parse HTML | Directly from in-memory vnode |
| Long operations | No splitting | Yields to browser every 15 ms |
| Streaming | In order | Out of order (experimental) |
| Optimizer | Rust | Rust and TypeScript (opt-in) |
| Vite | Version 7 | Version 8 (Rolldown) |
The element references and vnode rows reflect the policy described in the 2024 design article; the rest comes from the RC announcement. I only make definitive claims about items I was able to verify in the actual HTML in the next section.
Building the v1 and v2 starters with the same configuration and comparing the HTML
Since the announcement's examples are simplified, I verified things with actual build output. The playground starter for create-qwik has the same structure for both v1 and v2; the source differences are only the import paths and rewrites to root.tsx and entry.ssr.tsx on the v2 side.
bun create qwik@latest playground qwik1-playground # @builder.io/qwik 1.20.1 + Vite 7.3.1
bun create qwik@rc playground qwik2-playground # @qwik.dev/core 2.0.0-rc.0 + Vite 8.2.1
After bun install for each, I did a production build with bun run build.client and bun run build.preview, served with bun x vite preview, and saved the top page with curl. Extracting the area around the Counter component, v1 looks like this:
<!--qv q:id=d q:key=5Go3:H1_2-->
<div class="_counter-wrapper_43sys_1" q:key="no_1">
<button class="button-dark button-small" on:click="q-DV-9VYlM.js#s_D04jAYuCnhM[0 1]" q:id="e">-</button>
<!--qv q:id=f q:key=7gzr:no_0-->
<div class="_wrapper_1v6hy_1" q:key="cu_0">
...
<span class="_value_1v6hy_9">70</span>
</div>
<!--/qv-->
<button class="button-dark button-small" on:click="q-DV-9VYlM.js#s_LkCVrojX09Y[0 1]" q:id="g">+</button>
</div>
<!--/qv-->
The same location in v2 looks like this:
<div :="no_1" class="_counter-wrapper_43sys_1">
<button q:ps="5" : class="button-dark button-small" q-e:click="q-BymhPPa_.js#_run#6">-</button>
<div :="cu_0" class="_wrapper_1v6hy_1">
...
<span : class="_value_1v6hy_9">70</span>
</div>
<button q:ps="7" : class="button-dark button-small" q-e:click="q-BymhPPa_.js#_run#8">+</button>
</div>
The comments, q:id, and q:key are all gone. What stands out instead is an attribute whose name is just :. Tracing the source, it's a constant called Q_PROPS_SEPARATOR—SSR writes : after dynamic attributes, puts the key as the value in :="no_1" if the element has one, then writes the static attributes. This single-character attribute name has taken over the role that q:key and q:id played in v1. Trimming the attribute name down to one character reveals an obsession with HTML byte count and traversal cost.
Counting across the full page gives the following table:
| Aspect | v1 1.20.1 | v2 2.0.0-rc.0 |
|---|---|---|
| HTML comment count | 60 (<!--qv appears 28 times) |
0 |
q:id attribute count |
20 | 0 |
q:key attribute count |
42 | 0 |
: attribute count |
0 | 104 |
| State script | qwik/json 324 B |
qwik/state 4,007 B + qwik/vnode 831 B |
| Total HTML bytes (gzipped) | 19,191 B (7,115 B) | 22,991 B (8,716 B) |
| Client bundle | 50 files, 115,020 B | 66 files, 193,491 B |
| Qwikloader | 3,100 B | 5,222 B |

Marker counts on the same playground starter top page. v1's 60 comments, 20 q:id, and 42 q:key become 0 in v2, replaced by 104 ":" attributes.
To be honest, the HTML hasn't gotten smaller. The gain from removing comments is outweighed by the addition of qwik/state and qwik/vnode at the end—about 1.6 KB more even after gzip. The client bundle also grew, with the core chunk going from 52 KB to 112 KB, and Qwikloader from 3.1 KB to 5.2 KB. The "about 1 kb minified" in the official documentation is an early description; it was already 3 KB in 1.20.1. It seems more accurate to read v2's changes as dismantling the structure that required a single ordered pass through the HTML, rather than as an effort to reduce byte count.
So what does the browser read first? I opened the top page in headless Chromium and counted the JS fetched before the load event, the JS prefetched speculatively during a 3-second idle period after that, and the JS additionally fetched when pressing the "+" on the Counter.
| Phase | v1 1.20.1 | v2 2.0.0-rc.0 |
|---|---|---|
JS fetched before load |
3 files, 59,435 B (including core) | 2 files, 9,048 B (Qwikloader and preloader only) |
| Speculative prefetch during idle | 9 files, 8,938 B | 29 files, 161,612 B |
| Additional fetch after "+" click | 0 files | 2 files, 940 B |
| Counter value | 70 → 71 | 70 → 71 |

Amount of JS fetched by headless Chromium. v2 reads only 9 KB before load, but speculative prefetch during idle is 161.6 KB—more than v1. After the click, only v2 fetches an additional 0.9 KB.
v2 renders the page without fetching core, using just 9 KB before load. It then speculatively prefetches 161 KB following the bundle graph during idle, so v2 actually fetches more in total. Speculative prefetching is "speculative code fetching," which Qwik has promoted since the v1.0 announcement—fetch it but don't execute it. The fact that v2 fetches 940 B across 2 chunks at click time before updating to 71 is direct evidence that the handler code hadn't been read until that moment, and you can see resumability working exactly as described.
Speed, streaming, and caching all came from the same design change
As a result of discarding the comment markers, the announcement lists three gains.
Reactivity is more than twice as fast as v1
These are js-framework-benchmark results from measurements by the Qwik team.
| Operation (ms) | Vanilla JS | Qwik v2 2.0.0-rc.0 | Vue 3.5.39 | Angular 22.0.0 | React 19.0.0 | Qwik v1 1.20.1 |
|---|---|---|---|---|---|---|
| Create 1,000 rows | 20.9 | 26.8 | 24.7 | 33.7 | 25.5 | 80.5 |
| Select a row | 3.0 | 3.8 | 4.3 | 6.6 | 7.9 | 10.0 |
| Swap two rows | 14.1 | 17.7 | 16.7 | 16.5 | 96.6 | 27.0 |
| Create 10,000 rows | 208.5 | 275.7 | 247.8 | 291.0 | 414.8 | 757.6 |
| Weighted geometric mean | 1.00× | 1.23× | 1.25× | 1.42× | 1.53× | 2.59× |

Weighted geometric mean from the RC announcement's js-framework-benchmark. With Vanilla JS at 1.00×, Qwik v2 is 1.23× and Qwik v1 is 2.59×. Measured by the Qwik team; subject to change as this is an RC.
The announcement notes "Since this is RC, the results are not published yet and are subject to change until v2 comes out," with no mention of the browser or measurement machine. Even so, the shift from v1's 2.59× to v2's 1.23× reads as the effect of updating only the relevant text node or attribute directly from in-memory vnodes rather than re-parsing HTML. That they wrote "partial vDOM got in the way" about their own v1 reactivity is very Qwik of them.
Trying out-of-order streaming locally
Out-of-order streaming, which was impossible in v1, has been added to v2 as experimental. Pass experimental: ["pendingBoundary", "catchBoundary", "blockSSR"] to qwikVite() in vite.config.ts, and wrap slow components with <Pending fallback$={...}>. The server sends the rest of the page without waiting for that part; the actual content arrives later in the same response and is swapped in by a small inline script.

Out-of-order streaming flow observed locally. The body, fallback, and footer arrive within 5 ms; the actual content arrives 2 seconds later via template and qO(1), and the browser swaps the fallback for the real content.
I created a route with an async useComputed$ that waits 2 seconds, wrapped in <Pending>, and recorded the time each HTTP chunk arrived.
3 ms chunk 1 +223 B <!DOCTYPE html><html lang="en-us" q:render="ssr" ...
4 ms chunk 2 +8,186 B <head :><link rel="modulepreload" ...
5 ms chunk 4 +5,007 B ... id="immediate" ... id="fallback">Loading items... ... id="footer" ...
5 ms chunk 5 +4,116 B <script type="qwik/state" ...
2011 ms chunk 6 +1,117 B <template q:r="1"><ul :="Ws_0" id="slow-list"><li :="alpha">alpha</li> ... <script>qO(1)</script>
2012 ms chunk 7 +14 B </body></html>
The fallback "Loading items..." and the footer at the bottom of the page arrive within 5 ms; the actual <ul> arrives 2 seconds later as a <template>, and qO(1) swaps in the display. The inline script for the swap was 1,769 B. The fallback remains in the DOM as a display:contents div, and the slot for the real content waits as a display:none div.

(A) 0.1 seconds after arrival, the "Loading items..." fallback and footer are visible. (B) After 2.5 seconds, the real list is swapped in. Both are delivered in the same single response.
On the other hand, a version using routeLoader$ with blockSSR: false only delivered everything except the <html> opening tag after the full 2 seconds in my local vite preview, with no out-of-order behavior. The documentation says "rendering begins immediately," so I couldn't determine whether this is an RC-stage behavior or a configuration issue on my end. This is the kind of thing still unsettled with experimental APIs.
useComputed$ now accepting async functions is part of this same flow, positioned as the successor to <Resource>. The result can be read as a normal signal, with .pending and .error for status, and stale requests are cancelled via abortSignal.
Loaders are now split into individually cacheable units
In v1, data was transported in a single q-data.json per route, but in v2, each routeLoader$ has its own JSON endpoint. Specifying cacheControl: { maxAge: 60 } or 'immutable' causes only that loader's response to be browser-cached, and setting an eTag allows returning a 304 without executing the loader. <Link> speculatively fetches route data on hover, so data is often already available before the click. During page transitions, rendering begins without waiting for loaders to complete, and only regions wrapped in <Pending> show their fallback.
The obsession extended to how the framework itself is built
Reading the latter half of the announcement, the obsession isn't limited to the app's output.
The Optimizer is written in Rust and was also distributed as WASM. In v2, a TypeScript version of the Optimizer is available as an opt-in with tsOptimizer: true, and the announcement states it matches the Rust version in "every snapshot test" and is nearly the same speed. The goal is to let contributors work without setting up Rust; the plan is to make the TypeScript version the default in the final release. Testing also runs the renderer test suite through two paths—resuming from SSR and client-side rendering—and Playwright E2E tests are run across three browsers (Chromium, Firefox, WebKit) and two Optimizer combinations, with speed and memory benchmarks running in CI. The idea of using two implementations to verify each other makes particular sense for a framework where the correctness of resumability depends on the correctness of serialization.
On the finer details: importing code from *.server.ts files or server/ directories on the client side now fails the build. Rather than a warning, stopping it as a build error is this team's preference. Backpatching has also been added—if an attribute on an already-streamed element needs to change, a small inline script rewrites the browser side.
On migration: the announcement says "only a handful of small breaking changes," and bunx qwik migrate-v2 handles package names (@builder.io/qwik → @qwik.dev/core, @builder.io/qwik-city → @qwik.dev/router) and identifiers (QwikCityProvider → QwikRouterProvider, etc.). Manual changes include wrapping QwikCityProvider into the useQwikRouter() hook, replacing useResource$ with async useComputed$, and updating any tests or tools that read the listener attribute now changed from on:click to q-e:click. The 2024 design article had said no breaking changes were planned, so there was some movement on that front.
For developer experience: Vite 8 is now required, and builds run on Rolldown. HMR lets you edit components while preserving signal and store values. Qwik DevTools offers a development overlay and a production build browser extension showing props, signals, rendering, routes, and bundles. The announcement writes about this "so your agent can inspect," and the starter ships with .vscode/mcp.json included. New APIs added with backward compatibility include worker$ for running functions in a Web Worker, reactify$ for rendering Qwik components inside a React tree, useSerializer$ for serializing library class instances, and 404.tsx and error.tsx rendered inside layouts.
At the end of the announcement, alongside acknowledgments to contributors and other frameworks, there's a line: "Thank you to Claude and Codex for their generous open-source programs." It explicitly states that using coding agents through their OSS programs has made development and maintenance faster. Seeing that written down left an impression on me as one part of the backstory behind v2, which for so long appeared to have stalled, finally reaching RC.
Summary
Qwik 2.0 RC is a release that decided the HTML comment markers—the carriers at the heart of v1's machinery—were "heavy," discarded them, and repacked state and the DOM snapshot into qwik/state and qwik/vnode at the end of the HTML. In my own builds, using the same starter, HTML comments dropped from 60 to 0, q:id from 20 to 0, and JS read before load dropped from 59 KB to 9 KB. The reactivity improvement, out-of-order streaming, and per-loader caching all emerged from this single design change.
At the same time, total HTML bytes and client bundle size both grew, and speculative prefetching during idle is more aggressive in v2. routeLoader$'s blockSSR: false had no effect in my local setup, and the announcement notes that the <Pending> family of APIs will stabilize by the final release. The documentation also appears to be in the process of being rewritten. This feels like the stage for trying it out as an RC and filing issues, not yet for putting it into production.
Even so, I can't think of another framework that, in pursuit of not running JavaScript until the user touches something, trims attribute names down to a single character and uses two Optimizer implementations to verify each other. When the final release comes out, I'd like to run the same measurements on this starter again and see what changed from RC.
Reference Links
- Qwik 2.0 RC: JavaScript streaming without the overhead (2026-10-01, The Qwik Team)
- Qwik Reaches v1.0 (2023-05-01)
- Towards Qwik 2.0: Lighter, Faster, Better (2024-02-09, v2 design article)
- Moving Forward Together (2024-03-26, migration to QwikDev org)
- SvelteKit 3 is here (2026-10-01, The Svelte team)
- Remix 3 Release Candidate (2026-08-31. 3.0.0 published to npm on 2026-10-01)
- useSignal() is the Future of Web Frameworks (2023-02-16, Miško Hevery)
- JavaScript Frameworks - Heading into 2024 (2023-12-21, Ryan Carniato. Background on the signals debate)
- tc39/proposal-signals — Proposal to bring signals to JavaScript as a standard. Entered Stage 1 in 2024-04
- Upgrade | Qwik Docs — Migration guide from v1 to v2
- Pending | Qwik Docs — Experimental
<Pending> - Resumable | Qwik Docs
- Think Qwik | Qwik Docs
- Qwikloader | Qwik Docs
- The Optimizer | Qwik Docs
- QwikDev/qwik