Skip to content
HN
Back to blog
PlaywrightFlaky Tests

Smart Waiting: Why waitForTimeout Is a Trap

The difference between waiting for time and waiting for state, and why only one of them scales.

July 30, 202614 min read

Smart Waiting: Why waitForTimeout Is a Trap

Introduction

One of the most common ways to make an unstable UI test appear reliable is to add a fixed delay.

await page.waitForTimeout(5000);

The test waits for five seconds, the page has more time to load, and the failure seems to disappear.

At first, this feels like a practical solution.

In reality, waitForTimeout() often creates a slower, less reliable, and harder-to-maintain automation suite.

The problem is simple: a fixed delay does not wait for the application to become ready. It only waits for time to pass.

In this article, we will explore why hardcoded waits are a trap, how Playwright's auto-waiting works, and how to replace fixed delays with meaningful conditions.


What Is waitForTimeout?

Playwright provides waitForTimeout() to pause execution for a specific amount of time.

Example:

await page.waitForTimeout(3000);

This means:

Pause the test

↓

Wait exactly 3 seconds

↓

Continue execution

The test does not check whether:

  • The page finished loading
  • The API response completed
  • The button became visible
  • The animation ended
  • The modal disappeared
  • The expected data appeared

It only waits for three seconds.


Why Fixed Waiting Looks Useful

Imagine a test that clicks a search button and immediately reads the results.

await page
  .getByRole('button', {
    name: 'Search',
  })
  .click();

const resultText = await page
  .getByTestId('search-result')
  .textContent();

Sometimes the result is not ready yet, and the test fails.

A quick fix might be:

await page
  .getByRole('button', {
    name: 'Search',
  })
  .click();

await page.waitForTimeout(3000);

const resultText = await page
  .getByTestId('search-result')
  .textContent();

The test may now pass locally.

But the delay has not solved the synchronization problem. It has only hidden it.


The First Problem: The Wait May Be Too Short

Suppose the application usually responds in two seconds.

A three-second wait seems safe.

However, in CI the same operation may take four or five seconds because of:

  • Slower machines
  • Network latency
  • Shared infrastructure
  • Browser startup overhead
  • Backend load
  • Temporary service delays

The fixed wait expires after three seconds, and the test still fails.

Expected loading time: 2 seconds

CI loading time: 5 seconds

Fixed wait: 3 seconds

Result: Failure

The test remains flaky.


The Second Problem: The Wait May Be Too Long

Now suppose the application completes in 500 milliseconds.

The test still waits for three full seconds.

Application ready: 0.5 seconds

Fixed wait: 3 seconds

Wasted time: 2.5 seconds

That may not seem significant in one test.

But consider 500 tests with two unnecessary three-second waits each.

500 tests

×

2 waits

×

3 seconds

=

3,000 seconds

That is approximately 50 minutes of waiting without testing anything.

Hardcoded waits make automation suites slower as they grow.


Time Is Not an Application State

The core issue with waitForTimeout() is that elapsed time does not prove readiness.

After five seconds, the application could still be:

  • Loading
  • Showing an overlay
  • Waiting for an API
  • Rendering a table
  • Processing a transaction
  • Reconnecting to a service
  • Displaying an animation

A better question is not:

How long should the test wait?

The better question is:

What condition proves the application is ready?

This mindset is the foundation of smart waiting.


Playwright Already Auto-Waits

Playwright automatically waits before performing most actions.

For example:

await page
  .getByRole('button', {
    name: 'Submit',
  })
  .click();

Before clicking, Playwright checks whether the element is:

  • Attached to the DOM
  • Visible
  • Stable
  • Enabled
  • Able to receive pointer events

This means you usually do not need:

const submitButton = page.getByRole(
  'button',
  {
    name: 'Submit',
  },
);

await submitButton.waitFor({
  state: 'visible',
});

await submitButton.click();

In many cases, this is enough:

await submitButton.click();

The click action already waits for the button to become actionable.


Auto-Waiting Does Not Wait for Everything

Playwright's auto-waiting is powerful, but it does not automatically understand every business condition.

For example, clicking a button may start an API request:

await page
  .getByRole('button', {
    name: 'Generate report',
  })
  .click();

The click may succeed immediately, but the report may still be loading.

Playwright can ensure the button is clickable.

It cannot automatically know whether the report is complete unless the test waits for a meaningful signal.

That signal may be:

  • A loading spinner becoming hidden
  • A success message appearing
  • A network response completing
  • A table receiving rows
  • A URL changing
  • A download starting
  • A button becoming enabled

Wait for Visibility

Instead of:

await page.waitForTimeout(3000);

await page
  .getByRole('button', {
    name: 'Continue',
  })
  .click();

Use:

const continueButton = page.getByRole(
  'button',
  {
    name: 'Continue',
  },
);

