Repository navigation
Description
Version
1.63.0
Steps to reproduce
browserContext.storageState() collects localStorage by navigating a throwaway page to every visited origin. That navigation is meant to be inert — the page gets a prepended request interceptor that fulfills every request with an empty document. But request interception does not apply to responses served by a service worker, so for an origin with an active worker the site's own JS runs during the capture. If that JS navigates (a signed-out app redirecting to its sign-in page is the common case), the redirect aborts the capture and storageState() rejects.
This script is self-contained: two local origins, the first one installing a worker whose fetch handler serves a shell that redirects on load.
import http from 'node:http'
import { chromium } from 'playwright'
// Origin A: a site that installs a service worker. The worker serves a cached app shell for
// navigations to "/", and that shell redirects — what a signed-out app commonly does.
const shell =
'<html><body>SHELL<script>addEventListener("load", () => { location.href = "/signed-out" })</scr' +
'ipt></body></html>'
const a = http.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://x')
if (pathname === '/sw.js')
return res.writeHead(200, { 'content-type': 'text/javascript' }).end(`
self.addEventListener('install', (e) => e.waitUntil(self.skipWaiting()))
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate' || new URL(event.request.url).pathname !== '/') return
event.respondWith(new Response(${JSON.stringify(shell)}, { headers: { 'content-type': 'text/html' } }))
})`)
if (pathname === '/signed-out') return res.writeHead(200, { 'content-type': 'text/html' }).end('<html>bye</html>')
res.writeHead(200, { 'content-type': 'text/html' }).end(
'<html><body><script>localStorage.setItem("a", "1"); navigator.serviceWorker.register("/sw.js")</script></body></html>',
)
})
// Origin B: any second origin the context also visited.
const b = http.createServer((_req, res) =>
res
.writeHead(200, { 'content-type': 'text/html' })
.end('<html><body><script>localStorage.setItem("b", "1")</script></body></html>'),
)
await new Promise((r) => a.listen(0, '127.0.0.1', r))
await new Promise((r) => b.listen(0, '127.0.0.1', r))
const A = `http://localhost:${a.address().port}`
const B = `http://localhost:${b.address().port}`
const browser = await chromium.launch()
// Pass { serviceWorkers: 'block' } here and storageState() succeeds.
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(A) // installs the worker
// wait for the worker to control the origin (resolves immediately; never resolves when workers are blocked)
await page.evaluate(() =>
Promise.race([navigator.serviceWorker.ready.then(() => undefined), new Promise((resolve) => setTimeout(resolve, 3000))]),
)
await page.goto(B)
await page.goto('about:blank') // leave both origins, so storageState() must visit them itself
try {
const state = await context.storageState()
console.log('storageState OK, origins =', JSON.stringify(state.origins.map((o) => o.origin)))
} catch (error) {
console.log('storageState FAILED:', error.message.split('\n')[0])
}
await browser.close()
a.close()
b.close()Expected behavior
storageState() returns the localStorage of both origins without running either origin's code — which is what the empty-document interceptor on the internal page is there to guarantee.
storageState OK, origins = ["http://localhost:50369","http://localhost:50370"]
That is what happens with newContext({ serviceWorkers: 'block' }) (2 runs out of 2), and the localStorage of both origins is still captured correctly.
Actual behavior
The worker serves the probe navigation, the shell's load handler redirects, and the capture is aborted (3 runs out of 3):
storageState FAILED: browserContext.storageState: Execution context was destroyed, most likely because of a navigation
This is Chromium-specific. Running the same script on Firefox and WebKit, the worker activates and serves navigations there too (a second page.goto(A) returns the worker's shell), yet storageState() succeeds on both — so their capture paths are not exposed to service workers, only Chromium's is.
Possible fix
Make the internal storage-state page genuinely inert by bypassing service workers for it, rather than relying only on request interception. Chromium has Network.setBypassServiceWorker, which does not appear to be used anywhere in the codebase. I verified the mechanism from the outside on a page I own: with a page.route('**/*') fulfilling every request, the active worker still served its own redirecting content, while adding
const session = await context.newCDPSession(page)
await session.send('Network.enable')
await session.send('Network.setBypassServiceWorker', { bypass: true })made the route win: the same navigation got the route's document and no longer redirected.
Since Firefox and WebKit are not affected, fixing the Chromium probe would resolve the whole issue.
Workarounds today, for anyone landing here
All three make storageState() succeed with every origin's localStorage intact (each verified 3 runs out of 3 on 1.63.0).
1. Block service workers for the whole context. One line, but it changes the browser the tests run in rather than only the internal probe — the site under test (or the third party) no longer behaves as in production:
const context = await browser.newContext({ serviceWorkers: 'block' })2. Unregister the workers via CDP, just before the capture. Workers stay enabled while the tests run; they are only removed once the context is about to be captured. Relies on context.serviceWorkers() and on the default scope being the script's directory:
const cdp = await context.newCDPSession(page)
await cdp.send('ServiceWorker.enable')
for (const worker of context.serviceWorkers())
await cdp.send('ServiceWorker.unregister', { scopeURL: new URL('.', worker.url()).href })
await cdp.send('ServiceWorker.stopAllWorkers')
await cdp.detach()
const state = await context.storageState()3. Unregister the workers from inside the page, just before the capture. Same timing as 2, no CDP — only web-standard APIs, so it also works where newCDPSession is unavailable. A throwaway page visits each origin the context has seen (the caller has to track those): the route keeps origins without a worker off the network entirely, and the init script unregisters registrations at document start, before the worker-served shell's own scripts can redirect:
const cleanupPage = await context.newPage()
await cleanupPage.route('**/*', (route) => route.fulfill({ body: '<html></html>', contentType: 'text/html' }))
await cleanupPage.addInitScript(() => {
navigator.serviceWorker
?.getRegistrations()
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
})
for (const origin of visitedOrigins) {
await cleanupPage.goto(origin, { waitUntil: 'commit' }).catch(() => undefined)
// the worker-served page may still navigate once; waitForFunction survives that and
// settles once a document confirms no registration is left
await cleanupPage.waitForFunction(async () => (await navigator.serviceWorker.getRegistrations()).length === 0)
}
await cleanupPage.close()
const state = await context.storageState()Environment
- Playwright 1.63.0 (bundled Chrome Headless Shell 153.0.8010.12)
- Node v24.18.1
- macOS 26.6.2, arm64
Investigated and written with LLM assistance (Claude Code). The repro script above was run on the environment listed here — 3 failing runs with the default context, 2 passing runs with serviceWorkers: 'block' — and the Network.setBypassServiceWorker finding was verified the same way rather than inferred.
Activity
chore: mark v0.1.21 (#469)
chore: mark v0.0.82 (#1764)
chore(deps): update all non-major dependencies (#169)
chore(deps): update dependency @playwright/mcp to v0.0.82 (#1011)
chore(deps): update dependency @playwright/mcp to v0.0.82 (#3175)
chore: mark v0.0.82 (microsoft#1764)
Update agent CLIs