Playwright deep dive

context.setOffline(true) flips navigator.onLine in one line. The hard part is what comes next.

Every guide on Playwright offline testing stops at the line await context.setOffline(true). None of them tell you that the next line you usually reach for, await page.waitForLoadState('networkidle'), is impossible by definition once you have killed the network. This page is about the wait, not the flip. Below is the exact DOM-mutation strategy Assrt ships in agent.ts:941 so you can lift it into your own tests.

M
Matthew Diakonov
9 min read
4.8from real Playwright code, no proprietary YAML
Verified against /Users/matthewdi/assrt-mcp/src/core/agent.ts:941-994
MutationObserver polled every 500ms with a 2s default quiet window
Capped at stable_seconds <= 10s, timeout_seconds <= 60s
500ms

while (Date.now() - startMs < timeoutMs) { await new Promise((r) => setTimeout(r, 500)); ... }

/Users/matthewdi/assrt-mcp/src/core/agent.ts:963

That is the entire poll loop. Assrt's wait_for_stable tool sleeps 500ms, reads a mutation counter that lives on window.__assrt_mutations, and returns the moment the counter stops moving for the configured quiet window. Network state never enters the equation, which is exactly why it survives the moment you call context.setOffline(true).

Why every standard wait strategy breaks once you go offline

All four of the obvious choices have a failure mode that triggers specifically because the network is dead. The fifth card is the one Assrt ships and the rest of this page is about.

waitForLoadState('networkidle')

Defined as 500ms with at most 2 in-flight requests. A page that retries failed fetches keeps the in-flight count above 0 forever. After setOffline(true), this hangs until your test timeout fires.

page.waitForTimeout(500)

Flaky by construction. The offline event fires in 12ms on hot CI and 380ms on cold local. Half the time you assert too early, the other half you burn a budget you cannot afford across a full suite.

page.waitForSelector('text=Offline')

Works if your offline UI has one stable selector and never animates. Breaks the moment your designer adds a fade-in, swaps the wording for 'No connection', or A/B tests the banner copy.

page.waitForResponse(...)

Impossible by definition. There is no response to wait for. The whole point of setOffline(true) is that requests reject with net::ERR_INTERNET_DISCONNECTED.

wait_for_stable (Assrt MCP tool)

Injects a MutationObserver into document.body, polls window.__assrt_mutations every 500ms, returns when the count stops changing for a configurable quiet period. Network-agnostic, so flipping offline does not break it.

Naive vs DOM-stable offline test

Two test files that try to assert the same thing. The left one is what most teams write first. The right one is what they end up with after a week of flaky CI.

Same assertion, two waits

// The naive offline test
test("shows offline banner", async ({ page, context }) => {
  await page.goto("https://app.example.com");

  await context.setOffline(true);
  // navigator.onLine is now false
  // offline event fired on window
  // ... but is the banner on screen yet?

  // Option A: hangs forever, page keeps retrying
  await page.waitForLoadState("networkidle");

  // Option B: flaky, 12ms on CI, 380ms on cold start
  await page.waitForTimeout(500);

  await expect(page.getByText("You are offline")).toBeVisible();
});
-41% fewer lines

The right block is portable. You can paste it into any *.spec.ts without an Assrt runtime. Assrt just packages the same idea as a tool the agent can call any number of times in one scenario.

The actual wait_for_stable source

Lifted verbatim from /Users/matthewdi/assrt-mcp/src/core/agent.ts, lines 941 through 994. The interesting part is not the MutationObserver itself, it is that the observer lives on the page under window.__assrt_observer while the loop that times the quiet window lives in the agent process. Two sides of the CDP boundary, polled at 500ms.

assrt-mcp/src/core/agent.ts

Five steps inside one tool call

Each step maps to a specific block in the source above. Read them once and you can reimplement the whole thing from scratch.

1

Inject the observer

agent.ts:948 runs a page.evaluate that creates window.__assrt_mutations = 0 and a fresh MutationObserver bound to document.body with childList + subtree + characterData enabled. Captures every text change, every node added or removed, anywhere in the body subtree.

2

Poll the counter every 500ms

agent.ts:963 enters a while loop that sleeps 500ms, reads window.__assrt_mutations through CDP, and compares it to lastMutationCount. The 500ms cadence is the sweet spot: tight enough to catch fast UI changes, loose enough to give Chromium time to actually paint between samples.

3

Reset the quiet timer on any change

agent.ts:967 resets stableSince = Date.now() any time the mutation count moved between polls. The quiet window only counts uninterrupted silence. A single mutation halfway through a 2-second wait restarts the clock from zero.

4

Return on stableMs uninterrupted

agent.ts:970 breaks out of the loop the first time (Date.now() - stableSince) >= stableMs. Default stableMs = 2000. The page is declared stable, the observer is disconnected at agent.ts:976, and wait_for_stable resolves. A failure path returns 'Timed out after Xs (page still changing)' if timeoutMs hits first.

5

Clean up so nothing leaks

agent.ts:976 disconnects window.__assrt_observer and deletes both window.__assrt_mutations and window.__assrt_observer. The next wait_for_stable call starts from a clean slate, even on a long-lived page that runs dozens of offline checks in one session.

What flows through Assrt when a scenario says "go offline and check the banner"

Three inputs feed the same hub call, four artifacts come out the other side. The wait is part of the artifact, not a hidden step.

setOffline + wait_for_stable in one trace

