DEV Community

Tech Auto Lab
Tech Auto Lab

Posted on

I deleted 200 lines of Playwright waits and my automation got more stable

Your automation isn't flaky because it's missing sleeps. It's flaky because you keep adding them.

I spent my first weeks of browser automation treating Playwright like Selenium: time.sleep(2) after every click, wait_for_timeout sprinkled everywhere, and a wall of try/except around everything that might race. The scripts worked on my machine and died at 3 a.m. in production.

Then I deleted nearly all of it. The scripts got faster and stopped failing. Here's the shift, in the order it matters.

1. Stop waiting for elements. Wait for state instead.

Every sleep encodes a guess about how long something takes. The guess is always wrong somewhere — a slow CI box, a cold cache, a network hiccup.

Playwright auto-waits for the element to be actionable if you use the right locator. The moment I replaced sleeps with state-based waits, the flakiness dropped.

# BEFORE: guess the timing, hope it's enough
page.click("text=Submit")
page.wait_for_timeout(3000)

# AFTER: wait for the *outcome*, not the clock
page.get_by_role("button", name="Submit").click()
page.get_by_text("Welcome back").wait_for()
Enter fullscreen mode Exit fullscreen mode

2. Role-based locators survive redesigns. CSS selectors don't.

My first selectors were pure CSS: #submit-btn, .login > form > input[3]. Every site redesign broke them, and I was the one who found out at 2 a.m.

Switching to role-based locators — get_by_role, get_by_label, get_by_text — was the single biggest stability win. They describe what a user sees, which barely changes, instead of how the DOM is shaped, which changes constantly.

# BEFORE: breaks when the class is renamed
page.locator(".form__submit--primary").click()

# AFTER: survives a redesign
page.get_by_role("button", name="Publish").click()
Enter fullscreen mode Exit fullscreen mode

3. Web-first assertions catch the failure where it happens, not three steps later

The old way: click, sleep, read some value, compare it yourself, raise your own error. By the time my custom check ran, the actual failure was three steps behind.

expect assertions poll for the condition automatically and fail at the step that broke, with the state of the page in the message. That alone cut my debugging time more than any log line I ever wrote.

from playwright.sync_api import expect

page.get_by_role("button", name="Publish").click()
expect(page).to_have_url(re.compile(r"/myhandle/my-slug"))
Enter fullscreen mode Exit fullscreen mode

4. The retry belongs at the top, not wrapped around every click

I used to wrap individual actions in try/except and retry them in place. That hides the real failure: if a click needs a retry, the page is already in a state you didn't expect.

The correct place for a retry is the whole task — re-run the job from a clean context. If the task is idempotent, a clean retry fixes far more than a hundred in-place excepts.

for attempt in range(3):
    try:
        run_job()
        break
    except JobError:
        if attempt == 2:
            raise
Enter fullscreen mode Exit fullscreen mode

What I'd do differently next time

  1. Write role-based locators from the first line, not as a refactor.
  2. Ban wait_for_timeout in review unless the wait has a comment saying why no state condition exists.
  3. Make every automated task idempotent so the clean retry is actually safe.

The takeaway that surprised me most: less code, fewer sleeps, and a browser that waits for state instead of clocks — that's the whole trick. The flakiness was never about timing. It was about describing what I wanted instead of guessing how long it would take.

Which wait pattern still bites you in production? Tell me in the comments — I'm logging this series build-in-public and I read every one.

Top comments (0)