非同期レンダリング
ページの表示速度は、通常もっとも遅いクエリに引きずられます。ハンドラがすべてを 待ち、テンプレートが一度だけ描画し、最後の依存が応答するまで読者には何も見えません。
非同期レンダリングはこの結びつきを断ちます。準備できた部分は即座にコミットされ、 遅いセクションはそれぞれのデータが確定した時点で自分のプレースホルダを置き換えます。 ひとつの HTTP レスポンスの中で完結し、クライアント側のデータ取得は発生しません。
手元にあるプロフィール、900 ms のクエリの先にある注文一覧、1500 ms の呼び出しの先に ある推薦、という3つを持つページを考えます。
通常どおり描画すると、読者は 1.5 秒間まっさらなタブを眺めたあと、すべてを一度に 受け取ります。非同期に描画すると、シェルとプロフィールが 20 ms で、注文が 0.9 秒で、 推薦が 1.5 秒で届きます。合計は 1.5 秒のままです。2つの依存が順番待ちではなく 重なって走るためですが、ページが役に立つようになる時点は 75 倍早くなります。
重要なのは合計時間ではありません。ステータスコード、ドキュメントの head、確定済みの 値が、遅い処理の完了を待たずにサーバを離れることです。
これは Next.js の App Router が広めたストリーミングの形と同じものです。 プレースホルダを含むシェルが先に出て、サスペンドされた各領域がデータの解決に応じて 埋まっていき、それがひとつのレスポンスの中で完結します。Popcorn Web は同じ結果を、 ブラウザ上のコンポーネントフレームワークなしで実現します。読者が受け取るページは 最初のバイトから最後までサーバ描画された HTML であり、関与するクライアントコードは 完成したマークアップを所定の位置へ移す小さなモジュールひとつだけです。 違いは末尾の仕組みで具体的に説明します。
ハンドラの変更点
Section titled “ハンドラの変更点”ほとんどありません。これまで完成した値を渡していた場所に、保留中の値を渡すだけです。
func profile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pw.WriteHTML(w, r, Home(HomeParams{ Profile: Profile{Name: "Ada Lovelace", Joined: "2026-02-11"}, Orders: pw.Go(ctx, loadOrders), Recommendation: pw.Go(ctx, recommend), }))}呼ぶべきストリーミング API も、設定するヘッダも、仕込むフラッシュも、書くループも
ありません。pw.WriteHTML が「合成されたドキュメントが await 境界を開きうるか」を
問い合わせ、自分で経路を選びます。境界を持たないページは、従来どおりバッファされた
レスポンスと Content-Length のままです。
つまりストリーミングするかどうかはテンプレートの性質であって、ハンドラごとに 繰り返す判断ではありません。
保留値を作る
Section titled “保留値を作る”pw.Go は独立したゴルーチンで処理を開始し、ハンドルを返します。
func loadOrders(ctx context.Context) ([]Order, error) { return store.Orders(ctx, customerID)}
orders := pw.Go(ctx, loadOrders)渡したコンテキストが処理を束縛し、キャンセルの責任は呼び出し側に残ります。 レンダリングが束縛するのは「どれだけ待つか」だけです。
| コンストラクタ | 用途 |
|---|---|
pw.Go(ctx, work) |
独立したゴルーチンで今すぐ開始する |
pw.Resolved(v) |
すでに手元にある値、およびテスト |
pw.Failed(err) |
すでに判明している失敗 |
知っておく価値のある性質が3つあります。
ハンドルは一度だけ確定し、読み続けられます。 レイアウトとその内側のページが同じ値を 持てます。両方の境界が同じ結果を見て、背後の処理は一度しか走りません。
チャネルを受け取るコンストラクタはありません。 すでにチャネルを返すサービスは
pw.Go のクロージャの中で受信して取り込みます。これによりすべてのハンドルは
フレームワークが開始したゴルーチンに属し、その中の panic はプロセス終了ではなく
そのハンドルのエラーになります。
早く始めることに意味があります。 処理は pw.Go を呼んだ場所で始まるため、
リクエストの解析、認可、そしてその上にあるすべての描画と重ねられます。
テンプレートでの宣言
Section titled “テンプレートでの宣言”パラメータに async を付け、await ブロックの中で読みます。
package handlers
type Order { id: string total: string}
export component Home(profile: Profile, orders: async Order[]): html {<h1>{profile.name}</h1>
{await list = orders} <ul>{for order in list}<li>{order.id} — {order.total}</li>{/for}</ul>{fallback} <p class="pending">注文を読み込んでいます…</p>{/await}}async T は任意のパラメータやレコードフィールドに付く前置修飾子で、生成される
params 構造体では pw.Pending[T] になります。呼び出し可能な値ではなく、読める場所は
await の束縛だけです。
修飾子は型全体にかかります。async Order[] は「保留中のスライス」ひとつであって、
「保留値のスライス」ではありません。行ごとに個別に到着させたい場合は、行の型自身に
async フィールドを持たせ、ループの中で await します。
レコードは確定済みメンバーと保留中メンバーを同時に持てます。上の例で注文がまだ
飛行中でも profile.name をすぐ描画できるのは、この性質のためです。
{await user = LoadUser(id), posts = LoadPosts(id)} ...主サブツリー...{fallback} ...何も判明する前に、最初にコミットされる...{recover err} ...束縛が失敗したとき、代わりに描画される...{/await}awaitの後ろの束縛は同時に開始します。ひとつのブロックにある2つの遅い呼び出しは 合計ではなく、遅いほうの時間で済みます。fallbackは必須です。最初にレスポンスへコミットされるものであり、遅い依存が ページの残りを遅らせないための要です。recoverは省略可能で、code、message、retryable、timeoutを持つ安全な エラー値を束縛します。
束縛は主サブツリーでのみ、エラー名は recover でのみ可視です。したがってどの節も
「描画時点で存在しない値」を読むことはできません。
<slot> は await ブロックの中に置けません。fallback と置換の両方が描画してしまう
ためです。これは同時に、ドキュメント・レイアウト・ページの境界が入れ子ではなく
兄弟になる理由でもあります。すべて最初のパスで開始し、並行に確定します。
失敗したとき
Section titled “失敗したとき”境界が recover を宣言しているかどうかが、失敗の代償を決めます。
recover 節がある場合、失敗はそこで封じ込められます。その節が当該セクションの
代わりに描画され、ページの他の部分は影響を受けません。レスポンスは 200 のままで、
これは正直です。大部分は成功しているのですから。
ない場合、そのページは諦められます。テンプレートは「待っている間に何を見せるか」と 「成功したら何を見せるか」を書きましたが、失敗については何も言っていません。 fallback を残せば、ページは永久に「読み込み中」と主張し続けることになります。 そこでフレームワークが、ドキュメントシェルより下をエラーページで置き換えます。
そのページは一度だけ登録します。
pw.RegisterHTMLErrorPage(func(problem pw.Problem) pw.HTMLFragment { return Error500(Error500Params{Title: problem.Title})})受け取るのはマップ済みの problem であって元のエラーではないため、テンプレートが サーバ側に留めるべき原因を出力することはできません。リゾルバ未登録なら最小限の 組み込みページが使われるので、エスカレーションがアプリ側の設定に依存することはありません。
エラーはサーバ側に留まる
Section titled “エラーはサーバ側に留まる”recover サブツリーが生の Go エラーを見ることはありません。既定では失敗は
メッセージなしの code: "internal" に、タイムアウトは code: "timeout" になります。
より具体的な内容を公開したい場合は、エラー自身に安全な射影を持たせます。
func (e UpstreamError) PublicError() pw.AsyncError { return pw.AsyncError{Code: "upstream", Message: "しばらくしてからお試しください。", Retryable: true}}いずれの場合も、元のエラーは発生した境界とともにログへ届きます。
ERROR await boundary failed with no recover clause boundary=tb-1 error="order service returned 503"未処理の失敗が 200 のままである理由
Section titled “未処理の失敗が 200 のままである理由”ステータスがシェルとともに、失敗が判明するはるか前に送り出されているからです。 これはストリーミングの正直な代償であり、これらのページの監視をステータスコードに 頼る前に知っておく価値があります。
ストリーミングを切れば、同じ失敗が本物の 500 になります。その場合は何もコミット されていない状態で描画が失敗するので、レスポンスはまだそう言えるのです。2つの経路の うちステータス行で真実を語れるのは片方だけであり、その片方は実際に語ります。
[html]streaming = trueasync_timeout = "3s"async_concurrency = 0bot_detection = truebot_async_timeout = "5s"bot_user_agents = []scriptless_detection = true| キー | 意味 |
|---|---|
streaming |
false にすると、ストリーミング可能なページでもバッファ経路を強制する |
async_timeout |
await 境界ひとつを束縛する。0 ならリクエストのコンテキストが唯一の期限 |
async_concurrency |
1回の描画で同時に走る境界処理の上限。0 は無制限 |
bot_detection |
false にすると、クローラや CLI クライアントにもストリーミングする。後述 |
bot_async_timeout |
ボットと判定したリクエストの境界の上限。0 なら async_timeout に戻る |
bot_user_agents |
ボットとして扱う User-Agent 部分文字列の追加。組み込みリストへの追加であり、置き換えではない |
scriptless_detection |
false にすると、スクリプトを切ったブラウザをストリーミングのまま放置する。後述 |
期限切れの境界は code: "timeout" で recover を描画し、recover がなければ
エスカレーションします。処理そのものが止まるかどうかは関数次第です。
context.Context を受け取る関数はキャンセルを見られますが、受け取らない関数は
放棄されます。自力で完走し、その結果は破棄されます。
streaming = false は、レスポンスをバッファしてしまうプロキシへの避難口です。
同じテンプレートが、すべての境界が確定するまでブロックする単一のバッファされた
レスポンスとして描画されます。ページは依然として正しく、完全です。
ブラウザ側で起きること
Section titled “ブラウザ側で起きること”ドキュメントシェルが、完了した内容を差し込む小さな ES モジュールをひとつ読み込みます。
external RuntimeScriptURL(): url
export component Document(children: html?): html {<!doctype html><html><head>...<script type="module" src={RuntimeScriptURL()}></script></head><body><slot /></body></html>}func RuntimeScriptURL() *url.URL { return &url.URL{Path: pw.RuntimeScriptURL()} }テンプレートがリテラルのパスではなく関数を呼ぶのは、この URL がスクリプト自身の
バイト列から導出したリビジョンを含むためです。ランタイムを変えるアップグレードが
あっても、誰もテンプレートを編集せずに URL が変わり、レスポンスは
Cache-Control: immutable を正直に名乗れます。
pw init は両方を雛形として生成するので、新規プロジェクトには最初から入っています。
完了内容がインラインスクリプトを運ぶことはないため、script-src 'self' で足ります。
nonce も unsafe-inline も不要です。
JavaScript を無効にしたブラウザには、自分でそう名乗ってもらったうえで確定済みの ドキュメントを返します。fallback ではなくページそのものが読めます(後述)。 それでもストリーミングされるセクションは、それ自体で意味のある内容に対する上乗せ として扱ってください。fallback を書く価値があるのはそのためですし、1往復の追加で 取り戻せるのもそこまでです。
スクリプトを切ったとき
Section titled “スクリプトを切ったとき”スクリプトを切ったブラウザは、普通の User-Agent を送ってきます。ランタイムを走らせない
ことはリクエストのどこにも書かれていないので、後述の判別には引っかかりません。そして
スクリプトから問いかけることもできません。スクリプトを走らせないことがその相手の
定義だからです。
スクリプトが無効なときにだけ動く HTML 機能は <noscript> ひとつです。だからそれに
訊きます。Popcorn Web はストリーミングするページの head にブロックを寄与し、同じ
ページをマーカー付きクエリで指してリダイレクトさせます。マーカー付きのリクエストは
バッファ経路で描画されます。読み手は求めたページに、完全な形で、1往復遅れて、同じ
パスで到達します。cookie に選択結果を保存するため、追加の往復が発生するのは最初のページだけです。
既定で有効で、スクリプトが動くブラウザには何のコストもありません。ブロックは発火せず、
マーカーも cookie も付きません。読者層にとって1往復が見合わないなら
scriptless_detection = false で切れます。
知っておく価値のある挙動が3つあります。cookie も拒否している相手は毎ページ2 リクエストになりますが、毎回正しいページに着きます。マーカークエリだけでバッファ 経路が選ばれるので、ループはしません。GET 以外のレスポンスには訊きません。meta refresh は GET を再発行するので、いま描いたバリデーションエラーを捨ててしまうからです。 そして自動リフレッシュをブロックするブラウザは、ストリーミングされたレスポンスと その fallback に留まります。この機能が無かった頃とちょうど同じ場所です。
クローラ、スパイダー、CLI クライアント
Section titled “クローラ、スパイダー、CLI クライアント”fallback を内容に変えるのはランタイムです。そのランタイムを走らせないクライアントは、
すべての fallback をそのまま受け取ります。Googlebot や OGP スパイダー、curl には
ブラウザのように訊くことができません。リダイレクトを追うか黙殺するかのどちらかで、
どちらでも答えにならないからです。結果として fallback がインデックスされる本文になり、
シェアカードの説明文になり、ターミナルに流れてくる中身になります。
Popcorn Web はそうしたクライアントを User-Agent から判別し、代わりにバッファされた
レスポンスを渡します。彼らはすべての境界が確定するまで待ち、完成した文書を受け取ります。
$ curl -s https://example.com/orders | head -2<!doctype html><html><head>…</head><body><ul><li>A-1001 — ¥12,800</li>…このために2つ目の描画経路は要りません。streaming = false はもともと、すべての境界が
確定するまでブロックして完全で正しいページを作っていました。判定はその選択を、デプロイ
単位ではなくクライアント単位にするだけです。
何がボットとみなされるか
Section titled “何がボットとみなされるか”順に2つの規則を適用します。
既知のボット名。ブラウザらしい User-Agent を名乗ってくるエージェントを厳選した
リストです。Googlebot、bingbot、GPTBot、ClaudeBot、PerplexityBot、facebookexternalhit、
Twitterbot、Slackbot、Discordbot、AhrefsBot などが入っています。
Mozilla を名乗らないもの。主要なブラウザはいまだに Mozilla/5.0 を先頭に付けます。
数十年前に技術的な理由ではなくなった慣習ですが、CLI やクライアントライブラリはこの癖を
まず真似しません。したがって curl/8.7.1、Wget/1.21、python-requests/2.32、
Go-http-client/1.1、okhttp/4.12、PostmanRuntime/7.42 は、どのリストにも載せずに
カバーされます。このリストが書かれたあとに登場したツールも同様です。
User-Agent がない場合もボット扱いです。ブラウザは必ず送ってくるからです。
リストが部分文字列 bot ではなく具体的な名前で照合しているのは、端末名に bot が
含まれうるからです。CUBOT NOTE 20 と名乗る Android 端末はクローラではありません。
必要なら追加できます。
[html]bot_user_agents = ["headlesschrome"]ヘッドレスブラウザは意図的に既定から外してあります。ランタイムを実行するので、 ストリーミングのままで正しく機能するからです。スクリーンショットや PDF 生成ツールが 境界の確定前にページを撮ってしまう場合に追加してください。
変わるのは配信であって、内容ではない
Section titled “変わるのは配信であって、内容ではない”どちらの分岐も、ひとつのチェーンをひとつのデータで描画します。クローラがインデックス する内容は読者が見る内容と同じで、違うのは fallback が先にワイヤへ出るかどうかだけです。
この性質は保ってください。User-Agent によって内容を変えるのはクローキングであり、
検索エンジンのペナルティ対象です。同じ内容を別の仕組みで届けるのは、文書化された作法の
ほうです。ハンドラからは pw.IsBot(r) を使えますが、それは軽いクエリを選ぶ、計測
ビーコンを送らない、といった用途のためであって、ページの内容を変えるためではありません。
User-Agent は検証されないので、アクセス制御の判断に使ってもいけません。
知っておくとよい2つの帰結
Section titled “知っておくとよい2つの帰結”クローラは正直なステータスを受け取ります。 この分岐では何もコミットされていないので、
recover 節のない境界の失敗は、ストリーミングされた文書差し替えが維持するしかない
200 ではなく、本物の 500 で応答します。監視や
インデクサはそれに基づいて動けます。
最初のバイトまで長く待つので、専用の上限を持ちます。 bot_async_timeout の既定は 5s
です。ブラウザ側の上限より長いのは、インデクサが読者よりはるかに長く待つからであり、
完成した文書にタイムアウトの fallback が焼き込まれることこそ、この機能が防ぎたいもの
だからです。それでも短めなのは、リンクプレビューのスパイダーが数秒で諦めるうえ、この
分岐には先に返せるものが何もないからです。
ストリーミングしうるページにはヘッダがひとつ増えます。Vary: User-Agent です。ひとつの
URL がバイト列として2つの表現を持つようになったので、共有キャッシュがクローラに
ストリーミング版を返してしまってはいけません。await ブロックを持たないページは影響を
受けず、何にも Vary しないキャッシュエントリのままです。
asyncパラメータを読めるのはawait束縛の中だけです。awaitブロックにはfallback節が必要です。<slot>をawaitブロックの中に置くことはできません。- 保存する形の
@cacheコンポーネントはasyncパラメータを宣言できず、asyncを 持つレコードに到達することもできません。保存されたバイト列が新しい描画の代わりになるのに 対し、保留値はそれを開始した唯一のリクエストに属するためです。ttlを持たない形は何も 保存しないので、await するページでもスコープの宣言には使えます。
いずれも生成時エラーなので、リクエスト時ではなく pw generate の時点で判明します。
リポジトリの examples/async_render が、成功・封じ込められた失敗・未処理の失敗を
それぞれ1ページずつリンクしています。どの経路も運任せではなく、狙って到達できます。
一度きりでは足りないとき
Section titled “一度きりでは足りないとき”await 境界は一度だけ確定するので、こうして描かれた画面はそこで変化を止めます。
external live として宣言したソースは代わりに配信を続け、同じ境界が読者がページを
開いている間ずっとそのペースで再描画されます。節も fallback も recover も同じです。
ライブレンダリングを参照してください。
ここまでの内容を使うのに、この節を知る必要はありません。載せているのは、仕組みが すべて説明しきれるほど小さいからであり、そしてその中のひとつの詳細が、 本番でしか失敗しない形で間違えやすいからです。
ひとつの HTTP レスポンスを Transfer-Encoding: chunked で送ります。Content-Length
はありません。ヘッダを送り出す時点では、境界がどれだけの HTML を生むか分からないためです。
最初のチャンクはドキュメント全体で、プレースホルダ状態のものです。 doctype、head、
body、確定済みのすべての値、そして未解決の各境界を fallback を抱えたプレースホルダとして
描画したもの — 閉じ </html> までが含まれます。完結したマークアップなので、ブラウザは
ページをレイアウトでき、読者は読み始められます。
続いて境界ごとに1チャンクずつ、それぞれの pw.Go が終わった時点で送られます。
到着順はドキュメント順ではなく確定順です。先に答えたクエリが先に送られます。
各チャンクは <template data-tb-boundary="…"> ブロックとそのマーカーを運び、
ランタイムがそれを待っているプレースホルダへ移します。
最後の境界が確定した時点でレスポンスが終わります。 それ以上は何も追記できず、 接続が閉じます。
同じページの到着を眺めると形がはっきりします。
200 Transfer-Encoding: chunked Content-Length: (なし)
0.02s +963 B <!doctype html> … <tb-boundary id="tb-1">…</tb-boundary> … </html> 0.90s +171 B <template data-tb-boundary="tb-1">…</template><tb-apply for="tb-1"> 1.50s +160 B <template data-tb-boundary="tb-2">…</template><tb-apply for="tb-2"> 1.50s + 0 B レスポンス終了この記録で気づいておきたい点が2つあります。
確定内容は </html> の後ろに届きます。おかしく見えますが、間違いではありません。
ドキュメントの終わりを読んだあとのパーサも、続く内容を body へ追記します。まさにこれが、
2度目のリクエストなしにこの仕組みを成立させています。
そして接続は 1.5 秒のあいだ開いたままです。これがこの技法の実際のコストです。
ストリーミング中のリクエストは、もっとも遅い境界が確定するまで接続を占有します。
したがって async_timeout は UX の話であると同時に、リソースの上限でもあります。
ワイヤフォーマット
Section titled “ワイヤフォーマット”最初のパスで、未解決の境界はそれぞれ fallback を抱えたプレースホルダとして書き出され、 ドキュメント全体がフラッシュされます。
<tb-boundary id="tb-1" style="display:contents"> <p class="pending">注文を読み込んでいます…</p></tb-boundary>display:contents はプレースホルダをレイアウトから外します。境界が、その fallback や
置換内容の配置に影響を与えられないようにするためです。
確定した内容は同じレスポンスの続きとして、確定した順に、不活性な template と それに続くマーカー要素として追記されます。
<template data-tb-boundary="tb-1">…確定した内容…</template><tb-apply for="tb-1"></tb-apply><tb-apply> はカスタム要素です。その connectedCallback はドキュメントのパース中に
実行されるため、置換はインラインスクリプトと同じだけ即座です。for から境界 ID を
読み、その ID を持つ要素を置き換え、template と自分自身を取り除きます。適用済みの
境界はプレースホルダも template もマーカーも DOM に残しません。何も蓄積されず、
二重に適用されることもありません。
なぜ template ではなくマーカーなのか
Section titled “なぜ template ではなくマーカーなのか”HTML パーサは要素を開始タグを読んだ時点で挿入します。したがって <template> の
出現に反応するコードは、中身がまだ到着していない template を読み、プレースホルダを
空で置き換え、template を取り除いてしまう可能性があります。結果と一緒に fallback まで
壊れます。
<tb-apply> はバイト列の上で </template> の後ろにあるため、マーカーが存在する
時点で template が完結していることが、バイトがどう分割されようと保証されます。
これは机上の話ではなく、そして開発環境では見えません。小さな確定内容は1チャンクで
届き、1タスクでパースされるからです。プロキシ、TLS レコード境界、圧縮エンコーダが
バイト列を分割して初めて現れます。準拠する実装は必ずマーカーで発火させてください。
マーカーを監視する MutationObserver は適合しますが、template を監視するものは
適合しません。
未処理の失敗が起こすページ全体の置き換えも同じ規律に従い、専用の封筒を使い、 そこで終端します。
<template data-tb-document>…エラーページ…</template><tb-apply-document></tb-apply-document>Next.js App Router との違い
Section titled “Next.js App Router との違い”利用者から見た挙動は同じです。違うのは、ブラウザの仕事のうちどれだけが JavaScript のものかです。
React のストリーミング SSR は、置き換えをストリームに埋め込まれたインラインの
<script> 要素で駆動します。サーバコンポーネントのペイロードも同じ経路で届きます。
CSP の nonce がセットアップの一部になるのはこのためであり、それはツリーを
ハイドレートしてブラウザ側で再描画できるようにするクライアントランタイムと
セットでやってきます。
Popcorn Web はストリームにスクリプトを一切載せません。確定内容はマークアップであり、
発火の起点は要素定義であり、ランタイムは src で読み込まれるキャッシュ済みモジュール
ひとつです。したがって script-src 'self' で足り、nonce も unsafe-inline も不要です。
ハイドレーションも仮想 DOM も、ブラウザ上のコンポーネントコードもありません。
これは意図したトレードオフです。この仕組みができるのは サーバ描画された HTML を配置することだけです。クライアントの状態からセクションを 再描画することはなく、これ自体は何のインタラクティビティももたらしません。 インタラクティビティは、ストリーミングの代償としてではなく、必要な場所に自分で 足すものであり続けます。
