<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>HTTP 103 · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/http-103/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/http-103/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflareの504エラーは障害ではなかった：Early Hints（HTTP 103）とは</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>Webパフォーマンス</category>
      <category>Preload</category>
      <category>オブザーバビリティ</category>
      <category>デバッグ</category>
      <description>Cloudflareに504エラーが何千件も記録されたのに、実際のユーザーに届いたものは1件もありませんでした。正体はEarly Hintsのキャッシュミスです。HTTP 103の仕組み、偽の504と本物の504の見分け方、そして問題を解決したヘッダーを解説します。</description>
      <content:encoded><![CDATA[<p>Cloudflareのダッシュボードは、私のリクエストの4分の1が504だと言っていました。そのうちユーザーに届いたものは1件もありません。正体はCloudflareのEarly Hints機能のキャッシュミスで、<code>ClientRequestSource = earlyHintsCache</code>、<code>OriginResponseStatus = 0</code>の504として記録されていたのです。この記事では、これを本物の504と見分ける方法と、ミスをヒットに変えるたったひとつのヘッダーを説明します。</p>
<p>最初に出会ったのは9月でした。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ヘルスチェックはすべて正常なのにCloudflareのダッシュボードではリクエストの22%が失敗していた</a>ときで、私はEarly Hintsをオフにして次へ進みました。11月に同じゾーンを見ると、Early Hintsがまたオンになっていて、1日で504が2,600件たまっていました。今回は、その正体を正確に知りたかったし、もう一度オフにするのが正解なのか、きちんと働かせるのが正解なのかも知りたかったのです。このゾーンはFly.ioのオリジン1台の前段にあり、そのオリジンがDjango API、Expo製のWebアプリ、Hugoで作ったマーケティングサイトを配信しています。</p>

<h2 class="relative group">短い答え
    <div id="短い答え" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9f%ad%e3%81%84%e7%ad%94%e3%81%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li>この504を書いているのは訪問者ではなく、CloudflareのEarly Hintsキャッシュです。Cloudflareのログでは<code>ClientRequestSource = earlyHintsCache</code>になっています。</li>
<li>ここでの504はキャッシュミスを意味します。そのURLに保存されたヒントがなかったということです。ヒットは2xxとして記録されます。</li>
<li>これらはオリジンに一切届きません。<code>OriginResponseStatus</code>は<code>0</code>です。</li>
<li>Early Hintsは<code>Link</code> HTTPレスポンスヘッダーで動きます。HTML内の<code>&lt;link rel=&quot;preload&quot;&gt;</code>タグでは動きません。</li>
<li>ヘッダーを送れば、プリロードするものがあるページでは、このミスがヒットに変わり、本物の<code>103 Early Hints</code>レスポンスが返るようになります。</li>
</ul>
<p>先に知っておくと役立つことが、あと3つあります。ヒントにするのはレンダリングに必須のCSS、スクリプト、フォントだけにして、フォントには<code>crossorigin</code>を残してください。確認は<code>curl -sv --http2</code>で、<code>200</code>の前に<code>103</code>が見えるはずです。そして、ほかに何を決めるにせよ<code>earlyHintsCache</code>はアラートから外しておき、今後もヘッダーを送らないなら、ログのノイズで代償を払うよりEarly Hintsをオフにしてください。</p>

<h2 class="relative group">偽のCloudflare 504と本物の504の見分け方
    <div id="偽のcloudflare-504と本物の504の見分け方" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%81%bd%e3%81%aecloudflare-504%e3%81%a8%e6%9c%ac%e7%89%a9%e3%81%ae504%e3%81%ae%e8%a6%8b%e5%88%86%e3%81%91%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>LogpushやInstant Logsを使っているなら、まず2つのフィールドを見てください。<code>ClientRequestSource</code>は、実際の訪問者のリクエストなら<code>eyeball</code>、Early Hintsの参照なら<code>earlyHintsCache</code>です。<code>OriginResponseStatus</code>が<code>0</code>なら、オリジンには接続すらしていません。</p>
<p>GraphQL Analytics APIでは、同じ区別が<code>requestSource</code>ディメンションとして取れます。5xxの急増を信じる前に、私は今このクエリを実行するようにしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>User-Agentでまとめても同じ話になります。9月、Early Hintsの参照は<code>bastion early hints</code>と<code>nginx-ssl early hints</code>として記録されていました。11月は<code>fl2 early hints</code>と<code>nginx-ssl early hints</code>でした（FL2はCloudflareのプロキシの新しい世代です）。24時間で、このゾーンの504の2,631件すべてがこれらのUser-Agentから来ていました。ブラウザ、アプリ、curl、検索クローラーからのものは1件もありませんでした。</p>
<p>訪問者が実際に見た本物の504は、ほぼ必ずCloudflareの外側、つまりあなたの側から来ています。アプリケーションの前段にあるプロキシやロードバランサーが待ちきれずに諦めたのです。nginxかもしれないし、クラウドのロードバランサーかもしれないし、スリープ中のマシンが起きるのを待つプラットフォームのプロキシかもしれません。Cloudflare自身のタイムアウトは別のコードを使います。<strong>522</strong>はオリジンに接続できなかったこと、<strong>524</strong>は接続はできたが125秒以内（Enterprise未満のプランのデフォルト）に応答がなかったことを意味します。</p>
<p>504のせいでCloudflareのタイムアウトを延ばす方法を探してこの記事に来たのなら：504の背後にあるタイムアウトは、ほぼ確実にCloudflareのものではありません。nginxの<code>proxy_read_timeout</code>、ロードバランサーのアイドルタイムアウト、あるいはプラットフォームがマシンを起こすのにかかる時間を確認してください。役に立つ手がかりがひとつあります。125秒よりかなり短い、30秒や60秒といったきりのいい数字でリクエストが失敗するなら、それはCloudflareではなく、あなたのプロキシの上限です。（Cloudflareの125秒の上限を最大6,000秒まで延ばせるのはEnterpriseプランだけで、Cloudflare自身もそれを応急処置と位置づけています。）</p>

<h2 class="relative group">この504の正体
    <div id="この504の正体" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%93%e3%81%ae504%e3%81%ae%e6%ad%a3%e4%bd%93" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Early Hintsが有効なゾーンでは、HTMLページか拡張子のないURLへのリクエストごとに(APIの多いゾーンでは、API呼び出しのほとんどがこれに当たります)、内部の参照がひとつ追加されます。Cloudflareが、送るべきヒントがあるか自分のキャッシュを確認しているのです。この参照は<code>ClientRequestSource = earlyHintsCache</code>として独立した行に記録されます。エンドユーザーのリクエストではなく、オリジンに送られることもありません。</p>
<p>その行のステータスは、エラーではなくキャッシュ参照の結果です。</p>
<ul>
<li>2xxはヒットです。CloudflareにそのURL用の<code>Link</code>ヘッダーが保存されていて、それを<code>103</code>としてブラウザに送ったということです。私のゾーンでは、ヒットは<code>200</code>と<code>204</code>の両方で現れます。</li>
<li>504はミスです。何も保存されていなかったので、先に送るものがなかったということです。</li>
</ul>
<p>オリジンがEarly Hintsの使うヘッダーを一度も送らなければ、すべての参照がミスになり、すべてのリクエストが504の行を1行ずつ残します。私が何も変える前の3時間で、このゾーンは173件のEarly Hints参照を記録し、173件すべてがミスでした。機能がオンになってから、一度もヒントを送っていなかったのです。</p>
<p>9月の記事についての訂正もひとつ。私はこれを「Flyのオリジンから応答を得られない」プローブだと説明していました。それは正確ではありません。この参照はそもそもオリジンに行きません。504はタイムアウトではなく、キャッシュミスを記録したものです。</p>
<p>Cloudflareはこのことを<a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hintsのドキュメント</a>で簡潔に説明しています。<code>ClientRequestSource: earlyHintsCache</code> として記録されたEarly Hintsの参照は「expected and benign」(想定内で無害)であり、200はキャッシュヒット、504はミスです。Log Explorerでは <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code> で除外できます。Cache Analyticsはもともとこれらを除外していますが、私を怖がらせたHTTPトラフィックのグラフは除外していません。</p>

<h2 class="relative group">偽の504のせいでサーバーを増強してしまった
    <div id="偽の504のせいでサーバーを増強してしまった" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%81%bd%e3%81%ae504%e3%81%ae%e3%81%9b%e3%81%84%e3%81%a7%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%92%e5%a2%97%e5%bc%b7%e3%81%97%e3%81%a6%e3%81%97%e3%81%be%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>この区別が大事な理由がここにあります。この記事に取りかかる前日の11月3日、Cloudflareには約13,300件の訪問者リクエストに対して3,292件の504が記録されていました。見かけ上のエラー率は25%です。APIはFly.ioの<code>shared-cpu-1x</code>マシンで動いていて、これはバーストクレジット方式です。保証されるCPUの取り分は小さく、速く動くにはクレジットを消費します。8月の負荷テストでは、持続的な負荷がかかると毎秒およそ20〜30リクエストまでスロットリングされることが分かっていました。「ピーク時にリクエストの4分の1がタイムアウトしている」のは、まさにそのスロットリングに見えました。そこでAPIを<code>shared-cpu-2x</code>に移しました。</p>
<p>ところが、その504はすべてEarly Hintsのキャッシュミスでした。<code>requestSource</code>で分け、オリジン自身のログも見直すと、こうなっていました。</p>
<ul>
<li>10月28日から11月4日までのCloudflareの5xxは、1日1件ほどの525を除いてすべて<code>earlyHintsCache</code>のルックアップでした（調べた525はセキュリティスキャナーからのものでした）。</li>
<li>10月24日から11月4日までのオリジンのアクセスログには5xxが1件もなく、10秒を超えたリクエストは1件だけでした。</li>
<li>Flyのメトリクスでは、最も多いときでも毎秒7リクエスト程度で、マシンが使ったCPUは1コアの5分の1以下でした。スロットリングの上限には一度も近づいていません。</li>
</ul>
<p>大きいマシンは何も直していませんでした。何も壊れていなかったからです。APIは<code>shared-cpu-1x</code>に戻しました。</p>
<p>request sourceで分けていない5xxチャートをもとに、キャパシティを判断しないでください。オリジン自身のログとも突き合わせてください。Cloudflareはリクエストの4分の1が失敗したと言っているのにサーバーのログにエラーがないなら、サーバーに手を入れる前に、Cloudflareが何を数えているのかを確かめましょう。</p>
<p><em>2026年11月5日更新：このセクションの最初の版では、その週にAPIが<code>shared-cpu-1x</code>で本当にダウンしたと書いていました。実際にはダウンしていませんでした。上の数字は、確認したときに分かったことです。</em></p>

<h2 class="relative group">HTTP 103はエラーなのか？
    <div id="http-103はエラーなのか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#http-103%e3%81%af%e3%82%a8%e3%83%a9%e3%83%bc%e3%81%aa%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>違います。1xx台のステータスコードは情報レスポンス、つまりサーバーが本当のレスポンスの前に送れる中間レスポンスです。RFC 8297で定義された<code>103 Early Hints</code>は<code>Link</code>ヘッダーを運び、サーバーがまだページを組み立てている間に、ブラウザが重要なCSS、JavaScript、フォントの取得を始めたり、別のオリジンへの接続を開いたりできるようにします。最終的な<code>200</code>は同じリクエストの中で後から届きます。</p>
<p>対応状況はまちまちですが、安全です。Chromium系ブラウザ（Chrome 103以降、Edge）と最新のFirefoxは、<code>preload</code>と<code>preconnect</code>の両方のヒントを処理します。Safari(17以降)は<code>preconnect</code>だけを使います。ブラウザが<code>103</code>を処理するのはHTTP/2とHTTP/3の場合だけです。理解しないブラウザはいつも通り<code>200</code>を待つだけなので、<code>103</code>はほとんどの訪問者にとっては先回りになり、それ以外の訪問者には何の影響もありません。</p>

<h2 class="relative group">私の設定が空だった理由：Linkヘッダーと<code>&lt;link rel=preload&gt;</code>の違い
    <div id="私の設定が空だった理由linkヘッダーとlink-relpreloadの違い" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%a7%81%e3%81%ae%e8%a8%ad%e5%ae%9a%e3%81%8c%e7%a9%ba%e3%81%a0%e3%81%a3%e3%81%9f%e7%90%86%e7%94%b1link%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%81%a8link-relpreload%e3%81%ae%e9%81%95%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここに一番時間を使いました。すでに動いていて当然に見えたからです。</p>
<p>Early Hintsがキャッシュするのはただひとつ、<code>rel=preload</code>か<code>rel=preconnect</code>の関係を持つ**<code>Link</code> HTTPレスポンスヘッダー**です。Cloudflareは通常のレスポンス（HTMLまたは拡張子なしURLの<code>200</code>、<code>301</code>、<code>302</code>）からこのヘッダーを読み取って保存し、次の訪問者には完全なレスポンスの準備ができる前に<code>103</code>として再送します。</p>
<p>私のページにはすでにプリロードがありました。ただし、HTMLの<code>&lt;head&gt;</code>内のタグとしてです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>これらのタグにも意味はあります。ブラウザはこれを見て動きます。ただし、ブラウザがこのタグを目にするのはHTMLを受け取ってパースを始めた後で、それこそEarly Hintsが先回りしようとしている瞬間です。Expoのエクスポートも、私が使っているHugoのテーマも、このタグは自動で書いてくれますが、ヘッダーはどちらも設定しません。機能はオンで、プリロードも宣言されていたのに、両者は一度も出会っていなかったのです。</p>

<h2 class="relative group">解決策：すでに持っているデータでヘッダーを送る
    <div id="解決策すでに持っているデータでヘッダーを送る" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a7%a3%e6%b1%ba%e7%ad%96%e3%81%99%e3%81%a7%e3%81%ab%e6%8c%81%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e3%83%87%e3%83%bc%e3%82%bf%e3%81%a7%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%82%92%e9%80%81%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>いちばんきれいな解決策は、バンドルのハッシュが変わるたびに古くなる重要アセットのリストを手で管理することではありません。各ページは、何をプリロードしたいかをすでにタグで宣言しています。だから、ページを配信するときにそれを<code>Link</code>ヘッダーに写せばいいのです。標準ライブラリの<code>html.parser</code>は引用符ありの属性もなしの属性も(Hugoのminifierは引用符を外します)扱ってくれるので、正規表現で間違える余地はありません。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</span><span class="p">])</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>そして、DjangoのHTMLレスポンスに付けます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>ひとつだけ、私がはまった細部を。フォントには<code>crossorigin</code>を残してください。これがないフォントのプリロードは別のリクエストとして扱われ、フォントが2回ダウンロードされて、ページは速くなるどころか遅くなります。</p>