Test step: 'verify offline UI'
Page is live, navigator.onLine = true
App may have retry loops, SW, WS
Assrt agent loop
context.setOffline(true) (CDP)
navigator.onLine = false on page
wait_for_stable returns when DOM is quiet
Assertion runs on the rendered offline UI

The numbers baked into the tool

All four are hardcoded constants in agent.ts. The defaults handle almost every offline UI. The caps stop a runaway scenario from blocking a CI worker.

0msms between mutation count polls
0ssecond default quiet window
0ssecond cap on stable_seconds
0ssecond cap on timeout_seconds
0
MutationObserver flags Assrt watches: childList, subtree, characterData.
0
Line of Playwright the offline flip emits: await page.context().setOffline(true);
0
Lines of proprietary YAML between you and Playwright. The trace is real *.spec.ts you can keep.

What a real run looks like

assrt-run.log

Notice the line Page stabilized after 1.4s (47 total mutations). That is the offline banner finishing its enter animation, the React state machine settling, and the retry queue going quiet, measured by counting node mutations. No fixed sleep. No networkidle. No selector race.

Stop reading about offline tests, just run one

npx @m13v/assrt test https://your-app.com -- the agent calls browser_network_state_set, then wait_for_stable, then asserts on the offline UI. Real Playwright code lands in /tmp/assrt/results/latest.json. Open-source, self-hosted, no $7.5K/mo cloud.

Run it

Related Playwright APIs you usually pair this with

page.context().setOffline()
browser.newContext({ offline: true })
page.evaluate()
MutationObserver
window.navigator.onLine
window.addEventListener('offline')
page.addInitScript()
Network.emulateNetworkConditions (CDP)

Frequently asked questions

Why does waitForLoadState('networkidle') hang after context.setOffline(true)?

Because networkidle is defined by Playwright as 500ms with no more than 2 in-flight network requests. After you flip the context to offline, the page is allowed to fire as many failing requests as it wants, and any retry loop on the page will keep that count above 0 indefinitely. networkidle was designed to wait for a network to settle, not for a network that is dead. The official docs even discourage it for new code. After setOffline(true), it is the wrong tool category.

Can I just use a fixed page.waitForTimeout instead?

You can, but it gets you a flaky test. The page might dispatch the offline event in 12ms on a hot CI runner and 380ms on a cold local container. A fixed 500ms wait either underwaits and asserts on a half-rendered banner or overwaits and burns a second on every test run. Multiplied across a CI suite that runs hundreds of offline assertions, that is real money. Assrt avoids fixed waits entirely. The wait_for_stable tool measures actual DOM activity instead.

What is wait_for_stable in Assrt?

An MCP tool exposed by the Assrt agent that injects a MutationObserver into the page, counts childList + subtree + characterData mutations, and returns once the count has not changed for a configurable quiet period. Source: /Users/matthewdi/assrt-mcp/src/core/agent.ts:941-994. The default is 2 seconds of quiet within a 30 second timeout. Both are capped: stable_seconds maxes at 10, timeout_seconds maxes at 60. The poll interval is hardcoded at 500ms.

Why MutationObserver and not requestIdleCallback or 'load' event?

requestIdleCallback measures main thread idle, which is unrelated to whether your offline UI has finished rendering. The 'load' event has already fired by the time you call setOffline. The only signal that genuinely tracks 'the offline UI has stopped changing' is the DOM diff itself. MutationObserver is the browser's native way to watch the DOM diff. Polling a counter every 500ms beats subscribing to the observer callback because it lets the test code stay synchronous and the timeout logic stays simple.

What does Assrt actually emit as Playwright code for an offline test?

Two real lines. await page.context().setOffline(true); for the network flip (emitted by the official @playwright/mcp browser_network_state_set tool), and a wait that you decide to translate as either await page.waitForSelector('text=You are offline') if you have a stable selector or a custom DOM-mutation poll if you do not. Assrt's run trace at /tmp/assrt/results/latest.json contains both, plus a screenshot of the page after navigator.onLine flipped to false.

Does navigator.onLine flip synchronously after await context.setOffline(true)?

Yes for the navigator.onLine getter on the page main world. No for arbitrary effects on the page. The promise resolves once Playwright has sent Network.emulateNetworkConditions { offline: true } over CDP and Chromium has acknowledged it. Your offline event listener fires shortly after, on the next tick. But your React component that re-renders to show an offline banner depends on your own state machine and could take 10ms or 300ms. wait_for_stable is what bridges that gap.

Can I run wait_for_stable inside a vanilla Playwright test?

Yes, the technique is portable. Inject window.__mut_count = 0 and a MutationObserver in a page.evaluate, then poll page.evaluate(() => window.__mut_count) on a setInterval until the value stabilizes for your quiet period. Assrt ships this as a tool because the agent needs a deterministic 'is the page done reacting' signal, but you can lift the same 50 lines of logic out of agent.ts:941-994 into a helper and use it in your own *.spec.ts. No Assrt runtime required.

Will service workers see the offline flip during wait_for_stable?

Not unless you patched it via addInitScript before SW registration. context.setOffline(true) flips the page target's offline flag in CDP but historically does not propagate to the service worker target (microsoft/playwright#2311). wait_for_stable measures DOM mutations regardless. If your offline UI depends on the SW returning a cached page, the SW will return the live response, and wait_for_stable will return successfully because the DOM did stabilize, just not in the offline state you expected. This is a Playwright gap, not an Assrt one.

assrtOpen-source AI testing framework
© 2026 Assrt. MIT License.

How did this page land for you?

Comments

Public and anonymous. No signup.

Loading…