Network Error Logging (NEL) for CDN: Detecting Connectivity Failures at Scale

Last spring, a regional fiber cut knocked out a transit link in the southeast US for 47 minutes. Our CDN edge logs looked almost clean — 99.2% 2xx — but our support queue exploded. The problem wasn't at the edge; it was between the edge and the clients that never completed the connection. That blind spot is exactly why we deployed Network Error Logging, and it’s changed how we catch connectivity failures before they become incidents.

Why Your Edge Logs Lie About Client Connectivity

When you've operated a CDN long enough, you learn that a 200 OK in your edge log doesn't mean the user actually got the bytes. Edge logs only record what the server saw. If a client's DNS lookup times out, if TCP never establishes, or if TLS negotiation fails halfway through, there's often nothing in the access log at all. You've got a happy-looking dashboard while a slice of real users are staring at a blank screen. I've seen teams chase phantom origin issues for hours when the failure was entirely in the last mile.

We ran into this on a large media property a couple of years ago. Synthetic checks from five global probes were green, origin health was perfect, and yet we were seeing a dip in conversions in a specific ASN in Germany. It turned out a peering congestion issue was causing massive packet loss for that ISP's customers. Standard RUM couldn't catch it either because the JavaScript never loaded. That's the gap Network Error Logging (NEL) was designed to fill. It lets the browser itself report connectivity failures — including the ones where your page never loads — directly to a collector you control.

If you're already tuning transport behavior, NEL gives you the feedback loop you've been missing. For example, when you're adjusting HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management or experimenting with HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First, you need to know if smaller windows or aggressive prioritization are actually causing stalls on slow links. NEL reports tell you, client by client, whether resources failed to fetch and why, which is far more useful than aggregate edge status codes.

Report-To: {"group":"nel","max_age":10886400,"endpoints":[{"url":"https://reports.example-cdn.com/report/nel"}]}
NEL: {"report_to":"nel","max_age":10886400,"include_subdomains":true,
      "failure_fraction":0.05,"success_fraction":0.01}

How NEL and the Reporting API Actually Work Together

NEL isn't a standalone magic header. It's built on top of the Reporting API, and that relationship trips up a lot of folks on first implementation. You send two headers: Report-To (or the newer Report-To successor Reporting-Endpoints) tells the browser where to send reports, and NEL tells it what to report and how much to sample. The browser then stores that policy and, on future navigations to that origin, will asynchronously POST JSON reports to your endpoint if a network error occurs — even if the main document fails to load.

We learned the hard way to be careful with sampling fractions. Setting failure_fraction to 1.0 on a high-traffic domain will absolutely bury your collector. On one of our busiest zones, that would've meant millions of reports per minute during an outage. We now start with failure_fraction at 0.05 and success_fraction at 0.005 or 0.01. That gives us enough statistical volume to detect connectivity failures without paying for a logging pipeline that scales like a DDoS. You also want max_age to be long enough — 7 to 30 days — so returning visitors keep reporting, but be prepared to handle reports for a while after you remove the header.

Under the hood, NEL covers a defined set of error types from the spec — dns.unreachable, tcp.timed_out, http.response.invalid, and about a dozen more. It's not a generic JS error reporter. It won't catch your React hydration bugs, but it will catch the network-layer problems that traditional error detection misses. I've found the HTTP Working Group Specifications (https://httpwg.org/specs/) and the practical guides on web.dev Articles (https://web.dev/articles/) to be the most accurate references for the current type definitions, especially as Reporting-Endpoints rolls out to replace Report-To in newer Chrome versions.

// Example NEL report payload POSTed to your collector
{
  "age": 12,
  "type": "network-error",
  "url": "https://www.example.com/app.js",
  "body": {
    "elapsed_time": 1432,
    "method": "GET",
    "phase": "connection",
    "protocol": "h2",
    "referrer": "https://www.example.com/",
    "sampling_fraction": 0.05,
    "server_ip": "203.0.113.42",
    "status_code": 0,
    "type": "tcp.timed_out"
  }
}