<p><code>103</code>を自分で書く必要はありません。ASGIやGranian、uvicornに中間レスポンスのコードを足すこともありません。Cloudflareがあなたのヘッダーから<code>103</code>を作ります。あなたの仕事はヘッダーを送ることだけです。</p>

<h2 class="relative group">ほかのスタックでは
    <div id="ほかのスタックでは" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%bb%e3%81%8b%e3%81%ae%e3%82%b9%e3%82%bf%e3%83%83%e3%82%af%e3%81%a7%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ルールはどこでも同じです。HTMLレスポンスに<code>Link</code>ヘッダーを付け、残りはCloudflareに任せます。</p>
<ul>
<li>
<p>nginxでは、HTMLを配信するlocationに：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p>LaravelとViteの組み合わせには、このためのミドルウェア<code>AddLinkHeadersForPreloadedAssets</code>がすでにあり、Viteのプリロードを<code>Link</code>ヘッダーに変換してくれます。これに対して報告されている2つの問題は、上で書いた落とし穴と同じです。<code>modulepreload</code>のヒントが<code>crossorigin</code>なしで送られ、ブラウザがそれを捨ててスクリプトを2回ダウンロードしうること、そして大きなページではヘッダーがApacheの約8KBのヘッダー上限を超えたことです。お使いのバージョンで両方を確認してください。</p>
</li>
<li>
<p>それ以外：<code>Link</code>ヘッダー1つにカンマ区切りで書くか、<code>Link</code>ヘッダーを複数送ります。どちらでも動きます。</p>
</li>
</ul>
<p>みんながやりすぎるのは、何をヒントにするかです。レンダリングに必須のCSS、エントリースクリプト、ファーストビューで使うフォント1〜2個だけをヒントにして、すべてのチャンクを入れないでください。リストが長いと、ブラウザは今必要なものより先に、まだ必要ないものを取りに行ってしまいます。私は12個を上限にしていますが、それもExpoのエクスポートがプリロードのリストをすでに絞ってくれているからです。</p>
<p>Cloudflareの後ろにいない場合や、CDNがEarly Hintsに対応していない場合でも、<code>Link</code>ヘッダーは少しだけ役に立ちます。ボディをパースする前、レスポンスヘッダーが届いた時点でブラウザがそれらの取得を始められるからです。ただし、サーバーかCDNが送らない限り<code>103</code>は返りません。</p>

