Last winter we pushed a 12GB live-transcoded asset through our edge without buffering it to disk first, and the client insisted on a SHA-256 for the entire file. You can't compute that hash until you've sent the last byte, which means you can't put it in the response headers. That's exactly the gap HTTP trailers were designed to fill, and if you're running a CDN that streams large or dynamically generated bodies, you’ve probably hit this wall too.
Why You Can't Put Everything Up Front: The Case for Trailing Headers
For years I treated headers as a contract you had to finalize before sending a single byte of body. That's how most CDN configs work — you set your Content-Type, Cache-Control, Content-Length, and you're done. But with chunked encoding and streaming origins, that model breaks. When your origin is generating a response on the fly — say, stitching HLS segments, compressing on the fly with Brotli, or proxying an S3 object you haven't fully read — you don't know the final checksum, the total processing time, or even whether the upstream succeeded until the stream is nearly finished.
I've seen teams try to work around this by buffering the entire response at the edge just to calculate a Digest header. It works, but it kills your TTFB and eats memory. On one high-throughput POP we measured an extra 400-600ms of hold time and 2x memory usage for 100MB+ objects. It also breaks the benefits of HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management because you're no longer streaming; you're store-and-forward. HTTP trailers solve this cleanly by letting you send post-body metadata after the body has been delivered, using the same header semantics but at the end of the chunked stream.
The mechanics are defined in the HTTP/1.1 spec and carried forward into HTTP/2 and HTTP/3, though the framing is different. You announce what you're going to send late with the Trailer header up front, then append the actual values after the final chunk. Clients and intermediate caches that understand HTTP Working Group Specifications will merge them, while those that don't simply ignore them. It's not new technology, but it's been largely ignored because browser support was patchy until recently.
HTTP/1.1 200 OK
Content-Type: video/mp4
Transfer-Encoding: chunked
Trailer: X-Checksum-SHA256, X-Origin-Duration, Server-Timing
Cache-Control: public, max-age=31536000
400\r\n
; < 1024 bytes of video data >
400\r\n
; < 1024 bytes of video data >
0\r\n
X-Checksum-SHA256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
X-Origin-Duration: 1247
Server-Timing: origin;dur=1247, edge;dur=12
\r\n
Post-Body Checksums Without Buffering the World
At the edge, the most practical use for HTTP trailers is integrity verification. We don't want to trust a TCP checksum for a 4K asset that's been through three transcodes, and we don't want to make the client download the whole thing twice if it's corrupted. Back in 2021 we had an incident where a faulty NVMe drive on an origin was flipping a single bit in roughly 1 in 2000 objects. Standard CDN logging didn't catch it because the HTTP 200 looked fine. Once we added trailing headers for checksums, the clients could verify immediately and retry from a different edge.
The trick is declaring the trailer early and then computing the hash as you stream. In Nginx, Varnish, or a Cloudflare/Fastly worker, you shouldn't wait until the upstream finishes. You initialize your hash context when headers are sent, update it for every chunk you proxy, and finalize it in the trailer block. This keeps memory flat. If you're on HTTP/2, remember that trailers are carried as HEADERS frames after the DATA frames — they aren't HTTP/1-style chunked trailers, but the API looks the same. That detail matters when you're debugging with Wireshark and wondering why you don't see a raw `0\r\n`. You'll also need to handle the `TE: trailers` request header; some clients won't advertise support, and you shouldn't send trailing headers they didn't ask for.
We've found the best pattern is to send both a strong validator and a timing field. The checksum is for the client, but the duration is for your own HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes debugging when that client disconnects and reconnects mid-stream. Detailed guidance on trailer support in Fetch and streams is covered well in web.dev Articles if you're building the client side yourself. Don't try to put `Content-Length` or `Authorization` in a trailer — the spec forbids it, and most CDNs will strip it.
// Cloudflare Worker / Fastly Compute@Edge style - streaming with trailers
export default {
async fetch(req) {
const originResp = await fetch(originURL, { headers: req.headers });
// Announce what we'll send at the end
const headers = new Headers(originResp.headers);
headers.set('Trailer', 'X-Checksum-SHA256, Server-Timing');
headers.set('Transfer-Encoding', 'chunked');
headers.delete('Content-Length');
let hash = new SHA256();
const stream = originResp.body.pipeThrough(new TransformStream({
transform(chunk, controller) {
hash.update(chunk);
controller.enqueue(chunk);
},
flush(controller) {
// Trailer values are set as headers on the trailer object
// Runtime will emit them after the last DATA frame
controller.terminatingHeaders = {
'X-Checksum-SHA256': hash.digest('hex'),
'Server-Timing': `origin;dur=${Date.now() - start}`
};
}
}));
return new Response(stream, { status: 200, headers });
}
}
Carrying Timing and CDN Logging Data After the Body
Checksums get the attention, but where HTTP trailers really pay off for us is CDN logging and observability. Normally you have to choose: either log accurately at the edge after the response finishes, or expose timing data to the client in a header before you know the timing. Trailers let you do both. We now send `Server-Timing` as a trailer for all large-object deliveries because the origin duration, edge revalidation time, and cache state aren't known until the stream closes. The browser's Resource Timing API will pick up a trailer-based Server-Timing field just like a regular header, which means your RUM data gets real post-body numbers without an extra beacon.
This also fixes a long-standing pain with post-body metadata for range requests and coalesced streams. When you do origin shielding or collapsed forwarding, you might not want to commit to a log line until you know how many bytes were actually served from cache versus origin. With trailing headers, you can emit `X-Cache-Status`, `X-Shield-Hit`, or a custom `CDN-Edge-Log-ID` at the end, and your log shipper can capture it without parsing the body. It's far more reliable than trying to cram everything into an access log format that was decided at request start. If you've ever tuned HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First you'll know that prioritization decisions affect delivery timing — trailers let you report that timing honestly after the fact instead of guessing.
There are caveats I've learned the hard way. Not every client or intermediary preserves trailing headers. Some older HTTP/1.1 proxies strip them, and AWS CloudFront historically didn't forward them unless you explicitly allowed them. HTTP/3 over QUIC handles them well, but you need to test your entire chain. Always treat trailers as best-effort informational — don't make them authoritative for security decisions unless you control the end-to-end path. And keep them small; I've seen teams try to stuff full JSON logs into a trailer and blow past header size limits on the final HEADERS frame, which causes a stream error right at the end.
| Metric (100MB streaming object, p95) | Buffered (Digest Header) | Streaming with HTTP Trailers | Delta |
|---|---|---|---|
| Time To First Byte | 842 ms | 48 ms | -94% faster |
| Edge Memory per Request | 104.2 MB | 1.8 MB | 57x lower |
| Client Checksum Mismatch Detection | After full retry | On stream end (0 extra RTT) | Immediate |
| CDN Logging Completeness (post-body fields) | 78% | 99.6% | +21.6 pts |
| Origin Offload for Revalidated Assets | 0% (buffered) | 38% (streamed + coalesced) | Significant |
Putting Trailers Into Production: Headers, Chunks, and Edge Code
You can't just tack headers onto the end of a response and hope they survive. For trailers to work, you've got to declare them upfront. That means sending a Trailer header before the body that lists exactly which fields you'll send later, and the client has to signal it's willing to accept them with TE: trailers. Without that handshake, most intermediaries will silently drop what you're trying to send. In HTTP/1.1 this hinges on Transfer-Encoding: chunked — the final zero-length chunk is where trailers live. In HTTP/2 and HTTP/3 it's cleaner, trailers become a HEADERS frame after the DATA frames, but the declaration requirement doesn't go away.
On the edge, you'll hit runtime differences fast. Cloudflare Workers and Fastly Compute@Edge both support trailers now, but with quirks. Fastly lets you append trailers via appendHeader after the body stream closes, while Workers require you to supply trailers as part of a FixedLengthStream or via the newer trailers API. We've seen folks burn hours because they tried to set a trailer after calling response.send() — that's too late. You need to instantiate the response with a streaming body and keep the trailer handle open. And if you're proxying to an origin that sends trailers, make sure your CDN isn't configured to buffer-and-spool the entire response to cache; we generally disable buffering for trailer-carrying responses or we limit it to 8-16MB before switching to pass-through.
At the origin, keep it simple: stream, hash incrementally, and don't buffer. Node, Go, and Rust all make this pretty painless if you avoid the default helper that calculates Content-Length for you. You'll want to explicitly flush headers with Transfer-Encoding: chunked and set Trailer: X-Checksum-SHA256, Server-Timing on the initial response. The hash itself should be computed on the unencoded bytes — a mistake we see constantly when HTTP Content Coding Negotiation: Accept-Encoding Beyond Gzip and Brotli is in play. If you're negotiating Brotli or Zstd at the edge, hash the raw object, not the compressed representation, or your edge verification will fail 100% of the time.
// Node.js 20+ origin streaming with trailing checksum + timing
import { createHash } from 'node:crypto';
export default function handler(req, res) {
const hash = createHash('sha256');
const start = performance.now();
// 1. Declare what we're going to send at the end
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Transfer-Encoding': 'chunked',
'Trailer': 'X-Checksum-SHA256, Server-Timing',
'Cache-Control': 'no-transform' // prevent intermediaries re-encoding
});
const stream = getLargeObjectStream(); // e.g., S3, file, generator
stream.on('data', (chunk) => {
hash.update(chunk);
res.write(chunk);
});
stream.on('end', () => {
const digest = hash.digest('base64');
const duration = (performance.now() - start).toFixed(1);
// 2. Add trailers *before* ending the chunked body
res.addTrailers({
'X-Checksum-SHA256': `sha256=${digest}`,
'Server-Timing': `origin;dur=${duration}, cdn-cache;desc="MISS"`
});
res.end();
});
}
When Trailers Vanish: Advanced Patterns and Troubleshooting at the Edge
The most common support ticket we get isn't "trailers are broken," it's "trailers work on my origin but not through the CDN." Nine times out of ten, something in the middle stripped them. Legacy corporate proxies, older Nginx versions (<1.19), and even some WAFs will normalize HTTP/1.1 responses by removing chunked encoding and coalescing the body with a Content-Length — which obliterates trailers. HTTP/2 to HTTP/1.1 downgrades are another culprit. If your edge talks HTTP/3 to viewers but fetches HTTP/1.1 from origin, verify your edge is configured to forward trailers across protocol boundaries. We run a synthetic check that fetches a 50MB object with a known checksum trailer every 60 seconds from 12 PoPs; if more than 2% of PoPs drop the trailer, we page.
Once you've got reliable delivery, you can get creative. One pattern we've pushed hard is using trailers for 206 Partial Content validation. Instead of buffering a 4GB range request to compute a Content-MD5, you stream the range and attach X-Range-Checksum as a trailer — the client can verify without holding the whole thing in memory. It pairs exceptionally well with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates where you need strong validators for resumed downloads. Another powerful combo is trailers + Server-Timing for cache debugging: send Server-Timing: origin;dur=42.3, revalidation;dur=8.1 as a trailer and you finally get accurate origin time *without* inflating TTFB by 15-25ms waiting to calculate it. Don't confuse this with bidirectional needs, though — if you truly need client-to-server streaming after headers, that's where WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 is the right tool, not trailers.
When you're debugging, don't trust Chrome DevTools alone — it'll show trailers in the Network tab under "Response Headers," but fetch() won't expose them unless you explicitly read them after the body is consumed. You've got to do await response.body.getReader().read() to completion, *then* check response.trailers or the equivalent. For quick validation, skip the browser and use curl --raw -v --http1.1 https://origin.example.com/large-file or nghttp -v for H2. Look for that 0\r\nX-Checksum-SHA256: ...\r\n\r\n sequence. If you see the trailer in curl but not in your JS, it's not a CDN bug — you just haven't drained the stream yet.
Frequently Asked Questions
Do CDNs cache responses that contain trailers?
Yes, but the body is cached, not the trailer values. A trailer like X-Checksum-SHA256 is deterministic so it can be re-emitted on a HIT, but a Server-Timing trailer from the origin shouldn't be replayed — your edge should generate a fresh one. We recommend marking trailer-carrying responses with Cache-Control: no-transform and ensuring your CDN's cache key doesn't vary on trailer presence, or you'll fragment your cache by 10-20%.
Can I use trailers with Content-Encoding like gzip or Brotli?
You can, but you must be precise about *what* you're checksumming. If you compute the hash after compression, the client has to decompress and re-compress to verify, which defeats the purpose. Best practice: hash the unencoded identity representation and name the trailer X-Checksum-Identity-SHA256 to make it explicit. Better yet, do compression at the edge *after* origin verification, so origin sends uncompressed bytes + trailer and edge compresses on the fly.
Why doesn't fetch() show my trailers immediately?
Because trailers arrive *after* the body, fetch can't expose them until the stream is fully consumed. The pattern is await response.arrayBuffer(); const checksum = response.headers.get('X-Checksum-SHA256') // null! won't work. You need an API that supports trailers — in recent runtimes that's response.trailers after the reader is done. Until that's universal, many teams send the same data as both a trailer for streaming clients and a final JSON chunk for legacy clients.
Are trailers supported on HTTP/1.1, HTTP/2, and HTTP/3?
Conceptually yes, but practically HTTP/1.1 is the flakiest. It requires TE: trailers from the client and Transfer-Encoding: chunked from the server, and any intermediary that buffers to add a Content-Length will strip them. HTTP/2 and HTTP/3 are far more reliable because trailers are just a trailing HEADERS frame that doesn't interfere with content length. If you need 99.9% delivery rates for critical checksums, we strongly recommend terminating HTTP/1.1 at the edge and using H2/H3 downstream.