Barely Failed: Why Your Automation Runs Die in the Last 500ms
A run that clears login, resolves selectors, and still fails on the final click is not random. Here's how to diagnose and fix the most expensive kind of browser automation failure.
Most flaky automation is not a crash. It is a barely failed run: the session opens, the page loads, the login works, and then the final click times out or the selector resolves one frame too late. The script was one condition away from success.
Why barely failed is expensive
A near miss burns the same compute as a full run, but it often returns less signal than a hard crash. If you retry from zero, you pay for every step again. If you do not retry, you lose a task that probably would pass with a better wait strategy.
What actually breaks in the last step
In browser automation, most late failures come from four sources:
**Timing skew:** The page is still hydrating JavaScript when your script queries the DOM. Playwright and Puppeteer auto-wait for actionability, but only for the element state you target. You can still click too early if the surrounding app is not ready.
**Selector drift:** A stable element during development becomes dynamic in production because a cookie banner, A/B test, or i18n string changes the DOM shape.
**Network variance:** The API call behind the button resolves quickly in test, slower in production. Your script sees a disabled button and declares failure.
**Session fatigue:** CPU throttling, memory pressure, or a long-lived browser context degrades timing, so the final action misses its window.
Treat barely failed as a timing bug, not a mystery
The most useful thing you can capture is the 500ms before failure. That means screenshot, console, network, and DOM snapshot at the failed step. The [Playwright trace viewer](https://playwright.dev/docs/trace-viewer) is a good baseline because it records DOM snapshots, network, and action timing. In our own runs at Sapior, we found that most barely failed jobs were not missing the button; they were querying the button while the parent container was still hidden.
Fix the retry model first
A blind full-run retry is a blunt instrument. It can work for read-only jobs, but it wastes time and can re-create the same failure if the issue is deterministic. A better model is:
1. **Checkpoint after expensive or non-idempotent steps.** After login, save storage state. After form fill, save the DOM state if possible.
2. **Retry only the failed step with a fresh locator.** If the step is safe, rerun just that action with bounded backoff and jitter.
3. **Make wait conditions explicit.** Use `waitForFunction`, `expect(locator).toBeEnabled()`, or a network idle predicate instead of fixed `sleep` calls.
4. **Separate read and write retries.** A retry on GET or a read-only action is usually safe. A retry on POST, payment, or delete needs an idempotency key or a preflight check.
What good retry logic looks like
A practical wrapper might wait for a button to be visible, enabled, and stable for two animation frames before clicking. If the target is still not ready, fail with the last DOM snapshot and network requests attached. That converts a barely failed run into a debuggable artifact.
Build for the near miss
Sapior's browser runs treat a failed step as an event, not just an exit code. We keep the session warm, capture the last 500ms of network and console activity, and allow checkpointed replays from the failed step. That turns the worst kind of failure into a small patch instead of a full re-run.
Barely failed is still failed, but it should be the easiest failure to fix. Capture the last moment, make the wait condition explicit, and retry only the part that missed.