<?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 · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/http-103/</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>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/http-103/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>Cloudflare&rsquo;s dashboard said a quarter of my requests were 504s. Not one of them reached a user: they were cache misses in Cloudflare&rsquo;s Early Hints feature, logged as 504 with <code>ClientRequestSource = earlyHintsCache</code> and <code>OriginResponseStatus = 0</code>. Here&rsquo;s how to tell them from real 504s, and the one header that turns the misses into hits.</p>
<p>I first met them in September, when <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >a Cloudflare dashboard said 22% of my requests were failing</a> while every health check stayed green; I switched Early Hints off and moved on. In November the same zone showed another 2,600 504s in a day, with Early Hints on again, and this time I wanted to know exactly what they are and whether the right fix was to turn the feature off a second time or to make it do its job. The zone fronts one Fly.io origin serving a Django API, an Expo web app and a Hugo marketing site.</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 Cloudflare&rsquo;s Early Hints cache, not by your visitors. In Cloudflare&rsquo;s logs they carry <code>ClientRequestSource = earlyHintsCache</code>.</li>
<li>A 504 there means a cache miss: Cloudflare had no hints stored for that URL. A hit is logged as a 2xx.</li>
<li>They never reach your origin. <code>OriginResponseStatus</code> is <code>0</code>.</li>
<li>Early Hints runs on the <code>Link</code> HTTP response header. <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>
<p>Three more things worth knowing up front. Hint only the render-critical CSS, scripts and fonts, and keep <code>crossorigin</code> on the fonts. Verify with <code>curl -sv --http2</code>: the <code>103</code> should arrive before the <code>200</code>. And keep <code>earlyHintsCache</code> out of your alerting whatever else you decide; if you&rsquo;ll never send the header, turn the feature off rather than paying for it in log noise.</p>

<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>ClientRequestSource</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 almost always 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 125 seconds (the default proxy read timeout 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 125 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 125-second limit, up to 6,000 seconds, and Cloudflare itself treats that as a stopgap.)</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 for an HTML page or an extensionless URL (which, on an API-heavy zone, is most API calls) 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>ClientRequestSource = 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>2xx 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>504 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>
<p>Cloudflare documents this, briefly, on its <a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hints page</a>: an Early Hints lookup logged with <code>ClientRequestSource: earlyHintsCache</code> is &ldquo;expected and benign&rdquo;, a 200 is a cache hit and a 504 a miss, and Log Explorer hides them with <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code>. Cache Analytics already leaves them out. The HTTP traffic chart that scared me doesn&rsquo;t.</p>

<h2 class="relative group">These fake 504s talked me into a server upgrade
    <div id="these-fake-504s-talked-me-into-a-server-upgrade" 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="#these-fake-504s-talked-me-into-a-server-upgrade" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is why the split matters. On 3 November, the day before I started on this post, Cloudflare logged 3,292 504s against about 13,300 visitor requests: an apparent error rate of 25%. The API runs on Fly.io&rsquo;s <code>shared-cpu-1x</code> machines, which are burst-credited. They get a small guaranteed share of a CPU and spend credits to run faster, and a load test in August had shown them throttling to roughly 20 to 30 requests a second under sustained load. &ldquo;A quarter of requests timing out at peak&rdquo; looked exactly like that throttling, so I moved the API to <code>shared-cpu-2x</code>.</p>
<p>Every one of those 504s was an Early Hints cache miss. Here is what the <code>requestSource</code> split and the origin&rsquo;s own logs showed:</p>
<ul>
<li>Every Cloudflare 5xx from 28 October to 4 November was an <code>earlyHintsCache</code> lookup, apart from about one 525 a day (the one I traced came from a security scanner).</li>
<li>The origin&rsquo;s access logs for 24 October to 4 November had no 5xx responses at all, and one request slower than 10 seconds.</li>
<li>Fly&rsquo;s metrics never showed more than about 7 requests a second, and the machine never used more than a fifth of one CPU core. It never got near the throttling ceiling.</li>
</ul>
<p>The bigger machines fixed nothing, because nothing was broken. The API is back on <code>shared-cpu-1x</code>.</p>
<p>Don&rsquo;t make capacity decisions from a 5xx chart you haven&rsquo;t split by request source. Check it against your origin&rsquo;s own logs as well: if Cloudflare reports a quarter of requests failing and your server logged no errors, find out what Cloudflare is counting before you change the server.</p>
<p><em>Updated 5 November 2026: the first version of this section said the API had really fallen over on <code>shared-cpu-1x</code> that week. It hadn&rsquo;t. The numbers above are what I found when I checked.</em></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 (17 and later) 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 my Hugo theme 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. The standard library&rsquo;s <code>html.parser</code> copes with quoted and unquoted attributes (Hugo&rsquo;s minifier strips the quotes), so there&rsquo;s no regex to get wrong:</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>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>One detail bit me, and it&rsquo;s worth keeping: keep <code>crossorigin</code> on fonts. A font preload without it is treated as a different request, so the font gets downloaded twice and the page gets slower, not faster.</p>
<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>nginx, 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>Laravel 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>Anything else: 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. Send the header on the pages that benefit and accept that API paths keep logging misses; the feature works, and the charts are quieter but not silent. Exclude <code>earlyHintsCache</code> from your 5xx alerting and dashboards whatever else you decide, because an internal cache lookup should never page anyone. Or turn Early Hints off (Speed → Settings → Content 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.</p>
<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>
]]></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>