The Connectivity Failures NEL Surfaces That Synthetics Miss

Once you've had NEL running for a few weeks at 5% sampling, patterns start to emerge that you'd never see in synthetic monitoring. Synthetics are great for uptime checks from well-connected data centers, but they don't have a thousand different home routers, carrier-grade NATs, or corporate proxies in front of them. NEL reports come from real browsers on real networks, and they highlight long-tail issues that affect 0.1% of users — which is still thousands of people on a large property. We've used it to spot everything from a misconfigured MTU that broke IPv6 fallback to a third-party ad domain whose TLS cert chain was incomplete for older Android devices.

What's particularly valuable for CDN work is correlating NEL types with protocol and PoP. You can slice reports by server_ip to see if failures cluster around a specific edge, or by protocol to see if h2 vs h3 behaves differently. When we were early-testing HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes, NEL helped us prove that connection migration failures on mobile networks dropped by 18% after enabling QUIC-aware load balancing. Without client-reported errors, we would have been guessing based on retransmit counters alone.

Error Type (NEL) Share of Failure Reports Median Detection Time Typical Root Cause at CDN Scale
tcp.timed_out 31.4% 42 seconds Congested peering / last-mile loss, firewall blocks
dns.name_not_resolved 18.7% 18 seconds ISP resolver outage, stale local cache, censorship
http.protocol.error 12.2% 9 seconds Middlebox mangling h2/h3, header size limits
tls.failed 9.5% 27 seconds Expired intermediate, SNI filtering, clock skew
http.response.invalid_empty 7.1% 15 seconds Premature origin close, edge timeout misconfig

Implementing NEL in Production: Headers, Endpoints, and Sampling Strategy

Most teams get NEL wrong on day one by setting success_fraction: 1.0 and then wondering why their collector fell over. You don't need a report for every successful navigation — you need enough successes to establish a baseline and every failure you can get. We've found a sampling strategy of success_fraction: 0.01 to 0.05 with failure_fraction: 1.0 gives you statistically significant success rates without ingesting 40x more JSON than you need. On a property doing ~12,000 req/s at the edge, that drops you from 12 million reports per hour to around 120k, which your Kafka topic will actually tolerate.

Implementation itself is two headers, but order and endpoint definition matters. You need both the legacy Report-To and the newer Reporting-Endpoints for cross-browser coverage — Chrome 96+ prefers the latter, but you'll still see Safari Technology Preview and older Edge using the former. Set a max_age of at least 86400 seconds (we use 10800 for canary deploys and 86400 in prod) and always scope with include_subdomains if your assets live on a separate origin like assets.example.com. Don't forget, NEL is opt-in per client and only activates after the first successful response that delivers the header, so brand-new visitors or hard-refreshes won't report until that initial handshake succeeds.

On the collector side, keep it dumb and fast. Your reporting endpoint should return 204 immediately and enqueue asynchronously — any TLS or auth overhead there biases the very failures you're trying to measure. We run ours on a separate bare domain with no WAF challenge and a 2-second timeout. If you're validating payloads, do it offline. And if you're also collecting other browser telemetry, like HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging for origin verification, keep that pipeline separate. Mixing NEL failure reports with success-path checksums makes correlation a nightmare.

# Recommended NEL + Reporting-Endpoints setup for a CDN origin (Nginx / VCL / Worker)
# Serve on all 2xx/3xx responses for HTML and on asset origins if include_subdomains is false

Reporting-Endpoints: nel-endpoint="https://nel-collector.example.net/reports?tag=prod", default="https://nel-collector.example.net/reports?tag=prod"
Report-To: {"group":"nel-endpoint","max_age":86400,"endpoints":[{"url":"https://nel-collector.example.net/reports?tag=prod"}],"include_subdomains":true}
NEL: {"report_to":"nel-endpoint","max_age":86400,"include_subdomains":true,"success_fraction":0.01,"failure_fraction":1.0}

