How to test offline mode in chrome devtools

Set the page offline in the DevTools Network tab, reload, then use every control that calls the API. In this capture the shell loaded from cache while /api/orders still reached the server, because offline emulation on the page does not cover the service worker. Stopping the server settled it.

Why check this

Run this before release on any build that claims to work without a network, and again in regression after a change to the worker, to the cache names, or to the API paths the page calls.

The defect it catches has a shape. The shell loads offline, the header and the last screen are there, and then every control that needs data does nothing. The cache holds the HTML and the CSS and none of the runtime calls. A page that loads offline and a page that works offline are different claims, and only the second one can be signed off.

The test itself fails in two ways. A page checked one reload too early looks broken when it is not, and offline set on the page alone leaves the worker's network running.

Prerequisites

// offline-demo.mjs: a page that loads offline and does not work offline.
import { createServer } from 'node:http';

const page = `<!doctype html><meta charset="utf-8"><title>Orders</title>
<link rel="stylesheet" href="/style.css">
<h1>Orders</h1>
<button id="load">Load orders</button>
<pre id="out">idle</pre>
<script>
navigator.serviceWorker.register('/sw.js');
document.getElementById('load').onclick = async () => {
  const out = document.getElementById('out');
  try { const r = await fetch('/api/orders'); out.textContent = await r.text(); }
  catch (e) { out.textContent = 'FAILED: ' + e.message; }
};
</script>`;

const sw = `const SHELL = 'shell-v1';
self.addEventListener('install', (e) =>
  e.waitUntil(caches.open(SHELL).then((c) => c.addAll(['/', '/style.css']))));
self.addEventListener('fetch', (e) =>
  e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request))));
self.addEventListener('message', (e) => {
  fetch('/api/from-worker')
    .then((r) => r.text()).then((t) => e.source.postMessage('worker reached the network: ' + t))
    .catch((err) => e.source.postMessage('worker failed: ' + err.message));
});`;

const routes = {
  '/': ['text/html; charset=utf-8', page],
  '/style.css': ['text/css', 'body{font:16px system-ui;margin:2rem}'],
  '/sw.js': ['text/javascript', sw],
  '/api/orders': ['application/json', '[{"id":1001,"total":"41.00"}]'],
  '/api/from-worker': ['text/plain', 'ok'],
};

createServer((req, res) => {
  console.log(`${new Date().toISOString().slice(11, 19)} ${req.method} ${req.url}`);
  const hit = routes[req.url];
  if (!hit) { res.writeHead(404).end('not found'); return; }
  res.setHeader('content-type', hit[0]);
  res.setHeader('cache-control', 'no-store');
  res.end(hit[1]);
}).listen(8791, () => console.log('serving http://localhost:8791/'));

