<?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>Early Hints · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/early-hints/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Wed, 04 Nov 2026 13:36:11 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/early-hints/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cloudflare 504 Errors That Aren&#39;t Real: Early Hints and HTTP 103</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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 Performance</category>
      <category>Preload</category>
      <category>Observability</category>
      <category>Debugging</category>
      <description>Thousands of Cloudflare 504 errors, and not one reached a user: they were Early Hints cache misses. How HTTP 103 works, how to tell a fake 504 from a real one, and the header that fixed it.</description>
      <content:encoded><![CDATA[<p>In September I wrote about <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >a Cloudflare dashboard that said 22% of my requests were failing</a> while every health check stayed green. Most of those 504s turned out to come from Cloudflare&rsquo;s Early Hints feature rather than from my users, so I switched Early Hints off and moved on.</p>
<p>In November I opened the same zone and found another 2,600 504s in a single day. Early Hints was on again. This time I wanted to know exactly what those 504s are, and whether the right fix was to turn the feature off a second time or to make it actually do its job.</p>
<p>The zone fronts one Fly.io origin that serves three things: a Django API, an Expo web app, and a Hugo marketing site. Keep the API in mind. It has a real capacity story of its own, and it&rsquo;s the reason this distinction matters.</p>

<h2 class="relative group">The short answer
    <div id="the-short-answer" 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="#the-short-answer" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ul>
<li>Those 504s are written by <strong>Cloudflare&rsquo;s Early Hints cache</strong>, not by your visitors. In Cloudflare&rsquo;s logs they carry <code>RequestSource = earlyHintsCache</code>.</li>
<li>A <strong>504 there means a cache miss</strong>: Cloudflare had no hints stored for that URL. A hit is logged as a 2xx.</li>
<li>They <strong>never reach your origin</strong>. <code>OriginResponseStatus</code> is <code>0</code>.</li>
<li>Early Hints runs on the <strong><code>Link</code> HTTP response header</strong>. <code>&lt;link rel=&quot;preload&quot;&gt;</code> tags in your HTML don&rsquo;t feed it.</li>
<li>Send the header, and those misses turn into hits and real <code>103 Early Hints</code> responses on the pages that have something to preload.</li>
</ul>

<h2 class="relative group">How to tell a fake Cloudflare 504 from a real one
    <div id="how-to-tell-a-fake-cloudflare-504-from-a-real-one" 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="#how-to-tell-a-fake-cloudflare-504-from-a-real-one" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you use Logpush or Instant Logs, look at two fields before anything else. <code>RequestSource</code> is <code>eyeball</code> for a real visitor&rsquo;s request and <code>earlyHintsCache</code> for the Early Hints lookups. <code>OriginResponseStatus</code> is <code>0</code> when the origin was never contacted.</p>
<p>In the GraphQL Analytics API the same split is the <code>requestSource</code> dimension. This is the query I now run before believing any 5xx spike:</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>Grouping by user agent tells the same story. In September the Early Hints lookups showed up as <code>bastion early hints</code> and <code>nginx-ssl early hints</code>. In November they were <code>fl2 early hints</code> and <code>nginx-ssl early hints</code> (FL2 is the newer generation of Cloudflare&rsquo;s proxy). Across 24 hours, all 2,631 of the zone&rsquo;s 504s came from those user agents. None came from a browser, the app, curl or a search crawler.</p>
<p>A real 504 that a visitor actually saw came from your side of Cloudflare. A proxy or load balancer in front of your application gave up waiting: nginx, a cloud load balancer, or a platform proxy waiting for a cold machine to boot. Cloudflare&rsquo;s own timeouts use different codes. <strong>522</strong> means it couldn&rsquo;t connect to your origin, and <strong>524</strong> means it connected but got no response within 100 seconds (the default on plans below Enterprise).</p>
<p>So if you landed here searching for how to raise Cloudflare&rsquo;s timeout because of 504s: the timeout behind a 504 is almost never Cloudflare&rsquo;s. Check <code>proxy_read_timeout</code> in nginx, your load balancer&rsquo;s idle timeout, or how long your platform takes to wake a machine. A useful clue: if requests fail at a round number well under 100 seconds, such as 30 or 60, it&rsquo;s your own proxy&rsquo;s limit, not Cloudflare&rsquo;s. (Only Enterprise plans can raise Cloudflare&rsquo;s 100-second limit, up to 6,000 seconds, and Cloudflare itself treats that as a stopgap.)</p>

<h2 class="relative group">The same week, the API had real 504s
    <div id="the-same-week-the-api-had-real-504s" 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="#the-same-week-the-api-had-real-504s" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is why the split matters. A few days earlier the same API had genuinely fallen over. It was running on Fly.io&rsquo;s <code>shared-cpu-1x</code> machines, which are burst-credited: under New Zealand peak traffic they ran out of burst and were throttled to a sustained ceiling of roughly 20 to 30 requests a second. Response times collapsed and visitors got real 504s at the edge. Moving to <code>shared-cpu-2x</code> fixed it.</p>
<p>Then the 504 chart filled up again, this time with Early Hints misses. Read without the <code>requestSource</code> split, that chart says the bigger machines didn&rsquo;t help. In fact there were two separate populations of 504s on the same zone in the same week, and only one of them ever reached a person. Don&rsquo;t make capacity decisions from a 5xx chart you haven&rsquo;t split by request source.</p>

<h2 class="relative group">What those 504s actually are
    <div id="what-those-504s-actually-are" 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="#what-those-504s-actually-are" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On a zone with Early Hints enabled, every request gets an extra internal lookup: Cloudflare checking its own cache for a set of hints to send. That lookup is logged as its own line, with <code>RequestSource = earlyHintsCache</code>. It isn&rsquo;t an end-user request, and it is never sent to your origin.</p>
<p>The status on that line is the result of the cache lookup, not an error:</p>
<ul>
<li><strong>2xx</strong> is a hit. Cloudflare had <code>Link</code> headers stored for that URL and sent them to the browser as a <code>103</code>. On my zone, hits show up as both <code>200</code> and <code>204</code>.</li>
<li><strong>504</strong> is a miss. Nothing was stored, so there was nothing to send early.</li>
</ul>
<p>If your origin never sends the header Early Hints runs on, every lookup is a miss and every request writes a 504 line. In the three hours before I changed anything, the zone logged 173 Early Hints lookups and all 173 were misses. Since the feature had been switched on, it had not served a single hint.</p>
<p>One correction to my September post: I described these as probes that &ldquo;get no response from the Fly origin&rdquo;. That isn&rsquo;t quite right. They never go to the origin at all. The 504 records a cache miss, not a timeout.</p>

<h2 class="relative group">Is HTTP 103 an error?
    <div id="is-http-103-an-error" 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="#is-http-103-an-error" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>No. Status codes in the 1xx range are informational: interim responses a server can send before the real one. <code>103 Early Hints</code>, defined in RFC 8297, carries <code>Link</code> headers so the browser can start fetching critical CSS, JavaScript and fonts, or open connections to other origins, while the server is still building the page. The final <code>200</code> follows on the same request.</p>
<p>Support is uneven but safe. Chromium-based browsers (Chrome since version 103, and Edge) and current Firefox act on both <code>preload</code> and <code>preconnect</code> hints. Safari uses only <code>preconnect</code>. Browsers process <code>103</code> only over HTTP/2 and HTTP/3. A browser that ignores it simply waits for the <code>200</code> as it always did, so a <code>103</code> is a head start for most visitors and a no-op for the rest.</p>

<h2 class="relative group">Why mine was empty: the Link header vs <code>&lt;link rel=preload&gt;</code>
    <div id="why-mine-was-empty-the-link-header-vs-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="#why-mine-was-empty-the-link-header-vs-link-relpreload" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is the part that cost me the most time, because it looks like it should already work.</p>
<p>Early Hints caches exactly one thing: the HTTP <strong><code>Link</code> response header</strong> with a <code>rel=preload</code> or <code>rel=preconnect</code> relation. Cloudflare reads it from your normal responses (status <code>200</code>, <code>301</code> or <code>302</code>, on HTML or extensionless URLs), stores it, and replays it to the next visitor as a <code>103</code> before your full response is ready.</p>
<p>My pages already had preloads, but as tags in the 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>Those tags still do something: the browser acts on them. But it only sees them after it has received the HTML and started parsing it, which is exactly the moment Early Hints exists to beat. The Expo export and Hugo both write these tags for you, and neither sets the header. So the feature was enabled, the preloads were declared, and the two never met.</p>

<h2 class="relative group">The fix: send the header you already have the data for
    <div id="the-fix-send-the-header-you-already-have-the-data-for" 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="#the-fix-send-the-header-you-already-have-the-data-for" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The cleanest fix doesn&rsquo;t involve maintaining a hand-written list of critical assets that goes stale every time a bundle hash changes. Each page already declares what it wants preloaded, in those tags. So mirror them into a <code>Link</code> header when you serve the page:</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">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PRELOAD</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">rb</span><span class="s2">&#34;&#34;&#34;&lt;link\b[^&gt;]*?\brel=[&#34;&#39;]?preload[&#34;&#39;]?[^&gt;]*?&gt;&#34;&#34;&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</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">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">,</span> <span class="n">name</span><span class="p">:</span> <span class="nb">bytes</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A real attribute: preceded by whitespace, followed by =, space, / or &gt;.</span>
</span></span><span class="line"><span class="cl">    <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="sa">rb</span><span class="s2">&#34;(?&lt;=\s)&#34;</span> <span class="o">+</span> <span class="n">re</span><span class="o">.</span><span class="n">escape</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="o">+</span> <span class="sa">rb</span><span class="s2">&#34;&#34;&#34;(?:\s*=\s*(?:&#34;([^&#34;]*)&#34;|&#39;([^&#39;]*)&#39;|([^\s&gt;]+)))?(?=[\s/&gt;])&#34;&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">tag</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">next</span><span class="p">((</span><span class="n">g</span> <span class="k">for</span> <span class="n">g</span> <span class="ow">in</span> <span class="n">m</span><span class="o">.</span><span class="n">groups</span><span class="p">()</span> <span class="k">if</span> <span class="n">g</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">),</span> <span class="sa">b</span><span class="s2">&#34;&#34;</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">bytes</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">parts</span><span class="p">,</span> <span class="n">seen</span> <span class="o">=</span> <span class="p">[],</span> <span class="nb">set</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">tag</span> <span class="ow">in</span> <span class="n">PRELOAD</span><span class="o">.</span><span class="n">findall</span><span class="p">(</span><span class="n">html</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">href</span> <span class="o">=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;href&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">href</span> <span class="ow">or</span> <span class="n">href</span> <span class="ow">in</span> <span class="n">seen</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="n">seen</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">href</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">seg</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">href</span><span class="o">.</span><span class="n">decode</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">as_</span> <span class="o">:=</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">as_</span><span class="o">.</span><span class="n">decode</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">as_</span> <span class="o">==</span> <span class="sa">b</span><span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="n">attr</span><span class="p">(</span><span class="n">tag</span><span class="p">,</span> <span class="sa">b</span><span class="s2">&#34;crossorigin&#34;</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">seg</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">parts</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">seg</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">parts</span><span class="p">)</span> <span class="o">==</span> <span class="n">limit</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">break</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="n">parts</span><span class="p">)</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>And on the HTML response, in Django:</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>Two details bit me, and they&rsquo;re worth keeping:</p>
<ul>
<li><strong>Anchor the attribute parser.</strong> A naive search for <code>as</code> happily matches the &ldquo;as&rdquo; inside <code>href=&quot;/assets/...&quot;</code>, and your font preloads silently lose <code>as=font</code>. Require the name to start after whitespace and end at <code>=</code>, a space, <code>/</code> or <code>&gt;</code>. Handle both quoted and unquoted attributes too, because static site generators like Hugo emit <code>href=https://jared.lynskey.co.nz/fonts/x.woff2 as=font crossorigin</code> with no quotes. A unit test caught this one for me.</li>
<li><strong>Keep <code>crossorigin</code> on fonts.</strong> A font preload without it is treated as a different request, so the font gets downloaded twice and the page gets slower, not faster.</li>
</ul>
<p>You don&rsquo;t write the <code>103</code> yourself. There&rsquo;s no ASGI, Granian or uvicorn interim-response code to add. Cloudflare builds the <code>103</code> from your header; your only job is to send the header.</p>

<h2 class="relative group">On other stacks
    <div id="on-other-stacks" 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="#on-other-stacks" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The rule is the same everywhere: put a <code>Link</code> header on the HTML response and let Cloudflare do the rest.</p>
<ul>
<li>
<p><strong>nginx</strong>, on the location that serves your HTML:</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><strong>Laravel</strong> with Vite already has middleware for this, <code>AddLinkHeadersForPreloadedAssets</code>, which turns Vite&rsquo;s preloads into <code>Link</code> headers. Two issues reported against it are the same traps as above: its <code>modulepreload</code> hints have gone out without <code>crossorigin</code>, so browsers can discard them and download the script twice, and on large pages the header has grown past Apache&rsquo;s roughly 8 KB header limit. Check both on your version.</p>
</li>
<li>
<p><strong>Anything else</strong>: set one <code>Link</code> header, comma-separated, or several <code>Link</code> headers. Either works.</p>
</li>
</ul>
<p>What to hint is where people overdo it. Hint the render-critical CSS, the entry script and the one or two fonts above the fold, not every chunk. A long list makes the browser fetch things it doesn&rsquo;t need yet, ahead of things it does. I cap mine at twelve, and only because the Expo export already curates its preload list.</p>
<p>If you&rsquo;re not behind Cloudflare, or your CDN doesn&rsquo;t do Early Hints, the <code>Link</code> header still helps a little: the browser can start those fetches as soon as the response headers arrive, before it parses the body. But no <code>103</code> is sent unless your server or CDN emits one.</p>

<h2 class="relative group">How to check it&rsquo;s working
    <div id="how-to-check-its-working" 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="#how-to-check-its-working" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><code>curl</code> shows the <code>103</code> directly. Ask for HTTP/2 and keep only the status lines and <code>Link</code> headers:</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>The <code>103</code> arrives first, then the <code>200</code>. Cloudflare reorders the attributes in the hint, which is harmless. Don&rsquo;t expect every request to get a <code>103</code>. Two of my first three did. The first visit after a change only stores the header, and Cloudflare skips the <code>103</code> when the final response headers are already available.</p>
<p>Then go back to the analytics query above and filter on <code>requestSource: &quot;earlyHintsCache&quot;</code>, grouped by status and path. On the pages you fixed, misses should give way to hits.</p>

<h2 class="relative group">What changed, and the caveat
    <div id="what-changed-and-the-caveat" 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="#what-changed-and-the-caveat" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Minutes after the header went live, the zone logged its first Early Hints hits ever, and <code>curl</code> showed real <code>103</code> responses on the web pages. One header fixed both problems: the feature started working, and a slice of the &ldquo;504s&rdquo; went away.</p>
<p>The caveat is that a JSON API has nothing to preload. The API endpoints keep missing and keep writing <code>504</code> lines, and on an API-heavy zone that&rsquo;s still most of the volume. You have three sensible options:</p>
<ol>
<li><strong>Send the header on the pages that benefit</strong> and accept that API paths keep logging misses. The feature works, and the charts are quieter but not silent.</li>
<li><strong>Exclude <code>earlyHintsCache</code> from your 5xx alerting and dashboards.</strong> Do this whatever else you decide. An internal cache lookup should never page anyone.</li>
<li><strong>Turn Early Hints off</strong> (Speed → Optimization in the dashboard, or the zone&rsquo;s <code>early_hints</code> setting in the API) if you&rsquo;ll never send <code>Link</code> headers. A single-page app whose HTML is tiny may get little from it, and then every lookup stops and the noise goes with it.</li>
</ol>
<p>I kept it on. The web pages now get a head start, the API&rsquo;s misses are filtered out of everything that alerts, and that&rsquo;s what I should have done in September instead of switching the feature off.</p>

<h2 class="relative group">Checklist
    <div id="checklist" 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="#checklist" aria-label="Anchor">#</a>
    </span>
    
</h2>
<ol>
<li>Before you call a Cloudflare 5xx spike an outage, split it by <code>requestSource</code> (or user agent).</li>
<li><code>earlyHintsCache</code> 504s are cache misses with <code>OriginResponseStatus = 0</code>. They are harmless and never reach your origin.</li>
<li>A real 504 comes from your proxy or load balancer. Cloudflare&rsquo;s own timeouts are 522 (can&rsquo;t connect) and 524 (100 seconds with no response).</li>
<li>Early Hints needs the <code>Link</code> <strong>response header</strong>. Preload tags in your HTML don&rsquo;t count.</li>
<li>Hint only render-critical CSS, scripts and fonts, and keep <code>crossorigin</code> on fonts.</li>
<li>Verify with <code>curl -sv --http2</code>: you should see a <code>103</code> before the <code>200</code>.</li>
<li>Keep <code>earlyHintsCache</code> out of your alerting.</li>
<li>If you&rsquo;ll never send the header, turn Early Hints off rather than paying for it in log noise.</li>
</ol>
]]></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>