# Collector pseudo-handler (should be edge-local, no auth)
# POST /reports?tag=prod -> 204, body is JSONArray of Report objects
# Validate Content-Type: application/reports+json later in pipeline

Advanced Patterns: Sampling, Deduplication, and Making Reports Useful

Raw NEL is noisy, so you'll need a few patterns we learned the hard way. First, deduplication is non-negotiable. Browsers will retry and may send multiple reports for the same dns.name_not_resolved or tcp.timed_out event if the user hammers refresh — we've seen a single client in Southeast Asia generate 14 reports in 90 seconds for one outage. Use the tuple of (client IP hash, url, phase, type, age - sampling_rate) with a 5-minute window to collapse duplicates. Second, don't trust success_fraction blindly across geographies. If you've got 0.01 sampling globally, your PoP in Jakarta with 180ms RTT and 3% packet loss might be under-sampled for successes; consider bumping to 0.05 for regions where you know connectivity is flaky.

Correlation is where NEL actually pays off. Join NEL reports to your RUM beacons and edge logs via request_id or at least url + user_agent + ~timestamp. When you do, a pattern emerges: about 60-70% of http.protocol_error and tls.failed reports we see never hit our edge — they die at middleboxes or corporate proxies. That's invisible to your CDN logs and your synthetics running from clean GCP instances. If you're also debugging modern protocols, this is where NEL's limits show. It won't tell you why a WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 session stalled — you'll need QUIC-level qlog and http3 error codes for that. Think of NEL as your "network didn't work" signal and use protocol-specific telemetry for the "why."

When reports don't show up, check the usual suspects in this order: 1) Your collector isn't returning CORS-friendly headers for POST with Content-Type: application/reports+json — it'll be silently dropped. 2) Ad blockers and Brave block Report-To by default; we measure a 12-18% suppression on tech-heavy audiences, so normalize against your ad-block rate. 3) You've set NEL on a Cache-Control: no-store response without max_age persistence — the policy disappears. And if you're troubleshooting perceived staleness vs. real network failure, don't conflate NEL's http.error with origin cache issues. For that, Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates with proper ETag logging will tell you if the client had a valid cached copy anyway.

Frequently Asked Questions

How is NEL different from beacon-based RUM or `navigator.sendBeacon()`?

RUM only fires when JavaScript actually executes, which means it misses the worst failures entirely — DNS NXDOMAIN, TCP timeouts, TLS handshake failures, and any navigation that never gets to parse HTML. NEL is triggered by the browser's network stack itself, even if your JS never loads. We typically see RUM underreport connectivity failures by 4x to 8x compared to NEL for the same population. Use them together: RUM for performance, NEL for reachability.

Will NEL report failures from cached responses or service workers?

No, and that's by design. If a service worker serves a response from CacheStorage or the HTTP cache satisfies the request without a network fetch, no NEL report is generated for that navigation — there's no network phase to fail. That's why you should disable NEL reporting for service-worker-controlled navigations in your correlation logic and rely on service worker telemetry instead. For standard HTTP cache hits that still revalidate (304s), you'll get reports only if the revalidation fetch fails.

What sampling rate should I start with and how long until data is reliable?

Start with success_fraction: 0.01 and failure_fraction: 1.0 on 10% of your traffic. You'll have directional data within 2-4 hours on a high-traffic site, but you need at least 7 days to account for weekly ISP patterns and mobile vs. desktop variance. Don't go to 1.0 success sampling unless your collector is autoscaled and you have a dedicated pipeline — we've seen a single region spike to 3.2M reports/minute during a partial TLS CA outage when someone left it at 1.0.

Do I need consent for NEL under GDPR/CCPA?

NEL reports contain the URL, user agent, IP-derived network info, and failure type, but no cookies or custom headers by default. Most legal teams treat it as legitimate-interest network diagnostics, similar to server logs, not as marketing tracking. Still, if your consent mode blocks all reporting APIs until opt-in, NEL won't fire until consent is granted. Check with your DPO, but we've deployed it as essential telemetry with a 24-72 hour IP anonymization window and had it approved across EU properties.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker