A page that runs for (;;) {} is a small test, but it shows which of Playwright's guards you can rely on in a screenshot worker and which you cannot. I ran it against Playwright 1.63.0 (chrome-headless-shell, Chromium 153.0.8010.12) on Node v24.22.3, Linux, with local fixture pages only. This article covers what each call does when the page locks up, hangs while loading, or crashes. It ends with a per-job wrapper that always returns in a bounded time and leaves nothing running.

The fixtures

One small server with four pages, plus a probe that reports which Chromium processes are using CPU. It samples /proc/<pid>/stat for one second, so it is Linux only.

// lib.cjs const http = require('http'); const fs = require('fs'); async function startServer() { const server = http.createServer((req, res) => { res.setHeader('content-type', 'text/html'); if (req.url === '/loop') res.end('<h1>busy</h1><script>setTimeout(() => { for (;;) {} }, 100)</script>'); else if (req.url === '/hang') res.write('<h1>half a page</h1>'); // headers sent, body never ends else if (req.url === '/silent') { /* never answers */ } else res.end('<h1>ok</h1>'); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); return { base: `http://127.0.0.1:${server.address().port}`, close() { server.closeAllConnections(); server.close(); }, }; } function ticks() { const out = {}; for (const d of fs.readdirSync('/proc')) { if (!/^\d+$/.test(d)) continue; try { const f = fs.readFileSync(`/proc/${d}/stat`, 'utf8').split(')').pop().trim().split(' '); out[d] = { ppid: +f[1], t: +f[11] + +f[12] }; // utime + stime, 100 ticks = 1 s of one core } catch {} } return out; } // How many processes below this Node process used more than half a core during the next second async function spinning() { const a = ticks(); await new Promise((r) => setTimeout(r, 1000)); const b = ticks(); const mine = new Set([process.pid]); for (let grew = true; grew;) { grew = false; for (const [p, r] of Object.entries(b)) if (!mine.has(+p) && mine.has(r.ppid)) { mine.add(+p); grew = true; } } return [...mine].filter((p) => p !== process.pid && a[p] && b[p].t - a[p].t > 50).length; } module.exports = { startServer, spinning }; 

/loop locks up the page's main thread 100 ms after it loads. /hang sends half a document and never finishes the body. /silent never answers. A crash is simulated later.

Case 1: the page is stuck in a loop

page.screenshot() has a timeout, so I expected it to be enough. page.evaluate() is a different story: its section in the Page API docs lists no timeout option. This script tries both with default settings:

// naive.cjs const { chromium } = require('playwright'); const { startServer } = require('./lib.cjs'); (async () => { const srv = await startServer(); const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(srv.base + '/loop'); await page.waitForTimeout(400); // the loop starts 100 ms after load const t0 = Date.now(); const tick = setInterval(() => console.log(` still waiting after ${Math.round((Date.now() - t0) / 1000)} s`), 15000); const step = process.argv[2]; try { if (step === 'evaluate') await page.evaluate(() => document.title); else await page.screenshot(); console.log(`${step} returned after ${Date.now() - t0} ms`); } catch (e) { console.log(`${step} threw after ${Date.now() - t0} ms: ${e.message.split('\n')[0]}`); } clearInterval(tick); await browser.close(); srv.close(); })(); 

I ran each under timeout 45:

$ timeout 45 node naive.cjs evaluate still waiting after 15 s still waiting after 30 s evaluate threw after 42538 ms: page.evaluate: Target page, context or browser has been closed exit code 124 $ timeout 45 node naive.cjs screenshot still waiting after 15 s screenshot threw after 30018 ms: page.screenshot: Timeout 30000ms exceeded. exit code 0 

The screenshot gives up after its default 30 s. The evaluate() call was still waiting when timeout sent SIGTERM, and it only threw because Playwright closed the browser on the way out. A worker that calls evaluate() on a user-supplied page with no deadline of its own can block that job indefinitely.

A timeout does not stop the page

When a call times out, you may assume the problem went away. It did not:

// after-timeout.cjs const { chromium } = require('playwright'); const { startServer, spinning } = require('./lib.cjs'); (async () => { const srv = await startServer(); const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto(srv.base + '/loop'); await page.waitForTimeout(400); let t = Date.now(); await page.screenshot({ timeout: 3000 }).catch((e) => console.log(`screenshot: ${e.name} after ${Date.now() - t} ms`)); console.log('processes burning CPU after the timeout:', await spinning()); t = Date.now(); await page.close(); console.log(`page.close() took ${Date.now() - t} ms`); console.log('processes burning CPU after page.close():', await spinning()); await browser.close(); srv.close(); })(); 
screenshot: TimeoutError after 3006 ms processes burning CPU after the timeout: 1 page.close() took 21 ms processes burning CPU after page.close(): 0 

The renderer process kept running at full speed after the TimeoutError, and stopped only when the page was closed. If your code catches the timeout and moves on without closing the page or context, that renderer keeps a core busy.

Hangs while loading, and crashes

Two more results from my first probe script. They are not in the final code, so treat them as observations.

  • With the default waitUntil: 'load', page.goto() on /hang threw a TimeoutError at the set limit (3 s). A screenshot() right after that returned an image, as did goto with waitUntil: 'commit'. The same happened for a page whose image never finishes loading: load timed out, domcontentloaded returned in about 30 ms, and the screenshot worked. So whether to keep a partial page is your choice, not something Playwright decides.
  • A crashed page fires the crash event, and the docs say ongoing and subsequent operations then throw. In my run, a screenshot() after the crash threw Target crashed after 616 ms and evaluate() threw the same error after 3 ms. The browser itself was fine: a new page in the same browser loaded and screenshotted normally.

One deadline per job

The pattern I settled on has three parts. Race the whole job against a single deadline, because separate per-call timeouts add up and evaluate() has none. Reject at once on the crash event. Close the context in finally; the docs say it closes every page in it, and a reason option is reported to operations it interrupts.

// capture.cjs const { chromium } = require('playwright'); const { startServer, spinning } = require('./lib.cjs'); async function capture(browser, url, { deadlineMs = 5000, onPage } = {}) { const context = await browser.newContext({ viewport: { width: 800, height: 600 } }); const page = await context.newPage(); if (onPage) onPage(page, context); // test hook, not needed in production let timer; const failFast = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`deadline ${deadlineMs} ms`)), deadlineMs); page.once('crash', () => reject(new Error('page crashed'))); }); const work = (async () => { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: deadlineMs }); await page.waitForTimeout(400); // stands in for "wait for the app to settle" await page.evaluate(() => document.fonts.ready.then(() => document.title)); // never returns on a busy page return page.screenshot({ type: 'png' }); })(); work.catch(() => {}); // the race reports the error; avoid an unhandled rejection after it try { return await Promise.race([work, failFast]); } finally { clearTimeout(timer); await context.close().catch(() => {}); // also stops a spinning renderer } } async function run(browser, label, url, opts) { const t0 = Date.now(); let result; try { result = (await capture(browser, url, opts)).length > 1000 ? 'png ok' : 'png tiny'; } catch (e) { result = e.message.split('\n')[0].slice(0, 60); } console.log(`${label.padEnd(10)} ${String(Date.now() - t0).padStart(5)} ms ${result}`); } (async () => { const srv = await startServer(); const browser = await chromium.launch(); for (const path of ['/ok', '/loop', '/hang', '/silent']) await run(browser, path, srv.base + path); // Page.crash is a Chromium DevTools command; it stands in for a page that ran out of memory await run(browser, 'crash', srv.base + '/ok', { onPage: (page, context) => page.once('load', async () => { const cdp = await context.newCDPSession(page); setTimeout(() => cdp.send('Page.crash').catch(() => {}), 150); }), }); console.log('--- a busy job and a normal job at the same time, same browser'); await Promise.all([run(browser, 'busy job', srv.base + '/loop'), run(browser, 'ok job', srv.base + '/ok')]); console.log('processes burning CPU afterwards:', await spinning()); await browser.close(); srv.close(); })(); 

Output from one run (milliseconds differ a little between runs):

/ok 781 ms png ok /loop 5093 ms deadline 5000 ms /hang 5088 ms deadline 5000 ms /silent 5103 ms deadline 5000 ms crash 622 ms page crashed --- a busy job and a normal job at the same time, same browser ok job 605 ms png ok busy job 5133 ms deadline 5000 ms processes burning CPU afterwards: 0 

Every bad case ended at the deadline (5 s here) or sooner, the crash took 0.6 s, and no process was left burning CPU. The /loop job, which was still stuck inside evaluate() when the deadline hit, was released by context.close(). The normal job that ran next to the busy one in the same browser finished normally (605 ms, against 781 ms for the first /ok job, which ran alone). An earlier run gave 818 ms against 590 ms, so the difference is noise at this sample size, and I only tried one neighbour.

Page.crash is a Chromium DevTools command (protocol reference). I used it as a stand-in. A real out-of-memory crash may behave differently, and I did not try one on this machine because it also runs other services.

Limits

  • Only Chromium, headless shell, one machine. I did not run Firefox or WebKit.
  • I did not test a frozen browser process, only a frozen page. A long-lived worker should have an outer guard as well, for example a supervisor that kills the process if jobs stop finishing.
  • A memory limit on the container or service (for example docker run --memory) is the usual way to stop a page that allocates without end. I did not test one here.
  • The 5 s deadline is arbitrary. Pick it from your slowest legitimate page, and log the URLs that hit it.

Written with AI assistance. Every code sample was run on 2026-11-12 with Playwright 1.63.0 (Chromium 153.0.8010.12, chrome-headless-shell) on Node v24.22.3; the output above is from that run.