await continueButton.waitFor({
  state: 'visible',
});

await continueButton.click();

Or simply:

await page
  .getByRole('button', {
    name: 'Continue',
  })
  .click();

if no separate state validation is required.


Wait for an Element to Disappear

Loading indicators are common synchronization signals.

Avoid:

await page.waitForTimeout(5000);

Use:

await page
  .getByTestId('loading-spinner')
  .waitFor({
    state: 'hidden',
  });

This means:

Wait until loading is actually finished

↓

Continue immediately

If loading takes one second, the test waits one second.

If loading takes four seconds, the test waits four seconds.

This is both faster and more reliable.


Use Web-First Assertions

Playwright assertions automatically retry until they pass or reach the configured timeout.

Example:

await expect(
  page.getByText('Payment successful'),
).toBeVisible();

This is much better than:

await page.waitForTimeout(3000);

const isVisible = await page
  .getByText('Payment successful')
  .isVisible();

expect(isVisible).toBe(true);

The isVisible() check runs immediately.

The web-first assertion retries.

Other useful assertions include:

await expect(locator).toBeVisible();

await expect(locator).toBeHidden();

await expect(locator).toHaveText(
  'Completed',
);

await expect(locator).toContainText(
  'Success',
);

await expect(locator).toHaveCount(5);

await expect(page).toHaveURL(
  /dashboard/,
);

Wait for a Network Response

Sometimes the clearest signal is the backend response.

Example:

const responsePromise =
  page.waitForResponse(
    response =>
      response.url().includes(
        '/api/orders',
      ) &&
      response.status() === 200,
  );

await page
  .getByRole('button', {
    name: 'Place order',
  })
  .click();

const response =
  await responsePromise;

expect(response.ok()).toBeTruthy();

It is important to create the wait before the click.

Correct:

const responsePromise =
  page.waitForResponse(
    '**/api/orders',
  );

await submitButton.click();

await responsePromise;

Risky:

await submitButton.click();

await page.waitForResponse(
  '**/api/orders',
);

The response may complete before Playwright starts waiting for it.


Wait for Navigation

Avoid:

await loginButton.click();

await page.waitForTimeout(5000);

Use:

await loginButton.click();

await expect(page).toHaveURL(
  /dashboard/,
);

Or when a navigation event is explicitly needed:

await Promise.all([
  page.waitForURL(
    '**/dashboard',
  ),
  loginButton.click(),
]);

The test now waits for the destination rather than an arbitrary amount of time.


Wait for a Specific UI State

Suppose a cash-out button becomes available only after a betting round begins.

Avoid:

await betButton.click();

await page.waitForTimeout(7000);

await cashOutButton.click();

Use:

await betButton.click();

await cashOutButton.waitFor({
  state: 'visible',
  timeout: 7000,
});

await cashOutButton.click();

This waits for the actual required state.

If the button appears after two seconds, the test continues immediately.


Wait for Data to Change

Sometimes an element is visible but still contains old or placeholder data.

Example:

await expect(
  page.getByTestId('balance'),
).not.toHaveText('Updating...');

Or:

await expect(
  page.getByTestId('balance'),
).toHaveText(/\d+/);

For a balance that initially shows zero while updating:

const balance =
  page.getByTestId('balance');

await expect(balance).not.toHaveText(
  'Updating...',
);

await expect(balance).toHaveText(
  /[1-9]\d*/,
);

This is much more reliable than waiting ten seconds and hoping the value has changed.


Wait for a Button to Become Enabled

Avoid:

await page.waitForTimeout(2000);

await saveButton.click();

Use:

await expect(
  saveButton,
).toBeEnabled();

await saveButton.click();

This is useful when the button becomes active only after:

  • Required fields are filled
  • Validation completes
  • Data finishes loading
  • A checkbox is selected

Wait for a Table to Load

Avoid:

await page.waitForTimeout(5000);

Use:

const rows =
  page.getByRole('row');

await expect(rows).toHaveCount(11);

Or wait for a specific record:

await expect(
  page.getByRole('row', {
    name: /Alice Johnson/,
  }),
).toBeVisible();

The test verifies the expected result directly.


Wait for a Download

Avoid clicking and then waiting several seconds.

Use:

const downloadPromise =
  page.waitForEvent('download');

await page
  .getByRole('button', {
    name: 'Export',
  })
  .click();

const download =
  await downloadPromise;

await download.saveAs(
  'downloads/report.xlsx',
);

The test waits for the actual download event.


Wait for a New Page or Popup

Avoid:

await openReportButton.click();

await page.waitForTimeout(3000);

Use:

const popupPromise =
  page.waitForEvent('popup');

await openReportButton.click();

const popup =
  await popupPromise;

await popup.waitForLoadState();

Again, the test waits for a real browser event rather than time.


Use expect.poll() for Custom Conditions

Sometimes the condition is not directly represented by a locator.

For example, you may need to poll an API until a transaction reaches a completed state.

await expect
  .poll(
    async () => {
      const response =
        await request.get(
          `/api/transactions/${transactionId}`,
        );

      const body =
        await response.json();

      return body.status;
    },
    {
      timeout: 30_000,
    },
  )
  .toBe('COMPLETED');

This is better than:

await page.waitForTimeout(30_000);

The poll ends as soon as the expected state is reached.


Use toPass() for Retriable Blocks

For a more complex condition:

await expect(
  async () => {
    const response =
      await request.get(
        `/api/orders/${orderId}`,
      );

    expect(response.status()).toBe(
      200,
    );

    const body =
      await response.json();

    expect(body.status).toBe(
      'CONFIRMED',
    );
  },
).toPass({
  timeout: 30_000,
});

This retries the entire block until it passes.

Use this carefully and only when the operation is safe to repeat.


Avoid Waiting for networkidle by Default

Some testers use:

await page.waitForLoadState(
  'networkidle',
);

This can be unreliable for modern applications because many pages continuously use:

  • WebSockets
  • Analytics
  • Background polling
  • Live notifications
  • Tracking requests

A page may never become truly idle.

Instead, wait for a specific user-visible or business-relevant state.

For example:

await expect(
  page.getByRole('heading', {
    name: 'Dashboard',
  }),
).toBeVisible();

This proves that the feature needed by the test is ready.


Avoid Huge Timeouts

Another common workaround is increasing timeouts everywhere.

await button.click({
  timeout: 100000,
});

A 100-second timeout does not make the test more stable.

It may only make failures much slower.

Use reasonable global defaults:

export default defineConfig({
  timeout: 60_000,

  expect: {
    timeout: 10_000,
  },

  use: {
    actionTimeout: 15_000,
    navigationTimeout: 30_000,
  },
});

Then override the timeout only for operations that are genuinely slow.

await expect(
  reportPage.completeMessage,
).toBeVisible({
  timeout: 60_000,
});

The timeout should communicate that a specific operation is expected to take longer.


Do Not Stack Multiple Waits Without Purpose

This pattern is often unnecessary:

await button.waitFor({
  state: 'attached',
});

await button.waitFor({
  state: 'visible',
});

await expect(button).toBeEnabled();

await button.click();

In most cases:

await button.click();

is enough.

Use additional waits only when each one verifies a meaningful state required by the test.

Too many waits make the code harder to understand and may hide the real synchronization problem.


A Common Anti-Pattern

Consider this test:

await page
  .getByRole('button', {
    name: 'Submit',
  })
  .click();

await page.waitForTimeout(5000);

await expect(
  page.getByText('Success'),
).toBeVisible();

The test waits twice:

  1. Five seconds through waitForTimeout()
  2. Again through the retrying assertion

The fixed delay is unnecessary.

Better:

await page
  .getByRole('button', {
    name: 'Submit',
  })
  .click();

await expect(
  page.getByText('Success'),
).toBeVisible();

The assertion already waits for the message.


Smart Waiting in Page Objects

Synchronization can be encapsulated inside meaningful Page Object methods.

Example:

export class CheckoutPage {
  constructor(
    private readonly page: Page,
  ) {}

  private get placeOrderButton() {
    return this.page.getByRole(
      'button',
      {
        name: 'Place order',
      },
    );
  }

  private get loadingSpinner() {
    return this.page.getByTestId(
      'checkout-loading',
    );
  }

  async placeOrder(): Promise<void> {
    await this.placeOrderButton.click();

    await this.loadingSpinner.waitFor({
      state: 'hidden',
    });
  }
}

The test remains readable:

await checkoutPage.placeOrder();

await expect(
  checkoutPage.successMessage,
).toBeVisible();

The Page Object handles the technical synchronization.

The test verifies the business result.


Smart Waiting for Dynamic Buttons

Consider this workflow:

await betButton.click();

await cancelButton.waitFor({
  state: 'visible',
  timeout: 100_000,
});

await cashOutButton.waitFor({
  state: 'visible',
  timeout: 7_000,
});

If cashOutButton sometimes needs one additional retry, avoid adding an unconditional seven-second delay.

A controlled retry can be used:

try {
  await cashOutButton.waitFor({
    state: 'visible',
    timeout: 7_000,
  });
} catch {
  await cashOutButton.waitFor({
    state: 'visible',
    timeout: 7_000,
  });
}

However, this should only be used when the application has a known two-stage transition.

A cleaner approach may be to wait for the business state that controls the button:

await expect(
  gameStatus,
).toHaveText('In Progress', {
  timeout: 14_000,
});

await expect(
  cashOutButton,
).toBeVisible();

Waiting for the real state usually produces better diagnostics.


When Is waitForTimeout Acceptable?

waitForTimeout() is not forbidden.

There are a few legitimate use cases.

Debugging

await page.waitForTimeout(5000);

This can be useful temporarily while observing the browser locally.

It should usually be removed before committing the test.


Testing Time-Based Behaviour

For example, testing an automatic logout after a specific period may genuinely require elapsed time.

Even then, consider whether the application clock can be mocked or controlled.


Waiting for an External System With No Signal

Sometimes an external service provides no API, event, or UI status that can be observed.

A fixed delay may be the only available option.

However, it should be:

  • Documented
  • Isolated
  • Used as a last resort
  • Kept as short as possible

Visual Demonstrations

A delay may be useful in demonstration scripts where a human needs time to observe each action.

That is different from production test automation.


Diagnosing Flaky Wait Problems

When a test fails intermittently, do not immediately add a delay.

Investigate the sequence:

Action

↓

Expected application event

↓

Actual application event

↓

Test synchronization

↓

Failure evidence

Review:

  • Playwright Trace
  • Screenshots
  • Videos
  • Network requests
  • Console logs
  • Element state
  • Loading indicators
  • Duplicate elements
  • API response timing

Ask:

  • Did the locator match the correct element?
  • Was the element visible but covered?
  • Did the API fail?
  • Did the application finish loading?
  • Was the test waiting for the wrong signal?
  • Did another worker modify the same data?

The correct fix depends on the root cause.


Practical Refactoring Examples

Example 1: Waiting for a Modal

Before:

await deleteButton.click();

await page.waitForTimeout(2000);

await confirmButton.click();

After:

await deleteButton.click();

const modal =
  page.getByRole('dialog');

await expect(modal).toBeVisible();

await modal
  .getByRole('button', {
    name: 'Confirm',
  })
  .click();

Example 2: Waiting for Search Results

Before:

await searchInput.fill('Laptop');

await page.keyboard.press('Enter');

await page.waitForTimeout(5000);

await expect(
  productCards,
).toHaveCount(10);

After:

await searchInput.fill('Laptop');

await page.keyboard.press('Enter');

await expect(
  productCards,
).toHaveCount(10);

The assertion handles the wait.


Example 3: Waiting for a Balance Update

Before:

await depositButton.click();

await page.waitForTimeout(10_000);

const balanceText =
  await balance.textContent();

After:

const previousBalance =
  await balance.textContent();

await depositButton.click();

await expect(balance).not.toHaveText(
  previousBalance ?? '',
  {
    timeout: 10_000,
  },
);

The test waits for the value to actually change.


Example 4: Waiting for a Report

Before:

await generateReportButton.click();

await page.waitForTimeout(15_000);

await downloadButton.click();

After:

await generateReportButton.click();

await expect(
  page.getByText(
    'Report generated successfully',
  ),
).toBeVisible({
  timeout: 15_000,
});

await downloadButton.click();

Example 5: Waiting for API and UI Together

const responsePromise =
  page.waitForResponse(
    response =>
      response.url().includes(
        '/api/profile',
      ) &&
      response.status() === 200,
  );

await saveButton.click();

await responsePromise;

await expect(
  successToast,
).toBeVisible();

This validates both backend completion and frontend feedback.


Smart Waiting Checklist

Before using waitForTimeout(), ask:

  • What exactly is the test waiting for?
  • Is there a visible UI state?
  • Is there a loading indicator?
  • Can I use a web-first assertion?
  • Is there a network response?
  • Is there a navigation event?
  • Can I wait for a value to change?
  • Can I wait for an element to disappear?
  • Is Playwright already auto-waiting for this action?
  • Will the condition work in both fast and slow environments?

If you can identify a real condition, wait for that condition instead of time.


Recommended Waiting Priority

A practical waiting order is:

  1. Playwright auto-waiting
  2. Web-first assertions
  3. Locator state
  4. Specific network response
  5. Navigation or browser event
  6. Custom polling
  7. Fixed timeout only as a last resort

This keeps tests responsive and reduces flakiness.


Conclusion

waitForTimeout() is tempting because it offers an immediate fix for synchronization problems. But fixed delays do not understand application state. They can be too short in slow environments and unnecessarily long in fast ones.

Smart waiting means identifying the event or condition that proves the application is ready.

Use Playwright's auto-waiting for actions, web-first assertions for UI states, network waits for backend operations, and event-based synchronization for navigation, downloads, and popups.

A reliable test should not say:

Wait five seconds and hope.

It should say:

Continue when the expected condition is true.

That small change in mindset can significantly improve the speed, stability, and maintainability of an entire Playwright test suite.

Smart Waiting: Why waitForTimeout Is a Trap | Hoa Nguyen