**Approach (brief)**Wait for page "stability" by observing DOM mutations (MutationObserver) and network activity (intercepting/fetching requests). Resolve when both have been quiet for configured quiescence windows or when a global timeout fires. Ignore background service-worker and long-polling by whitelist/heuristics (e.g., stable connections allowed, or require inactivity > longer window).typescript
// TypeScript (works with Playwright or Puppeteer Page-like API)
type Options = {
domQuietMs?: number; // e.g. 500
networkQuietMs?: number; // e.g. 500
timeoutMs?: number; // e.g. 10000
ignoreUrlPatterns?: RegExp[]; // long-poll/service-worker URLs
};
export async function waitForStableState(page: any, opts: Options = {}) {
const { domQuietMs = 500, networkQuietMs = 500, timeoutMs = 10000, ignoreUrlPatterns = [] } = opts;
return new Promise<void>(async (resolve, reject) => {
const start = Date.now();
let domTimer: NodeJS.Timeout | null = null;
let netTimer: NodeJS.Timeout | null = null;
let outstanding = new Set<string>();
let finished = false;
function cleanup() {
finished = true;
if (domTimer) clearTimeout(domTimer);
if (netTimer) clearTimeout(netTimer);
try { page.off && page.off('request', onRequest); page.off && page.off('requestfinished', onRequestFinished); page.off && page.off('requestfailed', onRequestFinished); } catch {}
// remove injected observer
page.evaluate(() => { (window as any).__stopMutationObserver && (window as any).__stopMutationObserver(); }).catch(()=>{});
}
function checkDone() {
if (finished) return;
if (outstanding.size === 0 && !domTimer && !netTimer) {
cleanup();
resolve();
}
}
// network tracking
function isIgnored(url: string) {
return ignoreUrlPatterns.some(r => r.test(url));
}
function onRequest(req: any) {
if (isIgnored(req.url())) return;
outstanding.add(req._requestId || req.url() + Math.random());
if (netTimer) { clearTimeout(netTimer); netTimer = null; }
}
function onRequestFinished(req: any) {
if (isIgnored(req.url())) return;
// remove one matching id if present
for (const id of outstanding) { if (id.includes(req.url().slice(0,50))) { outstanding.delete(id); break; } }
if (outstanding.size === 0) {
netTimer = setTimeout(() => { netTimer = null; checkDone(); }, networkQuietMs);
}
}
page.on && page.on('request', onRequest);
page.on && page.on('requestfinished', onRequestFinished);
page.on && page.on('requestfailed', onRequestFinished);
// DOM MutationObserver injected into page
await page.evaluate(({ domQuietMs }) => {
const win = window as any;
if (win.__stopMutationObserver) win.__stopMutationObserver();
let timer: any = null;
const notify = () => {
(window as any).__notifyMutation && (window as any).__notifyMutation();
};
const obs = new MutationObserver(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(notify, domQuietMs);
});
obs.observe(document, { childList: true, subtree: true, attributes: true, characterData: true });
win.__stopMutationObserver = () => { obs.disconnect(); if (timer) clearTimeout(timer); delete win.__stopMutationObserver; };
}, { domQuietMs });
// bridge: page.exposeFunction called once to receive notifications
await page.exposeFunction && page.exposeFunction('__notifyMutation', () => {
if (domTimer) clearTimeout(domTimer);
domTimer = setTimeout(() => { domTimer = null; checkDone(); }, domQuietMs);
});
// initialize timers: if no outstanding and no initial DOM changes, set timers
netTimer = setTimeout(() => { netTimer = null; checkDone(); }, networkQuietMs);
domTimer = setTimeout(() => { domTimer = null; checkDone(); }, domQuietMs);
// global timeout
const global = setTimeout(() => {
if (finished) return;
cleanup();
reject(new Error(`waitForStableState timeout after ${timeoutMs}ms (elapsed ${Date.now()-start}ms)`));
}, timeoutMs);
});
}
**Edge cases & handling**- Long-polling / SSE: use ignoreUrlPatterns to exclude known endpoints; otherwise require longer networkQuietMs.- Service workers/background fetches: ignore requests to service-worker scope or patterns; do not count navigator.serviceWorker messages.- Iframes: MutationObserver on top document won't see cross-origin iframe internals; for same-origin iframes, inject separate observers.- Flaky tests: use conservative defaults (500ms–1s) and allow callers to tune per test.**Complexity & notes**- Lightweight; cost equals event listeners + small injected script.- Ensure page.exposeFunction available (Playwright/Puppeteer); adapt to evaluateOnNewDocument for persistent behavior in SPA navigations.