<h2 class="relative group">動いているか確認する方法
    <div id="動いているか確認する方法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8b%95%e3%81%84%e3%81%a6%e3%81%84%e3%82%8b%e3%81%8b%e7%a2%ba%e8%aa%8d%e3%81%99%e3%82%8b%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>curl</code>なら<code>103</code>を直接見られます。HTTP/2でリクエストし、ステータス行と<code>Link</code>ヘッダーだけを残します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>先に<code>103</code>が届き、その後に<code>200</code>が来ます。Cloudflareはヒント内の属性の順番を並べ替えますが、問題ありません。すべてのリクエストが<code>103</code>を受け取るとは思わないでください。私の場合、最初の3回のうち2回でした。変更後の最初のアクセスはヘッダーを保存するだけで、最終レスポンスのヘッダーがすでに用意できている場合、Cloudflareは<code>103</code>を省略します。</p>
<p>そのうえで、上の分析クエリに戻り、<code>requestSource: &quot;earlyHintsCache&quot;</code>で絞り込んで、ステータスとパスでまとめてください。修正したページでは、ミスに代わってヒットが出てくるはずです。</p>

<h2 class="relative group">何が変わり、何が残ったか
    <div id="何が変わり何が残ったか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%95%e3%81%8c%e5%a4%89%e3%82%8f%e3%82%8a%e4%bd%95%e3%81%8c%e6%ae%8b%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ヘッダーを本番に出して数分後、このゾーンは初めてEarly Hintsのヒットを記録し、<code>curl</code>ではWebページに本物の<code>103</code>レスポンスが見えました。ヘッダーひとつで2つの問題が同時に片づきました。機能が動き始め、「504」の一部が消えたのです。</p>
<p>残った問題は、JSON APIにはプリロードするものがないことです。APIのエンドポイントはミスを出し続け、<code>504</code>の行を残し続けます。APIの比重が大きいゾーンでは、これが依然として大部分です。妥当な選択肢は3つあります。効果のあるページにヘッダーを送り、APIのパスがミスを記録し続けるのは受け入れる。機能は動き、チャートは静かになりますが、完全には静かになりません。ほかに何を決めるにせよ、<code>earlyHintsCache</code>を5xxのアラートとダッシュボードから除外する。内部のキャッシュ参照で誰かが呼び出されるべきではありません。あるいは、今後も<code>Link</code>ヘッダーを送らないなら、Early Hintsをオフにする（ダッシュボードのSpeed → Settings → Content Optimization、またはAPIのゾーン設定<code>early_hints</code>）。HTMLがとても小さいシングルページアプリでは得るものが少ないかもしれず、オフにすればすべての参照が止まり、ノイズも一緒に消えます。</p>
<p>私はオンのままにしました。Webページは先回りできるようになり、APIのミスはアラートが関わるすべての場所から除外しました。9月に機能をオフにする代わりにやるべきだったのは、まさにこれでした。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
