Interrupt handlers for web automation
Every browser-automation script is written as if the web were deterministic:
await page.goto('https://shop.example')
await page.locator('#search').fill('gravel bike')
await page.locator('#go').click()
And every browser-automation script eventually dies, because the web is not. A cookie banner appears on the second run but not the first. A "subscribe to our newsletter" modal shows up after 20 seconds, but only for EU IPs. The session expires mid-run and a login wall swallows a click. A chat widget slides over the button you're about to press.
The failure mode is always the same: the problem is asynchronous, but the script is linear. The banner doesn't appear at a fixed step — it appears at a fixed time, or on a cookie condition, or on a backend whim. There is no correct place in the sequence to put the if (banner) dismiss() check.
The standard workarounds are all bad:
- Sprinkle checks between every step. Now every script is 60% banner-handling boilerplate, and the banner can still appear during a slow step.
- Dismiss it once at the start. Works until it reappears.
- Retry the whole script on failure. Turns a 200ms problem into a 45-second one, and isn't idempotent if you were halfway through a checkout.
What you actually want is the thing operating systems solved decades ago: an interrupt handler. Register it once, let the main program run, and when the condition fires, handle it and return control.
In Orchestra (a desktop app for building browser automations — the details generalize) that primitive is called a Cue: a step that doesn't run in sequence at all. It arms a watcher, and while the rest of the script executes, the watcher polls. The moment its condition is true, its child steps fire — dismiss the banner, re-login, whatever — and the main flow continues from wherever it was.
What can trigger a cue
Two families, because they need different mechanics:
Polled conditions — checked on a background tick (~150ms):
- Selector exists —
page.locator(sel).count() > 0 - Element visible — matched element is actually displayed (a hidden cookie banner shouldn't fire the handler)
- URL matches — regex against
page.url() - Page contains text — needle in
bodyinnerText
Event conditions — captured by real listeners, buffered, and drained one event per fire:
- Network response — URL regex plus a status filter like
401,403,500-599 - Page / console error —
pageerrororconsole.errormatching a regex - URL changed — fires once per transition, not continuously while the URL matches
The buffered-event design matters. A polled check for "was there a 401?" is racy — the response comes and goes between ticks. So response/error cues attach Playwright listeners that push into a bounded buffer (capped at 100), and each fire consumes exactly one event:
const handler = (r: Response) => {
if (!re.test(r.url())) return
if (!matchesStatusFilter(r.status(), ranges)) return
entry.events.push({ at: Date.now(), kind: 'response', summary: `${r.status()} ${r.url()}` })
if (entry.events.length > EVENT_BUFFER_LIMIT) entry.events.shift()
}
page.on('response', handler)
Three 401s while the handler was busy → three fires, in order, no losses (up to the cap). That's a queue semantics decision, and it's the right one for "re-authenticate on every auth failure".
The part nobody tells you: reliability engineering
A naive watcher — if (condition) runHandler() on a timer — has four failure modes, and I hit all of them:
1. Fire loops. The handler runs, the condition is still true (the dismiss button didn't work today), the watcher fires again 150ms later. Forever. Fix: a cooldown (default 500ms) between fires, plus a max fires count (default 1; 0 means unlimited, for banners that genuinely reappear).
2. Stuck handlers. The handler clicks a button that never becomes clickable and now the handler is the thing hanging your run. Fix: a fire timeout (default 30s) races the handler; on timeout the fire is recorded as failed and the main flow keeps going.
3. Zombie watchers. A cue whose handler fails every time will fail every time — a selector rotted, the site changed. Letting it keep firing turns one broken handler into a run-wide tarpit. Fix: a circuit breaker — after N consecutive failed fires (default 3) the cue disables itself and says so in the UI.
4. Handlers that "succeed" without fixing anything. The click ran, no exception — but the banner is still there. From the engine's perspective that's a success; from yours it isn't. Fix: an opt-in verify-resolution mode that re-checks the condition after the handler completes. Still true → the fire counts as a failure (which feeds the circuit breaker).
None of these is exotic. Together they're the difference between a demo feature and one you can leave running overnight.
There's also a reentrancy set (a cue can't fire while it's already firing), and nested cues — a cue inside a cue's children — arm only while the parent handler runs, then disarm. Scoped interrupts, basically.
"Between steps" vs "mid-step"
JavaScript is single-threaded, so a cue can't literally preempt a running await page.click(). What actually happens in-app: the condition check is itself async, so the poll interleaves with the in-flight step on the event loop, and the handler can run while the main step is still awaiting. For most handlers (dismiss an overlay) that's exactly what you want — the overlay is why the step is stuck.
But it's genuinely concurrent-ish behavior, and sometimes it bites: a handler that navigates while the main step is mid-fill will lose the race. So there's an escape hatch — "only fire between steps" — which defers firing until the current instrument finishes. Safer, slower, and the default stays aggressive because a cue that fires after the 30-second timeout it was supposed to prevent is useless.
After firing, a cue either resumes (control returns to wherever the main flow was — the default) or continues (abandons the current position and jumps to the steps after the cue). Continue-mode is for handlers like "session died, re-login" where retrying the interrupted step makes no sense.
The compile-away trick
Here's my favorite part. Orchestra exports flows as plain Playwright scripts — no runtime, no library, npm i playwright and go. So what happens to the watcher engine when you export?
It compiles away. Each cue becomes a handful of state variables, a test function, and a body function; the engine becomes a ~15-line check routine plus two scheduling mechanisms:
const _cue_c0_max = Infinity // Handle cookie banner
let _cue_c0_runs = _cue_c0_max
let _cue_c0_active = true
let _cue_c0_firing = false
let _cue_c0_lastFire = 0
let _cue_c0_fails = 0
async function _cue_c0_test() {
try { return ((await page.locator("#cookie-banner").count()) > 0) } catch { return false }
}
async function _checkCues() {
if (_cue_c0_active && !_cue_c0_firing && _cue_c0_runs > 0 && _cue_c0_fails < 3
&& Date.now() - _cue_c0_lastFire > 500 && await _cue_c0_test()) {
_cue_c0_runs--
_cue_c0_lastFire = Date.now()
_cue_c0_firing = true
try { await _cue_c0_body(); _cue_c0_fails = 0 }
catch (err) { _cue_c0_fails++; console.error("[cue] Handle cookie banner failed:", err.message) }
finally { _cue_c0_firing = false }
}
}
const _cueTimer = setInterval(() => { _checkCues().catch(() => {}) }, 150)
_cueTimer.unref?.()
…and the generator weaves await _checkCues() after every leaf step in the main flow. Two schedulers because each covers the other's blind spot: the setInterval catches conditions that become true during a long await (the interval callback interleaves on the event loop), and the per-step checkpoint guarantees a deterministic check even if timers are starved. Cooldown, max-runs, the failure circuit breaker — all of it survives as plain code you can read and edit.
The honest caveat ships as code too: event-based triggers (response, pageError, urlChange) need live listeners the generated skeleton doesn't wire up, so they export as an inert false /* response · /api/auth 401 — runtime-only trigger */ with the reason inline, rather than silently becoming a different behavior.
Takeaways
- If your automation problems are temporal rather than sequential, no amount of restructuring the sequence fixes them. You need a second control path.
- The watcher itself is the easy 20%. Cooldowns, timeouts, circuit breakers, and resolution-verification are the 80% that makes it dependable.
- Events beat polling for anything transient — buffer them and drain one per fire, and you get queue semantics for free.
- If your tool exports code, make features compile away, not compile in. A polling loop the user can read beats a runtime dependency they have to trust.
Orchestra is free to try at orchestra-automation.com; the docs have a full page on cues if you want the user-level view.