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
- Node 22 and Chrome 120 or later.
npm i puppeteer-coredrives the Chrome already on the machine. Theopen()launcher used below is printed in full in How to check if a service worker is registered. - The figures here are one capture, Chrome 152.0.7977.76 on Windows 11, on 2026-09-12. Statuses and failure messages are stable across builds, timings are not.
- A local site whose worker caches the shell and nothing else. Save this as
offline-demo.mjs. The driver starts and stops it, so no port is left behind.
// 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/'));
- A driver,
offline-run.mjs, assembled from the blocks below. It starts the server withspawn(process.execPath, ['offline-demo.mjs'])and kills that one child at the end. Lines markedserver |are the demo server's log. - Network.emulateNetworkConditions is the command behind the Offline entry in the DevTools throttling menu.
Steps
- 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.
- 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
controlleris null. - 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.
- 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.onLineis false and/api/ordersstill arrived at the server, through the worker'sfetch(e.request)fallback. - 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.
- 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.onLinenow reporting true. - 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
What to check next
- How to check if a service worker is registered: the four registration states, one of which explains a failed offline test.
- How to check what a service worker has cached: read the entries, because the cache decides which controls survive offline.
- How to check if service worker is updated: a stale worker caches the previous shell and passes this test with old code.
- How to check web manifest: the manifest claims installability, the worker delivers offline, and they are checked apart.
- How to check console errors on a website: failed offline requests land there first, with the URL that failed.
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.
Related on this site
intermediate8 minpublished updated Maks Verny