← Back to microsoft/playwright
microsoft / playwright · Issue No. 42775
1.63.0
After a service worker is active and controlling the page, browserContext.setOffline(true) causes a subsequent navigation to fail in WebKit. This also happens when the navigation handler returns a literal HTML Response without making a network request. The same standalone reproduction passes in Chromium in the same Linux container.
The complete synthetic reproduction is included below. It uses only Node.js, @playwright/test, and an ephemeral loopback HTTP server; no application or framework is required.
package.json, install @playwright/test@1.63.0. Ensure the matching WebKit and Chromium browsers and their Linux dependencies are installed. The measured runs used the official Playwright container listed under Environment.Minimal Offline Diagnostic -26-0918 -v1.cjs in that directory.node "Minimal Offline Diagnostic -26-0918 -v1.cjs" webkit "/tmp/webkit-offline-repro-unique"
node "Minimal Offline Diagnostic -26-0918 -v1.cjs" chromium "/tmp/chromium-offline-repro-unique"
The driver runs each combination once, without retries or skips. Each case gets a fresh browser context and loopback server. For service-worker cases, it registers the worker, waits for readiness, reloads, then verifies both a controller and the cached offline document before applying the disruption.
It compares context.setOffline(true) while the origin is still listening against stopping the origin server without setting the offline flag. The latter is a server-unavailability control, not a claim that both disruptions emulate identical network conditions.
For each service-worker case it then navigates to /uncached-target and requires status 200, response.fromServiceWorker() === true, and the heading Synthetic offline fallback. The no-worker cases are negative controls that must fail navigation.
// Standalone synthetic, loopback-only service-worker diagnostic.
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs/promises');
const path = require('node:path');
const { createRequire } = require('node:module');
const projectRequire = createRequire(path.join(process.cwd(), 'package.json'));
const playwright = projectRequire('@playwright/test');
const browserName = process.argv[2];
const outputDirectory = process.argv[3];
assert(['chromium', 'webkit'].includes(browserName), 'Specify chromium or webkit');
assert(outputDirectory && path.isAbsolute(outputDirectory), 'Specify a new absolute output directory');
const offlineHtml = '<!doctype html><meta charset="utf-8"><h1>Synthetic offline fallback</h1>';
const modes = ['no-worker', 'literal', 'cache-only', 'network-fallback', 'preload-fallback'];
function workerSource(mode) {
const response = mode === 'literal'
? `Promise.resolve(new Response(${JSON.stringify(offlineHtml)}, {headers:{'Content-Type':'text/html'}}))`
: mode === 'cache-only'
? "caches.match('/offline')"
: mode === 'network-fallback'
? "fetch(event.request).catch(() => caches.match('/offline'))"
: "(async () => { try { return await event.preloadResponse || await fetch(event.request); } catch { return caches.match('/offline'); } })()";
return `
self.addEventListener('install', event => event.waitUntil((async () => {
const cache = await caches.open('synthetic-offline-v1');
await cache.add('/offline'); await self.skipWaiting();
})()));
self.addEventListener('activate', event => event.waitUntil((async () => {
${mode === 'preload-fallback' ? 'if (self.registration.navigationPreload) await self.registration.navigationPreload.enable();' : ''}
await self.clients.claim();
})()));
self.addEventListener('fetch', event => {
if (event.request.mode !== 'navigate') return;
event.respondWith(${response});
});`;
}
async function caseRun(browser, mode, outage) {
const requests = [];
const server = http.createServer((request, response) => {
requests.push(request.url);
response.setHeader('Cache-Control', 'no-store');
if (request.url === '/sw.js') {
response.writeHead(200, { 'Content-Type': 'application/javascript' });
response.end(workerSource(mode));
} else {
response.writeHead(200, { 'Content-Type': 'text/html' });
response.end(request.url === '/offline' ? offlineHtml : '<!doctype html><h1>Synthetic origin online</h1>');
}
});
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
const origin = `http://127.0.0.1:${server.address().port}`;
const stopOrigin = () => new Promise((resolve, reject) => {
if (!server.listening) return resolve();
server.close(error => error ? reject(error) : resolve());
server.closeAllConnections();
});
const context = await browser.newContext({ serviceWorkers: 'allow' });
context.setDefaultTimeout(10_000);
const page = await context.newPage();
const events = [];
page.on('requestfailed', request => events.push({ type: 'requestfailed', url: request.url(), error: request.failure()?.errorText }));
page.on('console', message => { if (message.type() === 'error') events.push({ type: 'console', text: message.text() }); });
const result = { mode, outage, origin, expected: mode === 'no-worker' ? 'network-failure' : 'offline-fallback' };
try {
await page.goto(origin);
if (mode !== 'no-worker') {
await page.evaluate(async () => { await navigator.serviceWorker.register('/sw.js'); await navigator.serviceWorker.ready; });
await page.reload();
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
result.preconditions = await page.evaluate(async () => ({
controlled: Boolean(navigator.serviceWorker.controller),
offlineCached: Boolean(await caches.match('/offline')),
preloadSupported: Boolean((await navigator.serviceWorker.ready).navigationPreload),
}));
assert(result.preconditions.controlled && result.preconditions.offlineCached);
}
if (outage === 'setOffline') await context.setOffline(true);
else { await stopOrigin(); assert.equal(server.listening, false); }
result.originListeningBeforeNavigation = server.listening;
const requestsBefore = requests.length;
try {
const response = await page.goto(`${origin}/uncached-target`, { timeout: 10_000 });
result.navigation = { status: response?.status(), fromServiceWorker: response?.fromServiceWorker(), error: null };
result.heading = await page.locator('h1').textContent();
} catch (error) {
result.navigation = { error: error.message };
}
result.originRequestsDuringOutage = requests.slice(requestsBefore);
result.passed = mode === 'no-worker'
? Boolean(result.navigation.error)
: !result.navigation.error && result.navigation.status === 200 && result.navigation.fromServiceWorker === true && result.heading === 'Synthetic offline fallback';
} catch (error) {
result.setupError = error.message;
result.passed = false;
} finally {
result.events = events;
result.requests = requests;
await context.close();
await stopOrigin();
}
return result;
}
async function main() {
await fs.mkdir(outputDirectory); // Exclusive new leaf; never replace retained results.
const browser = await playwright[browserName].launch({ headless: true });
const summary = {
createdAt: new Date().toISOString(), node: process.version,
platform: process.platform, playwright: projectRequire('@playwright/test/package.json').version,
browser: browserName, browserVersion: browser.version(),
scope: 'Minimal synthetic service-worker diagnostic without an application framework',
results: [],
};
try {
for (const outage of ['setOffline', 'origin-stopped']) {
for (const mode of modes) {
const result = await caseRun(browser, mode, outage);
summary.results.push(result);
console.log(JSON.stringify({ browser: browserName, mode, outage, passed: result.passed, error: result.navigation?.error ?? result.setupError ?? null }));
}
}
} finally {
await browser.close();
summary.passed = summary.results.length === modes.length * 2 && summary.results.every(result => result.passed);
await fs.writeFile(path.join(outputDirectory, 'Diagnostic Results -26-0918 -v1.json'), JSON.stringify(summary, null, 2), { flag: 'wx' });
}
process.exitCode = summary.passed ? 0 : 1;
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
Offline emulation should allow an active service worker to fulfill a navigation without a network request. In particular, the literal and cache-only navigation handlers should return their local responses. A failed network request in the fallback variants should allow the worker's cached response to be used.
In WebKit, all four service-worker variants fail after context.setOffline(true) with:
page.goto: WebKit encountered an internal error
All four WebKit variants succeed when the origin server is stopped instead. Chromium succeeds in all eight service-worker cases in the same container. Both browsers correctly fail the two no-worker negative controls.
| Worker response | WebKit: setOffline(true) |
WebKit: origin stopped | Chromium: setOffline(true) |
Chromium: origin stopped |
|---|---|---|---|---|
| no-worker | Expected network failure | Expected network failure | Expected network failure | Expected network failure |
| literal | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| cache-only | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| network-fallback | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
| preload-fallback | Internal error | 200, from service worker | 200, from service worker | 200, from service worker |
Measured totals: WebKit 6/10 expectations passed, 4 failed (process exit 1); Chromium 10/10 expectations passed (process exit 0). These totals include the expected failures of the no-worker controls. In all service-worker cases, the pre-navigation checks reported controlled: true and offlineCached: true.
The literal variant creates a new HTML Response directly in the fetch handler. The cache-only variant uses only caches.match('/offline') during navigation. This makes the failure reproducible without a framework or a network-first caching strategy.
The reproduction writes a JSON result file into the requested output directory, including preconditions, navigation results, request failures, and origin requests. The publication copy changes only the original diagnostic's introductory comment and scope label; its executed browser logic is unchanged.
While inspecting the pinned Playwright WebKit patch, I found an early offline failure in the resource-loading path: bootstrap.diff at the v1.63.0 source commit. This may be relevant, but the reproduction does not establish the precise internal cause.
Is this an unsupported WebKit offline-emulation case, or should the offline flag permit service-worker fulfillment and fail only requests that actually require the network?
Manually recorded from the measured runs (not an envinfo dump):
Playwright / @playwright/test: 1.63.0
Official container: mcr.microsoft.com/playwright:v1.63.0-noble
Container image digest: sha256:eff16c30e6f3f4af0a03fa4b706120d5e9b0891c344a27d64559aff5900a4a27
OS inside container: Ubuntu 24.04.4
Node.js inside container: 24.20.0
WebKit: 26.6, Playwright browser revision 2359
Chromium comparison: 153.0.8010.12, Playwright browser revision 1243
Headless browsers, non-root user, fresh contexts, loopback origins
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.