Core Framework & Test Design – Avoiding Static Waits with waitForResponse.
In our previous posts, we built a beautiful, maintainable architecture using the Page Object Model and made our tests readable for everyone using BDD. Today, we are declaring war on the single biggest enemy of automated testing: Flakiness.
If you’ve ever written a test that passes perfectly on your lightning-fast local machine but randomly fails in your CI/CD pipeline, you know exactly how frustrating this is. And I’d bet good money that if we looked at that failing test, we’d find a hardcoded static wait.
Let’s look at why page.waitForTimeout() is an anti-pattern and how we can replace it with deterministic, lightning-fast execution using Playwright’s waitForResponse().
The Villain: page.waitForTimeout()
Imagine you have a test that clicks a color button, waits for the backend to save the choice, and then checks if the UI updated.
A very common (and very terrible) way to write this is:
// ❌ The Anti-Pattern
await homePage.clickColorButton('Yellow');
await page.waitForTimeout(2000); // Wait 2 seconds for the network to do its thing...
await expect(homePage.currentColorText).toContainText('#f1c40f');
Why is this so bad?
- It wastes your team’s time: If the API responds in 50 milliseconds, your test still sits there doing absolutely nothing for 1.95 seconds. Multiply that wasted time by 500 tests, and you’ve just added 15 pointless minutes to your CI pipeline.
- It causes false failures (Flakiness): What if your CI runner is under heavy load, or the staging environment is having a bad day, and the API takes 2.5 seconds to respond? Your test fails, even though the application isn’t actually broken. You just didn’t wait long enough.
The Hero: Deterministic Waiting
Instead of telling Playwright to wait for an arbitrary amount of time, we need to tell it to wait for a specific event. In modern web applications, that event is almost always a network response.
Playwright provides page.waitForResponse() exactly for this purpose. However, there is a specific pattern you must follow to use it correctly without introducing race conditions.
The Golden Rule: Register Before You Fire!
You must register the network listener before you trigger the action that causes the network request. If you click the button first and then start listening, the response might arrive before your listener is ready, and your test will hang forever waiting for an event that already happened.
Let’s look at how we implemented this best practice perfectly in our repository’s Visual Regression tests:
// ✅ Best practice — deterministic, no wasted time
// 1. Register the listener BEFORE the click
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/colors/Yellow') && resp.status() === 200
)
// 2. Fire the action
await homePage.clickColorButton('Yellow')
// 3. Await the response (resolves as soon as it arrives)
await responsePromise
// 4. Use auto-retrying assertion to handle React state update
await expect(homePage.currentColorText).toContainText('#f1c40f')
Let’s break down why this is brilliant:
- The Setup: We create a promise (
responsePromise) that listens for a specific URL containing/api/colors/Yellowand a successful200status code. Notice we do notawaitit yet! - The Trigger: We click the “Yellow” button.
- The Synchronization: Now we
await responsePromise. If the API responds in 50ms, the test continues immediately. If the API takes 5 seconds, Playwright waits dynamically up to its global timeout. No time is ever wasted, and it never fails prematurely. - The Assertion: We can confidently assert the UI state knowing the underlying data has definitively arrived.
Summary
If you take one thing away from this post, it is this: Open your codebase, search for waitForTimeout, and delete every single one of them.
Replacing arbitrary time delays with deterministic network synchronization is the single fastest way to drastically reduce pipeline execution time and eliminate false-positive test failures.
