AbortSignal — Cancel Any Action
Playwright 1.62 adds a signal option to every action and web-first assertion. Cancel long-running clicks, navigations, and waits from your test code.
📖 The Story: The Stuck Sharded Worker
A team ran 10 test shards in parallel on GitHub Actions. One shard hit a flaky third-party login that hung indefinitely — the test waited 30 seconds (the default timeout), then 60 seconds (the test timeout), then the shard failed. But the other 9 shards had already finished, so the whole CI run waited for the stuck shard to time out.
The team tried reducing the test timeout to 10 seconds, but that broke legitimately slow tests. They tried splitting the flaky test into its own shard, but it still hung and blocked the run.
What they really wanted was: “if this specific action takes more than 5 seconds, cancel it and move on.” Not a test-level timeout, an action-level cancellation.
AbortSignal in 1.62 gives them exactly that. Pass a signal to click(), and if it aborts, the click fails immediately with a clear message.
🎯 Why It Matters
Action-Level
Cancel a specific action, not the whole test. Granular control over long-running operations.
Reusable
One AbortController can signal multiple actions. Cancel a batch of waits with one abort().
Unblock Workers
Stuck action in a sharded worker? Abort it instead of waiting for the full test timeout.
Clean Errors
Aborted actions fail with a clear 'aborted' message, not a cryptic timeout stack trace.
🌍 Real World Example
A team has a test that clicks a “Generate Report” button. The report generation can take 2–60 seconds depending on server load. The test can’t use a fixed timeout because 60s is too long for fast runs and too short for slow runs. With AbortSignal, they start a 30-second timer, pass the signal to the click, and if the report doesn’t appear in 30 seconds, the click aborts and the test fails with “Report generation took too long” — a clear, actionable error.
🧒 Explain Like I'm 10
Imagine you’re waiting for a bus. Before, you had to decide ahead of time: “I’ll wait 30 minutes, then give up.” If the bus came in 31 minutes, you missed it.
Now, you have a walkie-talkie. A friend can call you on the walkie-talkie and say “stop waiting, something else came up.” You stop waiting immediately, regardless of how long you’ve been waiting.
AbortSignal is the walkie-talkie. You can cancel a wait from anywhere in your test code, not just via a fixed timeout.
🎓 Professional Explanation
The AbortController/AbortSignal pattern is a Web API standard for cancelling async operations. Playwright 1.62 adds a signal option to most actions (click, fill, goto, etc.) and web-first assertions (toBeVisible, toHaveText, etc.). When the signal aborts, the operation fails immediately with an ‘aborted’ error instead of waiting for its own timeout.
This is distinct from Playwright’s default timeout. The default timeout (30s, or whatever you set in testConfig.timeout) is the upper bound for any single action. The signal is an additional cancellation source — whichever fires first (the signal or the default timeout) cancels the action.
📊 The Flow
How AbortSignal Interacts with Default Timeout
⚙️ The signal Option
Pass { signal: controller.signal } to any action or web-first assertion. When controller.abort() is called, the operation fails immediately. The signal does not disable the default timeout — pass timeout: 0 alongside it if you want the signal to be the only upper bound.
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'cancel a long-running click', async ({ page }) => { const controller = new AbortController(); setTimeout(() => controller.abort(), 1000); await page.goto(class="tk-str">'https:class="tk-com">//app.example.com'); class=class="tk-str">"tk-com">// If the click takes more than 1 second, it aborts await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Submit' }).click({ signal: controller.signal, }); });
The AbortController is a Web API. controller.signal is passed to the action. When controller.abort() fires (here, after 1 second), the click fails immediately with an ‘aborted’ error.
class=class="tk-str">"tk-com">// Cancel an assertion const controller = new AbortController(); setTimeout(() => controller.abort(), 2000); await expect(page.getByText(class="tk-str">'Done')).toBeVisible({ signal: controller.signal, }); class=class="tk-str">"tk-com">// Make the signal the SOLE cancellation source (disable default timeout) await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Submit' }).click({ signal: controller.signal, timeout: 0, class=class="tk-str">"tk-com">// disable default timeout — signal is the only upper bound });
Web-first assertions also accept the signal option. Pass timeout: 0 alongside the signal if you want the signal to be the only cancellation source — otherwise, the default 30s timeout still applies and may fire first.
class=class="tk-str">"tk-com">// Cancel multiple actions with one controller test(class="tk-str">'cancel a batch of waits', async ({ page }) => { const controller = new AbortController(); class=class="tk-str">"tk-com">// Some event elsewhere in the test triggers the abort page.on(class="tk-str">'console', msg => { if (msg.text().includes(class="tk-str">'FATAL')) controller.abort(); }); await page.goto(class="tk-str">'https:class="tk-com">//app.example.com'); class=class="tk-str">"tk-com">// All three actions share the same signal — any one aborts all await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Load' }).click({ signal: controller.signal }); await expect(page.getByText(class="tk-str">'Loaded')).toBeVisible({ signal: controller.signal }); await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Process' }).click({ signal: controller.signal }); });
One AbortController can signal multiple actions. If the page logs a ‘FATAL’ console message, all three pending actions abort immediately. This is useful for “abort on error” patterns where a runtime error should cancel all in-flight operations.
🔄 Before vs After
Before: Fixed test timeout (1.61 and earlier)
class=class="tk-str">"tk-com">// Test timeout is the only cancellation source test(class="tk-str">'slow action', async ({ page }) => { test.setTimeout(10000); class=class="tk-str">"tk-com">// 10s test timeout await page.goto(class="tk-str">'https:class="tk-com">//app.example.com'); await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Submit' }).click(); class=class="tk-str">"tk-com">// If click hangs, the whole test fails at 10s — no per-action control });
Before 1.62, the only way to cancel an action was the test-level timeout. You couldn’t say “cancel this click after 2 seconds but let the test continue for 30.” The timeout was all-or-nothing.
After: AbortSignal (1.62+)
import { test } from class="tk-str">'@playwright/test'; test(class="tk-str">'cancel a long-running click', async ({ page }) => { const controller = new AbortController(); setTimeout(() => controller.abort(), 1000); await page.goto(class="tk-str">'https:class="tk-com">//app.example.com'); await page.getByRole(class="tk-str">'button', { name: class="tk-str">'Submit' }).click({ signal: controller.signal, }); });
The signal gives you per-action cancellation control. The click aborts after 1 second if it hasn’t completed, but the test can continue for its full timeout — other actions aren’t affected.
Providing a signal does NOT disable the default timeout. Playwright’s default action timeout (30 seconds, or whatever you set in testConfig.timeout) still applies. If you want the signal to be the only upper bound, pass timeout: 0 alongside it. Otherwise, the test will fail on whichever fires first — the abort or the default timeout.
⚠️ Common Mistakes
Mistake 1: Expecting signal to disable the default timeout
The signal is an additional cancellation source, not a replacement for the default timeout. If you want the signal to be the only upper bound, pass timeout: 0 alongside it. Otherwise, the 30s default may fire first.
Mistake 2: Not handling the AbortError
When a signal aborts, the action throws an AbortError. If you don’t wrap the action in try/catch (or use expect.soft), the test fails. If you want to gracefully handle the abort (e.g., log it and continue), catch the error explicitly.
Mistake 3: Reusing a controller after abort()
Once controller.abort() is called, the controller’s signal is permanently aborted. You can’t “un-abort” it. If you need to cancel multiple batches of actions separately, create a new controller for each batch.
✅ Best Practices
- Pass
timeout: 0with the signal if you want the signal to be the sole cancellation source. - Catch AbortError if you want to gracefully handle cancellation (log and continue).
- Use one controller per batch — don’t reuse aborted controllers.
- Wire signals to error events (console FATAL, response 500) for “abort on error” patterns.
- Don’t use signals as a replacement for auto-waiting — use them for SLA-style upper bounds on known-slow operations.
🏗️ Senior Engineer Deep Dive
Signal vs test.setTimeout vs action timeout
Three timeout mechanisms: (1) test.setTimeout(ms) sets the test-level timeout (default 30s) — if any action exceeds it, the whole test fails. (2) Action-level { timeout: ms } sets a per-action timeout. (3) { signal } adds a cancellation source that can fire at any time, from anywhere in your code. Use test.setTimeout for overall test budget, action timeout for per-action limits, and signal for dynamic cancellation (abort on error, abort on external event).
When to use signals in sharded CI
In a sharded CI run, one stuck shard blocks the whole run. If a shard has a known-flaky action (e.g., a third-party login), wire a 10-second AbortController to that action. If it aborts, the test fails fast and the shard completes — the other shards don’t wait for the full 30s default timeout. This can cut CI wall-clock time by minutes on flaky runs.
💼 Interview Questions
Show Answer
Show Answer
Show Answer
Show Answer
🏋️ Practical Exercise
Cancel Your First Action
- Write a test that navigates to a slow-loading page (e.g., a demo site with intentional delay)
- Create an AbortController with a 2-second timer:
const c = new AbortController(); setTimeout(() => c.abort(), 2000) - Pass the signal to a click:
await page.getByRole('button').click({ signal: c.signal }) - Run the test — it should fail with ‘aborted’ if the click takes more than 2 seconds
- Now pass
timeout: 0alongside the signal and verify the signal is the sole cancellation source
🚀 Mini Project
🏗️ The Abort-on-Error Pattern
- Write a test that performs 3 sequential actions on a page
- Create one AbortController shared by all 3 actions
- Wire
page.on('console', msg => { if (msg.text().includes('FATAL')) c.abort() }) - Trigger a FATAL console log on the page mid-test
- Verify all 3 pending actions abort immediately, not one-by-one
- Catch the AbortError and log a clean message instead of failing
📝 Quiz
Test your understanding. Click an option to check your answer.
❓ FAQ
Q: Can I use AbortSignal with page.route()?
A: No. page.route() handlers don’t accept a signal option. Use page.unroute() to remove route handlers instead. The signal option is for actions and assertions only.
Q: Does the signal work with locator.waitFor()?
A: Yes. locator.waitFor({ signal }) accepts the signal. If the signal aborts before the locator appears, the wait fails immediately with an AbortError.
Q: Can I abort a navigation?
A: Yes. page.goto(url, { signal }) accepts the signal. If the signal aborts mid-navigation, the navigation fails. Note: the browser may still complete the network request — the signal cancels the Playwright wait, not the underlying fetch.
📦 Summary
🎯 Key Takeaways
- signal accepts an AbortSignal — cancel actions and assertions from anywhere in your test.
- Pass
timeout: 0alongside the signal to make it the sole cancellation source. - One controller can signal multiple actions — useful for abort-on-error patterns.
- Catch AbortError if you want to handle cancellation gracefully.
- Use for SLA enforcement and unblocking stuck shards, not as a replacement for auto-waiting.
Comments
Comments
Post a Comment