Steps

  1. Step 1.

    Load the page once and read what the registration left behind.

    await s.goto('http://localhost:8791/');
    const reg = await navigator.serviceWorker.ready;      // inside s.page.evaluate
    const c = await caches.open('shell-v1');
    return {
      'registration.active.state': reg.active.state,
      'cache shell-v1': (await c.keys()).map((r) => new URL(r.url).pathname),
      'navigator.serviceWorker.controller': navigator.serviceWorker.controller?.scriptURL ?? null,
    };
    
    --- 1 first load, service worker registered on this load
    {
    "registration.active.state": "activated",
    "cache shell-v1": [
      "/",
      "/style.css"
    ],
    "navigator.serviceWorker.controller": null
    }

    The worker is active, the shell is cached, and this document is controlled by nobody.

  2. Step 2.

    Put the page offline and fetch two URLs, one in the cache and one not.

    await s.cdp.send('Network.enable');
    await s.cdp.send('Network.emulateNetworkConditions',
      { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
    
    --- 2 offline, page still uncontrolled
    {
    "navigator.onLine": false,
    "controller": "null",
    "fetch /style.css (cached)": "threw: Failed to fetch",
    "fetch /api/orders (not cached)": "threw: Failed to fetch"
    }

    The cached file failed as hard as the uncached one. Nothing reads the cache while controller is null.

  3. Step 3.

    Ask the worker to fetch something while the page is still offline.

    const reg = await navigator.serviceWorker.ready;
    reg.active.postMessage('go');   // the worker replies with the result of fetch('/api/from-worker')
    
    server | 20:53:52 GET /api/from-worker
    --- 3 the worker fetches while the page is offline
    "worker reached the network: ok"

    The server logged the request. Offline was set on the page target, and the worker has its own.

  4. Step 4.

    Reload so the worker controls the document, re-apply the same offline setting, and repeat the two fetches.

    await s.page.reload({ waitUntil: 'domcontentloaded' });
    await s.cdp.send('Network.emulateNetworkConditions', offline);
    
    server | 20:53:52 GET /api/orders
    --- 4 reload while offline, now controlled, emulation re-applied to the page
    {
    "reload status": 200,
    "navigator.onLine": false,
    "controller": "yes",
    "fetch /style.css (cached)": "200 body{font:16px system-ui;margin:2r",
    "fetch /api/orders (not cached)": "200 [{\"id\":1001,\"total\":\"41.00\"}]"
    }

    Read the server line first. navigator.onLine is false and /api/orders still arrived at the server, through the worker's fetch(e.request) fallback.

  5. Step 5.

    Put the worker's own target offline as well, then repeat the fetches unchanged.

    const swTarget = s.browser.targets().find((t) => t.type() === 'service_worker');
    const swCdp = await swTarget.createCDPSession();
    await swCdp.send('Network.enable');
    await swCdp.send('Network.emulateNetworkConditions', offline);
    
    --- 5 offline on the page and on the worker
    {
    "navigator.onLine": false,
    "controller": "yes",
    "fetch /style.css (cached)": "200 body{font:16px system-ui;margin:2r",
    "fetch /api/orders (not cached)": "threw: Failed to fetch"
    }

    This is the answer the check exists for: the shell is served, the data call is not.

  6. Step 6.

    Drop every emulation, stop the server, and run the same two fetches against a target that is genuinely gone.

    await swCdp.send('Network.emulateNetworkConditions', { ...offline, offline: false });
    await s.cdp.send('Network.emulateNetworkConditions', { ...offline, offline: false });
    server.kill();
    
    --- 6 server stopped, no emulation at all
    {
    "navigator.onLine": true,
    "controller": "yes",
    "fetch /style.css (cached)": "200 body{font:16px system-ui;margin:2r",
    "fetch /api/orders (not cached)": "threw: Failed to fetch"
    }

    The same verdict as step 5 and the opposite of step 4, with navigator.onLine now reporting true.

  7. Step 7.

    Reload with the server still stopped and use the page the way a reader would.

    const nav = await s.page.reload({ waitUntil: 'domcontentloaded' });
    document.getElementById('load').click();              // inside s.page.evaluate
    
    --- 7 the page after a reload with the server stopped
    {
    "reload status": 200,
    "h1": "Orders",
    "pre#out after clicking Load orders": "FAILED: Failed to fetch"
    }

    A 200 reload, a rendered heading, and a button that does nothing.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | controller: null on the document under test | The worker is registered and is not serving this load | Reload once, then start the offline test | | A data call returns 200 while the page is offline | The request left the machine through the worker | Put the worker's target offline too, or stop the server | | Cached URL 200, data call Failed to fetch | The page loads offline and does not work offline | Cache the call, queue it, or render an offline state | | Every fetch fails and navigator.onLine is true | The machine has a link and your server is unreachable | Read onLine as a hint, never as the result | | The reload itself fails offline | No worker serves the navigation request | Check the worker's scope and its fetch handler |

Common mistakes

Sign: Offline passes at the desk and the same build fails on a plane.Cause: Offline set on the page target leaves the service worker's network alone. In this capture the page reported navigator.onLine false while /api/orders reached the server through the worker's fetch fallback, and the server log proved it.
Sign: A page tests as broken offline immediately after the worker is deployed.Cause: The document that registers a worker is not controlled by it. Until the next navigation, navigator.serviceWorker.controller is null and every request goes to the network, so even a cached file fails.
Sign: A tester reports the application as online because navigator.onLine is true.Cause: onLine describes the machine's network interface, not whether your server answers. Step 6 shows it true while every request to the stopped server failed.
Sign: The Network panel shows a 200 for a request the server never received.Cause: Responses served from the HTTP cache or by the service worker are listed with the status they were stored with. The server access log is the only place that separates the two.

What to check next

FAQ

How to test that a page works offline?

Reload once so the worker controls the document, put the page and the worker offline, then use each control that calls the API. Loading is not the test. A screen that renders from cache while every action fails is the usual result.

Does the DevTools offline setting stop the service worker?

Not in this capture. With offline applied to the page target, the worker's own fetch reached the server, and the page then saw a 200 for a call it believed was offline. Confirm it on your build by watching the server log while offline is on.

Why does the page fail offline right after the worker registers?

The document that ran the registration is never controlled by it. navigator.serviceWorker.controller is null on that load, so the cache is not consulted and every request goes to the network. One reload fixes the test, not the application.

Is stopping the server the same as a real disconnection?

Close enough for this check, and better than emulation. Step 6 produced the same verdict as full offline emulation, with navigator.onLine true. Connection refused and no network both end as a rejected fetch in the page.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny