
Qwik 2.0 RC で何が変わったのか、HTML コメントまで削った設計のこだわりを読んでみた
はじめに
こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。
フロントエンドフレームワークの Qwik が、2026 年 10 月 1 日に 2.0 の RC(Release Candidate)に到達しました。npm では @qwik.dev/core@2.0.0-rc.0 として公開されていて、bun create qwik@rc で試せます。
ちなみに、同じ 2026 年 10 月 1 日(UTC)には SvelteKit 3 と Remix 3.0.0 も正式版が出ています。npm の公開時刻で見ると Qwik の RC が 15:49、SvelteKit 3 が 17:22、Remix 3.0.0 が 22:58 で、7 時間ほどの間にメジャーが 3 つ並びました。狙ったわけではないと思いますが、Qwik 2 と SvelteKit 3 はどちらも 2026 年 3 月に出た Vite 8 を前提にしています。Remix 3 は逆に React にも Vite にも依存しない、web primitives の上に組んだ単一依存のパッケージとして出直しました。フレームワークの作り直しが同じ日に重なったのは、偶然と必然が半々といったところでしょうか。
自分は v1 の頃に Qwik を触って、hydration をしないで起動する resumability の発想がかなり気に入っていました。ただ、2024 年 2 月に設計記事「Towards Qwik 2.0」が出てから、2024 年 11 月に alpha、2025 年 6 月に beta と進んだあとが長く続きました。RC まで約 2 年 8 か月です。その間も v1 系は 1.20.1(2026 年 9 月 23 日)まで更新が続いていたので止まっていたわけではないのですが、v2 の本番はいつ来るのかと気にしていた一人です。
さらに話が逸れますが、Qwik の名前をよく見かけたのは、2023 年の signals 論争のころだった気がします。2022 年末に Preact と Qwik が signals を取り入れ、2023 年 2 月に Angular が追随し、Svelte も 5 の runes で signals ベースになりました。Miško Hevery さんが Builder.io のブログに書いた「useSignal() is the Future of Web Frameworks」(2023 年 2 月)も、自分のタイムラインではよく回ってきた記憶があります。一方の React は signals を API としては採らず、コンパイラ(当時の Forget、いまの React Compiler)に同じ役割を持たせる道を選びました。その後、signals を JavaScript 標準にする TC39 の提案が 2024 年 4 月に Stage 1 に入りました。設計には Angular、Vue、Solid、Preact、Svelte、Qwik などのメンテナが名を連ねています。ただし 2026 年 10 月時点でも Stage 1 のままです。論争としては静かになり、React 以外では signals が標準装備になった、というのが自分の見立てです。Qwik の v2 も、その signals の実装をゼロから書き直したところから始まっています。
RC の告知を読むと、v2 は機能を足したリリースというより、v1 の仕掛けの中心にあった HTML コメントのマーカーを「重い」と判断して捨て、状態と DOM の写しを HTML 末尾の文字列に詰め直したリリースでした。JavaScript をユーザーが触るまで動かさないという一点のために、ここまで手を入れるのかという、ある意味で狂気じみた(賞賛として)こだわりが見えます。
この記事では、Qwik が譲らなかった設計、その設定を v1 で支えていた仕組みの重さ、v2 がそれをどう作り直したかを、v1 と v2 の starter を同じ構成でビルドして HTML を見比べながら紹介します。Qwik を知らない人にも、フレームワークがどこまで「ブラウザに仕事をさせない」ことに執着できるのかが伝わるといいなと思っています。
Qwik が譲らない resumability と、それを v1 で支えていたコメントマーカーの重さ
一般的なフレームワークの SSR は、サーバーで HTML を作ったあと、ブラウザで同じコンポーネントのコードを読み込んで実行します。そこでイベントリスナーを付け直し、コンポーネントの木と状態を復元します。これが hydration で、アプリが大きくなるほど起動時に実行する JavaScript が増えます。
Qwik はこの hydration をしません。公式ドキュメントの言い方では、サーバーで実行を「一時停止」し、ブラウザで「再開」します。リスナーは on:click="./chunk.js#handler" のような属性として HTML に書き込まれ、コンポーネントの境界、アプリの状態、どの状態がどのコンポーネントに影響するかの購読関係まで HTML にシリアライズされます。ブラウザ側で最初に動くのは Qwikloader という小さなスクリプトだけです。document にグローバルなリスナーを 1 つ登録し、クリックされた要素の属性を見てから該当するチャンクを import します。
これを成立させているのが Optimizer です。コードの中の $ が付いた箇所(component$、onClick$、useComputed$ など)を見つけて、その中身を別のチャンクに切り出します。開発者がコード分割を設計しなくても、クロージャの粒度で遅延ロードの境界が自動で生まれます。公式ドキュメントの「Think Qwik」には「bundle size should not be something that developers should think about」とあります。v1.0 の告知も、初期 JavaScript のコストを一定に保つことを約束していました。RC の告知はこれを、動画のストリーミングのように O(1) で反応し始めると表現しています。

上段が hydration する一般的な SSR、下段が Qwik。Qwik は HTML に入った listener と状態をそのまま使い、Qwikloader を動かした時点で操作できる。コードは押した chunk だけ取得する。
上の図の下段を成り立たせていた「HTML に入った listener と状態」を運んでいたのが、v1 の HTML の各所に書かれていたコメントでした。
<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--> がコンポーネントの境界、<!--t=8--> が signal で更新されるテキストの位置を示すマーカーです。RC の告知はこれを「cute little comments」と呼びつつ、はっきりその重さを認めています。順序どおりの 1 パスで書き出す必要があり、CPU とメモリに負担をかけ、反応性は平均的なフレームワークより遅く、順序外のストリーミングが不可能で、遅い fetch がその下のすべてを止めていた、と。
v2 はコメントを消して HTML 末尾の文字列に置き換えた
v2 の答えは、マーカーの運び手そのものを捨てることでした。
<main>
Count: 123!
<button on:click="...">+1</button>
</main>
<script type="qwik/state">[...]</script>
<script type="qwik/vnode">...</script>
本文からコメントが消え、代わりに HTML 末尾に qwik/state(状態)と qwik/vnode(仮想ノードの情報)の 2 本のスクリプトが置かれます。ブラウザ側では仮想ツリーを小さな VNode オブジェクトとしてメモリに持ち、signal が変わったら HTML を再パースせずに該当するテキストノードや属性だけを更新します。大きな更新は分割され、スケジューラが 15 ms ごとにブラウザへ制御を返すので、長い描画の裏でクリックやタイピングが待たされません。

左が v1、右が v2 の HTML。v1 は境界と更新位置の印をコメントとして本文に埋め、v2 は本文を素の HTML にして末尾の qwik/state と qwik/vnode に集約した。
この方針は 2024 年 2 月の設計記事「Towards Qwik 2.0」で先に説明されていました。vnode の情報を 9 文字程度のエンコード文字列に詰める、要素の参照に ID 属性ではなく深さ優先の連番を使う、vnode は必要になった時点で遅延生成する、オブジェクトではなく配列で持って JIT が最適化しやすい形にする。どれも「ブラウザに余計な仕事をさせない」の一点に向いた判断です。
告知と設計記事の内容を v1 と並べると次のようになります。
| 観点 | v1 | v2 |
|---|---|---|
| パッケージ | @builder.io/qwik、@builder.io/qwik-city |
@qwik.dev/core、@qwik.dev/router |
| 状態の置き場 | <script type="qwik/json"> 1 本 |
qwik/state と qwik/vnode の 2 本 |
| 境界とテキストの印 | HTML コメント | 末尾のエンコード文字列 |
| 要素の参照 | q:id 属性 |
深さ優先の連番 |
| vnode | HTML を走査して構築 | 必要になった時点で生成 |
| 更新 | HTML を再パース | メモリ上の vnode から直接 |
| 長い処理 | 分割なし | 15 ms ごとにブラウザへ譲る |
| ストリーミング | 順序どおり | 順序外(experimental) |
| Optimizer | Rust | Rust と TypeScript(opt-in) |
| Vite | 7 系 | 8 系(Rolldown) |
このうち要素の参照と vnode の 2 行は 2024 年の設計記事に書かれた方針で、残りは RC 告知の説明です。RC の実装がそのままかは、次の章で手元の HTML から確かめられた項目だけ断定しています。
v1 と v2 の starter を同じ構成でビルドして HTML を見比べてみた
告知の例は簡略化されているので、実際のビルド出力で確かめました。create-qwik の playground starter は v1 も v2 も同じ構成で、ソースの差は import 元と、v2 側の root.tsx と entry.ssr.tsx の書き換えだけです。
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
それぞれ bun install のあと bun run build.client と bun run build.preview で本番ビルドし、bun x vite preview で配信したトップページを curl で保存しました。Counter コンポーネントの周辺を抜き出すと、v1 はこうです。
<!--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-->
同じ箇所が v2 ではこうなります。
<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>
コメントも q:id も q:key も消えています。代わりに目を引くのが、属性名が : だけの属性です。ソースを追うと Q_PROPS_SEPARATOR という定数で、SSR は動的な属性を書いたあとに : を書き、要素に key があれば :="no_1" のように値に入れ、その後に固定の属性を書いています。v1 で q:key と q:id が担っていた役割を、1 文字の属性名が引き受けた形です。属性名を 1 文字まで削るところに、HTML のバイト数と走査コストへの執着が出ています。
ページ全体を数えた結果が次の表です。
| 観点 | v1 1.20.1 | v2 2.0.0-rc.0 |
|---|---|---|
| HTML コメントの数 | 60(<!--qv が 28) |
0 |
q:id 属性の数 |
20 | 0 |
q:key 属性の数 |
42 | 0 |
: 属性の数 |
0 | 104 |
| 状態スクリプト | qwik/json 324 B |
qwik/state 4,007 B + qwik/vnode 831 B |
| HTML 総バイト(gzip 後) | 19,191 B(7,115 B) | 22,991 B(8,716 B) |
| クライアントバンドル | 50 ファイル、115,020 B | 66 ファイル、193,491 B |
| Qwikloader | 3,100 B | 5,222 B |

同じ playground starter のトップページで数えた印の数。v1 のコメント 60 個、q:id 20 個、q:key 42 個が v2 では 0 個になり、代わりに「:」属性が 104 個付く。
正直に書くと、HTML は小さくなっていません。コメントが消えたぶんより、末尾の qwik/state と qwik/vnode が増えたぶんのほうが大きく、gzip 後でも約 1.6 KB 増えています。クライアントバンドルも core のチャンクが 52 KB から 112 KB に増え、Qwikloader も 3.1 KB から 5.2 KB になりました。公式ドキュメントにある「about 1 kb minified」は初期の記述で、1.20.1 の時点でも 3 KB です。v2 の変更はバイト数を減らすためのものではなく、HTML を順序どおりに 1 パスで走査しなければならなかった構造を解体するためのもの、と読むのが正確そうです。
ではブラウザは最初に何を読むのか。headless Chromium でトップページを開き、load イベントまでに取得した JS、その後 3 秒の idle 中に先回りで取得した JS、Counter の「+」を押したときに追加で取得した JS を数えました。
| フェーズ | v1 1.20.1 | v2 2.0.0-rc.0 |
|---|---|---|
load までに取得した JS |
3 本、59,435 B(core を含む) | 2 本、9,048 B(Qwikloader と preloader のみ) |
| idle 中の先回り取得 | 9 本、8,938 B | 29 本、161,612 B |
| 「+」クリック後の追加取得 | 0 本 | 2 本、940 B |
| Counter の値 | 70 → 71 | 70 → 71 |

headless Chromium が取得した JS の量。v2 は load までに 9 KB しか読まないが、idle 中の先回り取得は 161.6 KB と v1 より多い。クリック後は v2 だけ 0.9 KB を追加で取る。
v2 は load までに core を読まず、9 KB で画面を出しています。そのあと idle 中に bundle graph に従って 161 KB を先回りで取得するので、取得量で見ると v2 のほうが多くなります。先回りは Qwik が v1.0 の告知から掲げている「speculative code fetching」そのもので、取得はしても実行はしないという立場です。クリック時に v2 が 940 B のチャンク 2 本を取ってから 71 に更新しているのは、ハンドラのコードがその瞬間まで読まれていなかったことの証拠で、こちらは resumability の挙動がそのまま見えています。
速度とストリーミングとキャッシュが同じ設計変更から手に入った
コメントマーカーを捨てた結果として、告知は 3 つの収穫を挙げています。
反応性は v1 の 2 倍以上になった
Qwik チームの手元計測による js-framework-benchmark の結果です。
| 操作(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 |
| 加重幾何平均 | 1.00× | 1.23× | 1.25× | 1.42× | 1.53× | 2.59× |

RC 告知の js-framework-benchmark の加重幾何平均。Vanilla JS を 1.00 としたとき Qwik v2 は 1.23、Qwik v1 は 2.59。Qwik チームの計測で、RC のため正式版までに変わり得る。
告知には「Since this is RC, the results are not published yet and are subject to change until v2 comes out.」と留保があり、ブラウザや測定機の記載もありません。それでも v1 の 2.59× から v2 の 1.23× への変化は、HTML を再パースせずメモリ上の vnode を直接更新するようになった効果として読めます。v1 の反応性が遅かった原因を「partial vDOM got in the way」と自分たちで書いているのが Qwik らしいところです。
順序外ストリーミングを手元で見てみた
v1 で不可能だった順序外ストリーミングは、v2 で experimental として入りました。vite.config.ts の qwikVite() に experimental: ["pendingBoundary", "catchBoundary", "blockSSR"] を渡し、遅いコンポーネントを <Pending fallback$={...}> で包みます。サーバーはその部分を待たずにページの残りを送り、本体は同じレスポンスの後ろで届いて、小さな inline script が差し替えます。

手元で観測した順序外ストリーミングの流れ。5 ms までに本文と fallback と footer が届き、2 秒後に本体が template と qO(1) で届いて、ブラウザが fallback を本体に差し替える。
2 秒待つ async な useComputed$ を <Pending> で包んだ route を作り、HTTP のチャンクが届いた時刻を記録しました。
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>
5 ms までに fallback の「Loading items...」とページ下部のフッターまで届き、本体の <ul> は 2 秒後に <template> で届いて qO(1) が表示を入れ替えます。差し替え用の inline script は 1,769 B でした。fallback は display:contents の div として DOM に残り、本体の置き場は display:none の div で待っています。

(A) 到着から 0.1 秒後は「Loading items...」の fallback とフッターが見えている。(B) 2.5 秒後に本体のリストが差し替わる。どちらも同じ 1 本のレスポンスで届いた。
一方、routeLoader$ に blockSSR: false を付けた版は、手元の vite preview では <html> の開始タグ以外が全部 2 秒後に届き、順序外にはなりませんでした。ドキュメントには「rendering begins immediately」とあるので、RC 段階の挙動か自分の構成の問題かは切り分けられていません。experimental の API はこういうところがまだあります。
useComputed$ が async 関数を受け付けるようになったのもこの流れで、<Resource> の後継という位置づけです。結果は普通の signal として読め、.pending と .error で状態が取れ、古いリクエストは abortSignal で打ち切られます。
ローダーはキャッシュできる単位に分かれた
v1 は route ごとに 1 本の q-data.json でデータを運んでいましたが、v2 では routeLoader$ ごとに JSON エンドポイントが分かれます。cacheControl: { maxAge: 60 } や 'immutable' を指定するとそのローダーの応答だけがブラウザキャッシュされ、eTag を設定すればローダーを実行せずに 304 を返せます。<Link> はホバー時に route のデータを先回りで取るので、クリック前にデータが届いていることも多くなります。ページ遷移でもローダーの完了を待たずに描画が始まり、<Pending> で包んだ領域だけが fallback を出す動きになります。
こだわりはフレームワーク自身の作り方にも及んでいた
告知の後半を読むと、こだわりの対象はアプリの出力だけではありません。
Optimizer は Rust 製で、WASM でも配布されていました。v2 では TypeScript 版の Optimizer が tsOptimizer: true の opt-in で入り、「every snapshot test」で Rust 版と一致し、速度もほぼ同じだと書かれています。貢献者が Rust を用意しなくてよくなるのが目的で、正式版では TypeScript 版を既定にする予定です。テストも、レンダラのテストを SSR からの再開とクライアント描画の 2 経路で回し、Playwright の E2E を Chromium、Firefox、WebKit の 3 ブラウザと 2 つの Optimizer の組み合わせで流し、CI で速度とメモリのベンチマークを取っています。2 つの実装を互いの検算に使う発想は、resumability の正しさがシリアライズの正しさに依存するフレームワークならではだと思います。
細かいところでは、*.server.ts という名前のファイルや server/ ディレクトリのコードをクライアント側から import するとビルドが失敗するようになりました。注意書きではなくビルドエラーで止めるのが、このチームの好みです。ストリーミング済みの要素の属性をあとから変える必要が出た場合は、小さな inline script でブラウザ側を書き換える backpatching も入っています。
移行については、告知が「only a handful of small breaking changes」と書き、bunx qwik migrate-v2 がパッケージ名(@builder.io/qwik → @qwik.dev/core、@builder.io/qwik-city → @qwik.dev/router)と識別子(QwikCityProvider → QwikRouterProvider など)を書き換えてくれます。手で直すのは、QwikCityProvider のラッパーを useQwikRouter() フックに変える、useResource$ を async の useComputed$ に置き換える、HTML 側の listener 属性が on:click から q-e:click に変わるのでそれを読んでいるテストやツールを直す、あたりです。2024 年の設計記事は破壊的変更を入れない計画だと書いていたので、ここは方針が少し動いています。
開発体験では Vite 8 が必須になり、ビルドは Rolldown で走ります。HMR は signal や store の値を保ったままコンポーネントを編集でき、Qwik DevTools は開発時のオーバーレイと本番ビルド向けのブラウザ拡張で props、signal、描画、route、バンドルを見せます。告知はこれを「so your agent can inspect」と書いていて、starter にも .vscode/mcp.json が同梱されています。新しい API としては Web Worker で関数を動かす worker$、React のツリーの中で Qwik コンポーネントを描く reactify$、ライブラリのクラスインスタンスをシリアライズする useSerializer$、layout の中で描かれる 404.tsx と error.tsx などが後方互換で追加されています。
告知の最後には、貢献者と他のフレームワークへの謝辞と並んで、「Thank you to Claude and Codex for their generous open-source programs」という一文がありました。OSS 向けのプログラムでコーディングエージェントを使い、開発と保守が速くなったと明記しています。長く止まって見えた v2 が RC まで来た背景の一つとして、書いているのが印象に残りました。
まとめ
Qwik 2.0 RC は、v1 の仕掛けの運び手だった HTML コメントのマーカーを「重い」と判断して捨て、状態と DOM の写しを HTML 末尾の qwik/state と qwik/vnode に詰め直したリリースでした。手元のビルドでも、同じ starter で HTML コメントが 60 個から 0 個、q:id が 20 個から 0 個になり、load までに読む JS は 59 KB から 9 KB に減っていました。反応性の改善、順序外ストリーミング、ローダー単位のキャッシュは、この一つの設計変更から同時に出てきた収穫です。
一方で HTML の総バイトもクライアントバンドルも増えていて、idle 中の先回り取得は v2 のほうが積極的です。routeLoader$ の blockSSR: false は手元では効かず、<Pending> 系の API は正式版までに安定化すると告知にあります。ドキュメントも書き直し中なので、今から本番に入れる段階ではなく、RC で試して issue を返す段階です。
それでも、ユーザーが触るまで JavaScript を動かさないという一点のために、属性名を 1 文字まで削り、2 つの Optimizer を互いの検算に使うところまでやるフレームワークは他に思い当たりません。正式版が出たら、この starter で同じ計測をやり直して、RC からの変化を確かめてみたいところです。
参考リンク
- 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 の設計記事)
- Moving Forward Together(2024-03-26、QwikDev org への移行)
- SvelteKit 3 is here(2026-10-01、The Svelte team)
- Remix 3 Release Candidate(2026-08-31。3.0.0 の npm 公開は 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。signals 論争の経緯)
- tc39/proposal-signals — JavaScript に signals を入れる提案。2024-04 に Stage 1
- Upgrade | Qwik Docs — v1 から v2 への移行ガイド
- Pending | Qwik Docs — experimental の
<Pending> - Resumable | Qwik Docs
- Think Qwik | Qwik Docs
- Qwikloader | Qwik Docs
- The Optimizer | Qwik Docs
- QwikDev/qwik






