Capturing client-side network failures with NEL

Network Error Logging (NEL) gives you a way to collect client-side network errors that your server-side logs never see. It works through two mechanisms: a NEL HTTP response header that tells the browser to start collecting errors, and the Reporting API, which handles delivering those errors to a collector endpoint you control.

How reporting endpoints are configured

To receive reports, you first configure endpoint groups using the legacy Report-To HTTP response header. The value of this header is a JSON object describing where the browser should send reports:

Report-To:
{
    "max_age": 10886400,
    "endpoints": [{
    "url": "https://analytics.provider.com/browser-errors"
    }]
}

If your collector endpoint lives on a different origin, it must support CORS preflight requests—for example, by sending appropriate Access-Control-Allow-* headers for methods like GET, PUT, and POST.

Each endpoint group needs a unique group name, a max_age specifying how long the browser should cache and use the configuration, and an endpoints array. You can optionally set include_subdomains to extend coverage to subdomains. The browser will only select one endpoint from the list, so if you need reports sent to multiple servers, forwarding must happen on your backend.

Sending multiple Report-To headers in a single response configures several endpoint groups at once. Alternatively, you can combine them into one header using comma-separated JSON objects:

Report-To: {
             "group": "default",
             "max_age": 10886400,
             "endpoints": [{
               "url": "https://example.com/browser-reports"
             }]
           }
Report-To: {
             "group": "network-errors-endpoint",
             "max_age": 10886400,
             "endpoints": [{
               "url": "https://example.com/network-errors"
             }]
           }
Report-To: {
             "group": "network-errors-endpoint",
             "max_age": 10886400,
             "endpoints": [{
               "url": "https://example.com/network-errors"
             }]
           },
           {
             "max_age": 10886400,
             "endpoints": [{
               "url": "https://example.com/browser-errors"
             }]
           }

When reports are delivered

The browser batches captured reports and sends them as POST requests with Content-Type: application/reports+json. Delivery happens out-of-band from your application—the browser decides when to send queued reports, balancing timely feedback against network congestion and higher-priority work. There is no API to force delivery, but this also means reporting has little to no performance impact on your app.

Failover and load balancing

For heavier reporting traffic, the endpoint group supports failover and load balancing inspired by DNS SRV records. Each endpoint can carry a weight to distribute a fraction of the reporting load, and a priority to designate fallback collectors that are only used when primary uploads fail. For instance, a backup collector at https://backup.com/reports can be configured with a lower priority:

Report-To: {
             "group": "endpoint-1",
             "max_age": 10886400,
             "endpoints": [
               {"url": "https://example.com/reports", "priority": 1},
               {"url": "https://backup.com/reports", "priority": 2}
             ]
           }

Enabling NEL for your origin

With a reporting group configured, the next step is sending the NEL response header. Since NEL is opt-in per origin, the header only needs to be sent once. Both NEL and Report-To apply to future requests to the same origin, as long as they remain within the max_age window. The header value is a JSON object with a max_age and a report_to field referencing the named collector group:

GET /index.html HTTP/1.1
NEL: {"report_to": "network-errors", "max_age": 2592000}

Note that NEL reports are scoped by origin, not by page. If example.com loads foobar.com/cat.gif and that resource fails, only foobar.com's NEL collector is notified. NEL essentially reproduces server-side logs on the client, so an origin only sees reports for its own resources.

For debugging, chrome://net-export/ is useful to verify that headers are correctly configured and reports are being sent. And while ReportingObserver is a related JavaScript-based mechanism, it is not suitable for network error logging because network errors cannot be intercepted via JavaScript.

A minimal Express collector

The following Node/Express example shows both how to configure reporting for network errors and how to capture the resulting reports with a dedicated handler:

const express = require('express');

const app = express();
app.use(
  express.json({
    type: ['application/json', 'application/reports+json'],
  }),
);
app.use(express.urlencoded());

app.get('/', (request, response) => {
  // Note: report_to and not report-to for NEL.
  response.set('NEL', `{"report_to": "network-errors", "max_age": 2592000}`);

  // The Report-To header tells the browser where to send network errors.
  // The default group (first example below) captures interventions and
  // deprecation reports. Other groups, like the network-error group, are referenced by their "group" name.
  response.set(
    'Report-To',
    `{
    "max_age": 2592000,
    "endpoints": [{
      "url": "https://reporting-observer-api-demo.glitch.me/reports"
    }],
  }, {
    "group": "network-errors",
    "max_age": 2592000,
    "endpoints": [{
      "url": "https://reporting-observer-api-demo.glitch.me/network-reports"
    }]
  }`,
  );

  response.sendFile('./index.html');
});

function echoReports(request, response) {
  // Record report in server logs or otherwise process results.
  for (const report of request.body) {
    console.log(report.body);
  }
  response.send(request.body);
}

app.post('/network-reports', (request, response) => {
  console.log(`${request.body.length} Network error reports:`);
  echoReports(request, response);
});

const listener = app.listen(process.env.PORT, () => {
  console.log(`Your app is listening on port ${listener.address().port}`);
});