<?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 · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/early-hints/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Wed, 04 Nov 2026 13:36:11 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/early-hints/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Hiệu năng web</category>
      <category>Preload</category>
      <category>Khả năng quan sát</category>
      <category>Gỡ lỗi</category>
      <description>Hàng nghìn lỗi 504 trên Cloudflare, nhưng không lỗi nào đến tay người dùng thật: tất cả đều là cache miss của Early Hints. Bài viết giải thích HTTP 103 hoạt động thế nào, cách phân biệt 504 giả với 504 thật, và header đã giải quyết vấn đề.</description>
      <content:encoded><![CDATA[<p>Tháng 9, tôi đã viết về <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >một bảng điều khiển Cloudflare báo 22% request bị lỗi</a> trong khi mọi health check vẫn xanh. Hóa ra phần lớn số 504 đó đến từ tính năng Early Hints của Cloudflare chứ không phải từ người dùng của tôi, nên tôi tắt Early Hints và chuyển sang việc khác.</p>
<p>Đến tháng 11, tôi mở lại đúng zone đó và thấy thêm 2.600 lỗi 504 chỉ trong một ngày. Early Hints lại đang bật. Lần này tôi muốn biết chính xác những lỗi 504 đó là gì, và cách đúng là tắt tính năng thêm lần nữa hay làm cho nó thực sự làm đúng việc của mình.</p>
<p>Zone này đứng trước một origin trên Fly.io, và origin đó phục vụ ba thứ: một Django API, một web app viết bằng Expo, và một trang marketing dựng bằng Hugo. Hãy ghi nhớ cái API. Nó có một câu chuyện năng lực xử lý thật của riêng nó, và đó chính là lý do sự phân biệt này quan trọng.</p>

<h2 class="relative group">Câu trả lời ngắn
    <div id="câu-trả-lời-ngắn" 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="#c%c3%a2u-tr%e1%ba%a3-l%e1%bb%9di-ng%e1%ba%afn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li>Những lỗi 504 này được ghi bởi <strong>bộ cache Early Hints của Cloudflare</strong>, không phải bởi khách truy cập. Trong log của Cloudflare, chúng có <code>RequestSource = earlyHintsCache</code>.</li>
<li>Ở đây, <strong>504 nghĩa là cache miss</strong>: Cloudflare không có gợi ý nào được lưu cho URL đó. Một lần hit được ghi là 2xx.</li>
<li>Chúng <strong>không bao giờ chạm tới origin</strong> của bạn. <code>OriginResponseStatus</code> là <code>0</code>.</li>
<li>Early Hints chạy bằng <strong>response header HTTP <code>Link</code></strong>. Các thẻ <code>&lt;link rel=&quot;preload&quot;&gt;</code> trong HTML không cung cấp dữ liệu cho nó.</li>
<li>Gửi header đó đi, và trên những trang có thứ cần preload, các lần miss sẽ chuyển thành hit và bắt đầu có phản hồi <code>103 Early Hints</code> thật.</li>
</ul>

<h2 class="relative group">Cách phân biệt 504 Cloudflare giả với 504 thật
    <div id="cách-phân-biệt-504-cloudflare-giả-với-504-thật" 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="#c%c3%a1ch-ph%c3%a2n-bi%e1%bb%87t-504-cloudflare-gi%e1%ba%a3-v%e1%bb%9bi-504-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn dùng Logpush hoặc Instant Logs, hãy xem hai trường trước tiên. <code>RequestSource</code> là <code>eyeball</code> với request của khách truy cập thật, và là <code>earlyHintsCache</code> với các lượt tra cứu Early Hints. <code>OriginResponseStatus</code> bằng <code>0</code> nghĩa là origin chưa từng được liên hệ.</p>
<p>Trong GraphQL Analytics API, sự phân tách đó chính là dimension <code>requestSource</code>. Đây là truy vấn tôi luôn chạy trước khi tin vào bất kỳ đợt tăng vọt 5xx nào:</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>Nhóm theo User-Agent cũng cho ra cùng một câu chuyện. Vào tháng 9, các lượt tra cứu Early Hints hiện lên dưới dạng <code>bastion early hints</code> và <code>nginx-ssl early hints</code>. Đến tháng 11 thì là <code>fl2 early hints</code> và <code>nginx-ssl early hints</code> (FL2 là thế hệ proxy mới của Cloudflare). Trong 24 giờ, toàn bộ 2.631 lỗi 504 của zone đều đến từ những User-Agent này. Không có lỗi nào đến từ trình duyệt, ứng dụng, curl hay crawler của công cụ tìm kiếm.</p>
<p>Một lỗi 504 thật mà khách truy cập nhìn thấy thì đến từ phía bạn, sau Cloudflare: một proxy hay load balancer đứng trước ứng dụng đã chờ quá lâu và bỏ cuộc. Đó có thể là nginx, một load balancer trên cloud, hoặc proxy của nền tảng đang chờ một máy đang ngủ khởi động. Các timeout của chính Cloudflare dùng mã khác: <strong>522</strong> nghĩa là không kết nối được tới origin, còn <strong>524</strong> nghĩa là đã kết nối nhưng không nhận được phản hồi trong vòng 100 giây (mặc định trên các gói dưới Enterprise).</p>
<p>Vậy nếu bạn tìm đến đây vì muốn tăng timeout của Cloudflare do lỗi 504: timeout đứng sau một lỗi 504 gần như không bao giờ là của Cloudflare. Hãy kiểm tra <code>proxy_read_timeout</code> trong nginx, idle timeout của load balancer, hoặc thời gian nền tảng của bạn cần để đánh thức một máy. Một manh mối hữu ích: nếu request thất bại ở một con số tròn thấp hơn hẳn 100 giây, như 30 hay 60, thì đó là giới hạn của proxy của bạn, không phải của Cloudflare. (Chỉ gói Enterprise mới nâng được giới hạn 100 giây của Cloudflare, tối đa 6.000 giây, và chính Cloudflare cũng coi đó là giải pháp tạm thời.)</p>

<h2 class="relative group">Cùng tuần đó, API gặp lỗi 504 thật
    <div id="cùng-tuần-đó-api-gặp-lỗi-504-thật" 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="#c%c3%b9ng-tu%e1%ba%a7n-%c4%91%c3%b3-api-g%e1%ba%b7p-l%e1%bb%97i-504-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là lý do việc tách bạch quan trọng. Vài ngày trước đó, chính cái API ấy đã thực sự sập. Nó chạy trên máy <code>shared-cpu-1x</code> của Fly.io, loại máy hoạt động theo burst credit: dưới lưu lượng giờ cao điểm ở New Zealand, nó dùng hết burst và bị bóp xuống mức trần bền vững khoảng 20 đến 30 request mỗi giây. Thời gian phản hồi sụp đổ và khách truy cập nhận lỗi 504 thật ở edge. Chuyển sang <code>shared-cpu-2x</code> đã khắc phục được.</p>
<p>Rồi biểu đồ 504 lại đầy lên, lần này là các lần miss của Early Hints. Nếu đọc mà không tách theo <code>requestSource</code>, biểu đồ đó sẽ nói rằng máy lớn hơn chẳng giúp được gì. Thực tế, cùng một tuần, trên cùng một zone, có hai nhóm 504 hoàn toàn khác nhau, và chỉ một nhóm từng chạm tới con người. Đừng ra quyết định về năng lực xử lý dựa trên một biểu đồ 5xx chưa được tách theo request source.</p>

<h2 class="relative group">Bản chất thật của những lỗi 504 này
    <div id="bản-chất-thật-của-những-lỗi-504-này" 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="#b%e1%ba%a3n-ch%e1%ba%a5t-th%e1%ba%adt-c%e1%bb%a7a-nh%e1%bb%afng-l%e1%bb%97i-504-n%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên một zone đã bật Early Hints, mỗi request đều kèm thêm một lượt tra cứu nội bộ: Cloudflare kiểm tra cache của chính nó xem có gợi ý nào để gửi không. Lượt tra cứu đó được ghi thành một dòng riêng, với <code>RequestSource = earlyHintsCache</code>. Nó không phải request của người dùng cuối, và không bao giờ được gửi tới origin của bạn.</p>
<p>Mã trạng thái trên dòng đó là kết quả tra cứu cache, không phải lỗi:</p>
<ul>
<li><strong>2xx</strong> là hit. Cloudflare có lưu header <code>Link</code> cho URL đó và đã gửi chúng tới trình duyệt dưới dạng <code>103</code>. Trên zone của tôi, hit xuất hiện dưới cả hai mã <code>200</code> và <code>204</code>.</li>
<li><strong>504</strong> là miss. Không có gì được lưu, nên không có gì để gửi sớm.</li>
</ul>
<p>Nếu origin không bao giờ gửi header mà Early Hints dựa vào, mọi lượt tra cứu đều miss và mọi request đều ghi một dòng 504. Trong ba giờ trước khi tôi thay đổi bất cứ điều gì, zone ghi nhận 173 lượt tra cứu Early Hints và cả 173 đều là miss. Kể từ khi được bật, tính năng này chưa từng gửi đi một gợi ý nào.</p>
<p>Một điều chỉnh cho bài viết tháng 9 của tôi: khi đó tôi mô tả chúng là các probe &ldquo;không nhận được phản hồi từ origin trên Fly&rdquo;. Điều đó không hoàn toàn đúng. Chúng hoàn toàn không đi tới origin. Mã 504 ghi lại một lần cache miss, không phải một lần timeout.</p>

<h2 class="relative group">HTTP 103 có phải là lỗi không?
    <div id="http-103-có-phải-là-lỗi-không" 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-c%c3%b3-ph%e1%ba%a3i-l%c3%a0-l%e1%bb%97i-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không. Các mã trạng thái trong dải 1xx là phản hồi thông tin: những phản hồi tạm thời mà máy chủ có thể gửi trước phản hồi thật. <code>103 Early Hints</code>, được định nghĩa trong RFC 8297, mang theo header <code>Link</code> để trình duyệt có thể bắt đầu tải CSS, JavaScript và font quan trọng, hoặc mở sẵn kết nối tới các origin khác, trong khi máy chủ vẫn đang dựng trang. Mã <code>200</code> cuối cùng sẽ đến sau trên cùng một request.</p>
<p>Mức hỗ trợ không đồng đều nhưng an toàn. Các trình duyệt dựa trên Chromium (Chrome từ phiên bản 103, và Edge) cùng Firefox bản mới xử lý cả gợi ý <code>preload</code> lẫn <code>preconnect</code>. Safari chỉ dùng <code>preconnect</code>. Trình duyệt chỉ xử lý <code>103</code> qua HTTP/2 và HTTP/3. Trình duyệt nào không hiểu nó thì đơn giản là chờ <code>200</code> như mọi khi, nên <code>103</code> là một lợi thế xuất phát sớm cho phần lớn khách truy cập và không ảnh hưởng gì tới số còn lại.</p>

<h2 class="relative group">Vì sao của tôi trống trơn: header <code>Link</code> so với <code>&lt;link rel=preload&gt;</code>
    <div id="vì-sao-của-tôi-trống-trơn-header-link-so-với-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="#v%c3%ac-sao-c%e1%bb%a7a-t%c3%b4i-tr%e1%bb%91ng-tr%c6%a1n-header-link-so-v%e1%bb%9bi-link-relpreload" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là chỗ tôi mất nhiều thời gian nhất, vì trông nó như thể lẽ ra đã hoạt động rồi.</p>
<p>Early Hints chỉ cache đúng một thứ: <strong>response header HTTP <code>Link</code></strong> có quan hệ <code>rel=preload</code> hoặc <code>rel=preconnect</code>. Cloudflare đọc nó từ các phản hồi thông thường của bạn (mã <code>200</code>, <code>301</code> hoặc <code>302</code>, trên URL HTML hoặc không có phần mở rộng), lưu lại, rồi phát lại cho khách truy cập tiếp theo dưới dạng <code>103</code> trước khi phản hồi đầy đủ sẵn sàng.</p>
<p>Các trang của tôi vốn đã có preload, nhưng ở dạng thẻ trong <code>&lt;head&gt;</code> của HTML:</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>Những thẻ này vẫn có tác dụng: trình duyệt hành động theo chúng. Nhưng trình duyệt chỉ thấy chúng sau khi đã nhận HTML và bắt đầu phân tích, mà đó lại chính là khoảnh khắc Early Hints muốn đi trước. Bản export của Expo và Hugo đều tự viết các thẻ này, nhưng không cái nào đặt header. Vậy là tính năng đã bật, preload cũng đã khai báo, nhưng hai thứ chưa bao giờ gặp nhau.</p>

<h2 class="relative group">Cách sửa: gửi header từ dữ liệu bạn vốn đã có
    <div id="cách-sửa-gửi-header-từ-dữ-liệu-bạn-vốn-đã-có" 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="#c%c3%a1ch-s%e1%bb%ada-g%e1%bb%adi-header-t%e1%bb%ab-d%e1%bb%af-li%e1%bb%87u-b%e1%ba%a1n-v%e1%bb%91n-%c4%91%c3%a3-c%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách sạch nhất không phải là tự tay duy trì một danh sách tài nguyên quan trọng, thứ sẽ lỗi thời mỗi khi hash của bundle thay đổi. Mỗi trang đã tự khai báo những gì nó muốn preload, trong chính các thẻ đó. Vậy chỉ cần chép chúng sang header <code>Link</code> khi phục vụ trang:</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>Và gắn nó vào phản hồi HTML trong 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>Có hai chi tiết đã làm tôi vấp, và đáng để giữ lại:</p>
<ul>
<li><strong>Neo chặt bộ phân tích thuộc tính.</strong> Tìm kiếm <code>as</code> một cách ngây thơ sẽ khớp ngay chữ &ldquo;as&rdquo; trong <code>href=&quot;/assets/...&quot;</code>, và các preload font của bạn lặng lẽ mất <code>as=font</code>. Hãy bắt buộc tên thuộc tính bắt đầu sau khoảng trắng và kết thúc ở <code>=</code>, khoảng trắng, <code>/</code> hoặc <code>&gt;</code>. Đồng thời xử lý cả thuộc tính có và không có dấu ngoặc kép, vì các trình tạo site tĩnh như Hugo xuất ra <code>href=https://jared.lynskey.co.nz/fonts/x.woff2 as=font crossorigin</code> mà không có dấu ngoặc. Lỗi này là do unit test bắt được.</li>
<li><strong>Giữ <code>crossorigin</code> cho font.</strong> Một preload font thiếu nó sẽ bị coi là một request khác, font bị tải về hai lần, và trang chậm đi chứ không nhanh hơn.</li>
</ul>
<p>Bạn không cần tự viết <code>103</code>. Không có đoạn mã phản hồi tạm thời nào phải thêm vào ASGI, Granian hay uvicorn. Cloudflare dựng <code>103</code> từ header của bạn; việc duy nhất của bạn là gửi header đó.</p>

<h2 class="relative group">Trên các stack khác
    <div id="trên-các-stack-khác" 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="#tr%c3%aan-c%c3%a1c-stack-kh%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Quy tắc ở đâu cũng vậy: đặt header <code>Link</code> lên phản hồi HTML và để Cloudflare lo phần còn lại.</p>
<ul>
<li>
<p><strong>nginx</strong>, trong location phục vụ 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> dùng Vite đã có sẵn middleware cho việc này, <code>AddLinkHeadersForPreloadedAssets</code>, chuyển các preload của Vite thành header <code>Link</code>. Hai vấn đề từng được báo cáo với nó cũng chính là những cái bẫy ở trên: các gợi ý <code>modulepreload</code> của nó từng được gửi đi mà không có <code>crossorigin</code>, khiến trình duyệt có thể bỏ qua chúng và tải script hai lần, và trên các trang lớn, header từng vượt quá giới hạn header khoảng 8 KB của Apache. Hãy kiểm tra cả hai trên phiên bản bạn đang dùng.</p>
</li>
<li>
<p><strong>Mọi môi trường khác</strong>: dùng một header <code>Link</code> phân tách bằng dấu phẩy, hoặc gửi nhiều header <code>Link</code>. Cách nào cũng được.</p>
</li>
</ul>
<p>Chỗ mọi người hay làm quá tay là chọn gợi ý những gì. Chỉ gợi ý CSS quan trọng cho việc render, script đầu vào và một hai font dùng ở màn hình đầu tiên, đừng nhét mọi chunk vào. Danh sách dài khiến trình duyệt đi tải những thứ chưa cần trước cả những thứ đang cần. Tôi giới hạn ở mười hai mục, và đó là vì bản export của Expo đã chọn lọc sẵn danh sách preload.</p>
<p>Nếu bạn không đứng sau Cloudflare, hoặc CDN của bạn không hỗ trợ Early Hints, header <code>Link</code> vẫn giúp được một chút: trình duyệt có thể bắt đầu tải các tài nguyên đó ngay khi header phản hồi tới nơi, trước khi phân tích phần thân. Nhưng sẽ không có <code>103</code> nào trừ khi máy chủ hoặc CDN của bạn tự gửi.</p>

<h2 class="relative group">Cách kiểm tra xem nó đã hoạt động chưa
    <div id="cách-kiểm-tra-xem-nó-đã-hoạt-động-chưa" 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="#c%c3%a1ch-ki%e1%bb%83m-tra-xem-n%c3%b3-%c4%91%c3%a3-ho%e1%ba%a1t-%c4%91%e1%bb%99ng-ch%c6%b0a" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>curl</code> cho bạn thấy <code>103</code> trực tiếp. Gửi request bằng HTTP/2 và chỉ giữ lại các dòng trạng thái cùng header <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> đến trước, rồi mới tới <code>200</code>. Cloudflare sắp xếp lại thứ tự các thuộc tính trong gợi ý, điều này vô hại. Đừng kỳ vọng mọi request đều nhận được <code>103</code>. Trong ba lần đầu của tôi, chỉ có hai lần nhận được. Lần truy cập đầu tiên sau khi thay đổi chỉ lưu header lại, và Cloudflare bỏ qua <code>103</code> khi header của phản hồi cuối cùng đã sẵn sàng.</p>
<p>Sau đó quay lại truy vấn phân tích ở trên, lọc theo <code>requestSource: &quot;earlyHintsCache&quot;</code>, nhóm theo mã trạng thái và đường dẫn. Trên những trang bạn đã sửa, hit sẽ dần thay thế miss.</p>

<h2 class="relative group">Điều gì đã thay đổi, và điều gì còn lại
    <div id="điều-gì-đã-thay-đổi-và-điều-gì-còn-lại" 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="#%c4%91i%e1%bb%81u-g%c3%ac-%c4%91%c3%a3-thay-%c4%91%e1%bb%95i-v%c3%a0-%c4%91i%e1%bb%81u-g%c3%ac-c%c3%b2n-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Vài phút sau khi header được đưa lên production, zone ghi nhận những lần hit Early Hints đầu tiên trong lịch sử, và <code>curl</code> cho thấy phản hồi <code>103</code> thật trên các trang web. Một header giải quyết cùng lúc hai vấn đề: tính năng bắt đầu hoạt động, và một phần các &ldquo;lỗi 504&rdquo; biến mất.</p>
<p>Điều còn lại là một JSON API chẳng có gì để preload. Các endpoint API tiếp tục miss và tiếp tục ghi các dòng <code>504</code>, và trên một zone nặng về API thì phần này vẫn chiếm đa số. Bạn có ba lựa chọn hợp lý:</p>
<ol>
<li><strong>Gửi header trên những trang có lợi</strong> và chấp nhận các đường dẫn API tiếp tục ghi miss. Tính năng hoạt động, các biểu đồ yên tĩnh hơn nhưng không hoàn toàn im lặng.</li>
<li><strong>Loại <code>earlyHintsCache</code> khỏi cảnh báo 5xx và dashboard.</strong> Dù bạn quyết định gì thì cũng hãy làm việc này. Một lượt tra cứu cache nội bộ không bao giờ nên đánh thức ai dậy.</li>
<li><strong>Tắt Early Hints</strong> (Speed → Optimization trong dashboard, hoặc thiết lập <code>early_hints</code> của zone qua API) nếu bạn sẽ không bao giờ gửi header <code>Link</code>. Một ứng dụng một trang có HTML rất nhỏ có thể không được lợi gì nhiều, và khi tắt thì mọi lượt tra cứu dừng lại, tiếng ồn cũng biến mất theo.</li>
</ol>
<p>Tôi giữ nó bật. Các trang web giờ đã được xuất phát sớm hơn, các lần miss của API đã bị lọc khỏi mọi thứ có thể kích hoạt cảnh báo, và đó mới là điều lẽ ra tôi phải làm vào tháng 9 thay vì tắt tính năng đi.</p>

<h2 class="relative group">Danh sách kiểm tra
    <div id="danh-sách-kiểm-tra" 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="#danh-s%c3%a1ch-ki%e1%bb%83m-tra" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ol>
<li>Trước khi gọi một đợt tăng vọt 5xx trên Cloudflare là sự cố, hãy tách nó theo <code>requestSource</code> (hoặc User-Agent).</li>
<li>Lỗi 504 của <code>earlyHintsCache</code> là cache miss với <code>OriginResponseStatus = 0</code>. Chúng vô hại và không bao giờ chạm tới origin.</li>
<li>Lỗi 504 thật đến từ proxy hoặc load balancer của bạn. Timeout của chính Cloudflare là 522 (không kết nối được) và 524 (100 giây không có phản hồi).</li>
<li>Early Hints cần <strong>response header</strong> <code>Link</code>. Thẻ preload trong HTML không được tính.</li>
<li>Chỉ gợi ý CSS, script và font quan trọng cho việc render, và giữ <code>crossorigin</code> cho font.</li>
<li>Kiểm tra bằng <code>curl -sv --http2</code>: bạn sẽ thấy <code>103</code> trước <code>200</code>.</li>
<li>Để <code>earlyHintsCache</code> ra ngoài hệ thống cảnh báo.</li>
<li>Nếu bạn sẽ không bao giờ gửi header, hãy tắt Early Hints thay vì trả giá cho nó bằng tiếng ồn trong log.</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>
