I read about what changed in Qwik 2.0 RC and the design philosophy behind even trimming HTML comments

I read about what changed in Qwik 2.0 RC and the design philosophy behind even trimming HTML comments

Qwik 2.0 RC was built with the same v1 starter. SSR HTML went from 60 comments to 0; state moved to qwik/state and qwik/vnode. JS before load dropped from 59KB to 9KB, though bundle size increased.
2026.10.03

This page has been translated by machine translation. View original

Introduction

Hello, I'm Morishige from Classmethod's Manufacturing Business Technology Division.

The frontend framework Qwik reached its 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 can be tried with bun create qwik@rc.

https://next.qwik.dev/blog/qwik-2-rc/

Incidentally, on the same October 1, 2026 (UTC), SvelteKit 3 and Remix 3.0.0 also had their official releases. 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. I don't think it was planned, but both Qwik 2 and SvelteKit 3 are based on Vite 8, which came out in March 2026. Remix 3, on the other hand, made a fresh start as a single-dependency package built on web primitives, with no dependency on React or Vite. The coincidence of these framework rewrites landing on the same day feels like equal parts accident and inevitability.

I played with Qwik back in v1 and was quite taken with the idea of resumability — starting without hydration. However, after the design article "Towards Qwik 2.0" was published in February 2024, then alpha in November 2024, beta in June 2025, progress seemed to slow considerably. It took about 2 years and 8 months to reach RC. During that time, the v1 line kept receiving updates up to 1.20.1 (September 23, 2026), so it wasn't stagnant — but I was among those wondering when v2 would be ready for production.

On a further tangent, the time I saw Qwik's name most frequently was around the signals debate in 2023. Preact and Qwik incorporated signals at the end of 2022, Angular followed suit in February 2023, and Svelte also became signals-based with runes in version 5. I also remember Miško Hevery's article "useSignal() is the Future of Web Frameworks" (February 2023) on the Builder.io blog circulating frequently in my timeline. React, on the other hand, chose not to adopt signals as an API, instead delegating that role to its 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 includes maintainers from Angular, Vue, Solid, Preact, Svelte, Qwik, and others. However, as of October 2026, it remains at Stage 1. My read is that the debate has quieted, 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 judged the HTML comment markers at the heart of v1's machinery as "heavy," discarded them, and repacked the state and DOM snapshot into a string at the end of the HTML. There's a certain madness (I mean that as praise) in going this far just for the single goal of not running JavaScript until the user interacts.

In this article, I'll introduce what Qwik refused to compromise on, what paid the price in v1, and how v2 rebuilt it — by building both the v1 and v2 starters with the same configuration and comparing the HTML output. I hope it conveys to those unfamiliar with Qwik just how far a framework can obsess over "not making the browser do unnecessary work."

Qwik's uncompromising resumability, and the comment markers that were its cost in v1

General SSR frameworks, after generating HTML on the server, load and execute the same component code in the browser. There, they reattach event listeners and restore the component tree and state. This is hydration, and the larger the app, the more JavaScript must be executed at startup.

Qwik does not do this hydration. In the words of the official documentation, 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, application state, and even the subscription relationships between state and components are serialized into the HTML. The only thing that runs first on the browser side is a small script called Qwikloader. It registers a single global listener on the document, and when something is clicked, it reads the attribute of the clicked element and imports the corresponding chunk.

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. Without developers designing code splitting themselves, lazy-loading boundaries are automatically created at the granularity of closures. The official documentation's "Think Qwik" section says "bundle size should not be something that developers should think about." The v1.0 announcement also promised to keep the initial JavaScript cost constant. The RC announcement expresses this as being able to start responding in O(1), like video streaming.

Comparison of what the browser does in general SSR with hydration versus resumable Qwik
Top row shows general SSR with hydration, bottom row shows Qwik. Qwik uses the listeners and state already in the HTML directly, and is interactive as soon as Qwikloader runs. Only the chunk for the pressed button is fetched.

Here's the main point. The carrier of the "listeners and state in HTML" shown in the bottom row of the diagram above was the comments scattered throughout v1's HTML.

Qwik v1 HTML (simplified example from the announcement)
<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--> is a marker for component boundaries, and <!--t=8--> is a marker for the position of text updated by a signal. The RC announcement calls these "cute little comments" while clearly acknowledging their cost: they required writing in a sequential single pass, put load on CPU and memory, reactivity was slower than an average framework, out-of-order streaming was impossible, and a slow fetch would block everything below it.

v2 eliminated comments and replaced them with a string at the end of the HTML

v2's answer was to discard the carriers of the markers themselves.

Qwik v2 HTML (simplified example from the announcement)
<main>
  Count: 123!
  <button on:click="...">+1</button>
</main>
<script type="qwik/state">[...]</script>
<script type="qwik/vnode">...</script>

Comments disappear from the body, and instead two scripts — qwik/state (state) and qwik/vnode (virtual node information) — are placed at the end of the HTML. On the browser side, a small VNode object tree is held in memory, 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 the scheduler yields control back to the browser every 15 ms, so clicks and typing won't be blocked behind long renders.

Comparison of where v1 and v2 HTML place the markers for boundaries and state
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 earlier in the February 2024 design article "Towards Qwik 2.0": pack vnode information into an encoded string of about 9 characters; use depth-first sequential numbers instead of ID attributes for element references; generate vnodes lazily only when needed; hold them as arrays instead of objects to make them easier for the JIT to optimize. Every one of these decisions points toward the single goal of "not making the browser do unnecessary work."

Comparing the announcement and design article content against v1 gives the following:

Aspect v1 v2
Packages @builder.io/qwik, @builder.io/qwik-city @qwik.dev/core, @qwik.dev/router
State storage 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 are from the 2024 design article; the rest are from the RC announcement. Only items I was able to confirm in my local HTML are stated definitively in the next section.

I built the v1 and v2 starters with the same configuration and compared the HTML

Since the announcement's examples are simplified, I verified with actual build output. The playground starter from create-qwik uses the same configuration for both v1 and v2; the only source differences are 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 ran bun run build.client and bun run build.preview for a production build, then saved the top page served by bun x vite preview with curl. Extracting the area around the Counter component, v1 looks like this:

v1 1.20.1 top page (excerpt around Counter)
<!--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 section in v2 looks like this:

v2 2.0.0-rc.0 top page (same section)
<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>

Comments, q:id, and q:key are all gone. What catches the eye instead is an attribute whose name is just :. Tracing the source, this is a constant called Q_PROPS_SEPARATOR — after writing dynamic attributes, SSR writes :, puts the key value into the attribute value as :="no_1" if the element has a key, and then writes the static attributes. The role that q:key and q:id played in v1 has been taken over by a single-character attribute name. Trimming the attribute name down to one character reveals an obsession with HTML byte count and traversal cost.

A full-page count gives the following table:

Aspect v1 1.20.1 v2 2.0.0-rc.0
Number of HTML comments 60 (<!--qv × 28) 0
Number of q:id attributes 20 0
Number of q:key attributes 42 0
Number of : attributes 0 104
State script qwik/json 324 B qwik/state 4,007 B + qwik/vnode 831 B
Total HTML bytes (after gzip) 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

Bar graph comparing the number of markers remaining in the top page HTML between v1 and v2
Marker counts on the top page of the same playground starter. v1's 60 comments, 20 q:id attributes, and 42 q:key attributes all become 0 in v2, replaced by 104 ":" attributes.

To be honest, the HTML has not gotten smaller. The gain from removing comments is outweighed by the addition of qwik/state and qwik/vnode at the end, and it's about 1.6 KB larger 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 from an early description; it was already 3 KB as of 1.20.1. It's more accurate to read v2's changes not as an effort to reduce byte count, but as a way to dismantle a structure that required traversing the HTML in a single sequential pass.

So what does the browser actually read first? I opened the top page in headless Chromium and counted the JS fetched before the load event, the JS prefetched speculatively during 3 seconds of idle afterward, and the JS fetched additionally when clicking the Counter's "+" button.

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

Bar graph comparing the amount of JavaScript fetched by the browser by phase between v1 and v2
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 reading core before load, using only 9 KB. It then speculatively fetches 161 KB during idle according to the bundle graph, so in total v2 fetches more. The speculative fetching is exactly the "speculative code fetching" that Qwik has touted since the v1.0 announcement — the position is to fetch but not execute. The fact that v2 fetches 940 B in two chunks after the click before updating to 71 is proof that the handler code had not been read until that moment — the resumability behavior is visible directly here.

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 the Qwik team's own measurements.

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
Geometric mean (weighted) 1.00× 1.23× 1.25× 1.42× 1.53× 2.59×

Bar graph comparing the weighted geometric mean of js-framework-benchmark across 6 frameworks
Weighted geometric mean from the RC announcement's js-framework-benchmark. With Vanilla JS as 1.00, Qwik v2 is 1.23 and Qwik v1 is 2.59. These are the Qwik team's measurements and may change before the final release since this is an RC.

The announcement includes the caveat "Since this is RC, the results are not published yet and are subject to change until v2 comes out," and the browser and test machine are not specified. Even so, the change from v1's 2.59× to v2's 1.23× can be read as the effect of updating in-memory vnodes directly instead of re-parsing HTML. The fact that they themselves write "partial vDOM got in the way" as the cause of v1's slow reactivity is very Qwik.

Trying out-of-order streaming locally

Out-of-order streaming, which was impossible in v1, has been added as experimental in v2. 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 content arrives later in the same response, and a small inline script swaps it in.

Sequence diagram of messages flowing between server and browser during Pending out-of-order streaming
Out-of-order streaming flow observed locally. The body, fallback, and footer arrive within 5 ms; the content arrives 2 seconds later via template and qO(1), and the browser swaps the fallback with the content.

I created a route wrapping an async useComputed$ that waits 2 seconds inside <Pending>, and recorded the arrival time of each HTTP chunk.

Chunk arrival times (excerpt)
      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, and the actual <ul> content arrives 2 seconds later as a <template>, with qO(1) swapping the display. The inline script for the swap was 1,769 B. The fallback stays in the DOM as a display:contents div, while the slot for the content waits as a display:none div.

Pending demo screen. Left shows the fallback immediately after arrival; right shows the content after it swaps in 2.5 seconds later
(A) 0.1 seconds after arrival, the "Loading items..." fallback and footer are visible. (B) After 2.5 seconds, the actual list swaps in. Both arrived in the same single response.

On the other hand, the version with blockSSR: false on routeLoader$ had everything except the opening <html> tag arrive 2 seconds later in my local vite preview — it didn't go out of order. The documentation says "rendering begins immediately," so I wasn't able to determine whether this is RC-stage behavior or a problem with my setup. This is where experimental APIs still have rough edges.

The fact that useComputed$ now accepts async functions is part of this same story — it's positioned as the successor to <Resource>. The result can be read as a normal signal, .pending and .error give you the state, and old requests are cancelled with abortSignal.

Loaders are now split into cacheable units

In v1, data was carried 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 eTag lets you return 304 without running the loader. <Link> prefetches the route's data on hover, so data is often already there before a click. Page transitions also start rendering without waiting for loaders to complete, with only regions wrapped in <Pending> showing a fallback.

The obsession extended to how the framework itself was built

Reading the latter half of the announcement, the targets of this obsession go beyond just the app's output.

The Optimizer was written in Rust and distributed as WASM. In v2, a TypeScript version of the Optimizer has been added as an opt-in with tsOptimizer: true, and the announcement states that 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 too: renderer tests are run on two paths — resuming from SSR and client-side rendering — and Playwright E2E tests run across Chromium, Firefox, and WebKit with two Optimizer implementations in CI, which also tracks performance and memory benchmarks. 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 smaller details: importing code from *.server.ts files or server/ directories on the client side now causes a build failure. Stopping with a build error rather than a warning fits this team's style. There's also backpatching — when an already-streamed element's attribute needs to be changed afterward, 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 rewrites 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 with the useQwikRouter() hook, replacing useResource$ with async useComputed$, and updating any tests or tools that read the listener attribute on:click which has changed to q-e:click. The 2024 design article had stated a plan not to introduce breaking changes, so this represents a slight shift in direction.

On developer experience, Vite 8 is now required and builds run with Rolldown. HMR allows editing components while preserving signal and store values, and Qwik DevTools shows props, signals, rendering, routes, and bundles via a dev-time overlay and a browser extension for production builds. The announcement writes of this "so your agent can inspect," and the starter also includes .vscode/mcp.json. 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 within layouts.

At the end of the announcement, alongside thanks to contributors and other frameworks, there was a line: "Thank you to Claude and Codex for their generous open-source programs." They explicitly state that using coding agents through programs for OSS sped up development and maintenance. It stuck with me as one of the things behind v2 finally reaching RC after what had seemed like a long pause.

Summary

Qwik 2.0 RC is a release that judged the HTML comment markers — the carriers of v1's machinery — as "heavy," discarded them, and repacked the state and DOM snapshot into qwik/state and qwik/vnode at the end of the HTML. In my local builds as well, using the same starter, HTML comments went from 60 to 0, q:id attributes from 20 to 0, and JS read before load dropped from 59 KB to 9 KB. The improvements in reactivity, out-of-order streaming, and per-loader caching all came as simultaneous gains from this single design change.

On the other hand, total HTML bytes and the client bundle have grown, and speculative prefetch during idle is more aggressive in v2. routeLoader$'s blockSSR: false didn't work in my local setup, and the announcement states that the <Pending> APIs will stabilize before the final release. Documentation is also being rewritten, so this is not yet the stage for putting it into production — it's the stage for trying out the RC and filing issues.

Even so, I can't think of another framework that trims attribute names to a single character and uses two Optimizer implementations to verify each other, all in service of the single goal of not running JavaScript until the user interacts. When the final release comes out, I'd like to redo these same measurements with the starter and see what changed from the RC.

Share this article