Manage Delays and Async in Playwright

ยท

6 min read

When you first start out in web automation, it's all about taking the right actions in the right order. But as you advance, timing becomes more and more important.

A great script minimizes the amount of time it spends waiting for changes, and it parallelizes as much behavior as possible. Faster scripts save you time and money, and the work it takes to make them more robust as well.

This article will cover the ins and outs of working with time in Playwright. Cut your scripts from taking minutes to seconds.

And if this post is helpful, visit my deep dive on working with Playwright locators. I cover optimizing your Playwright scripts for speed, scale, and stability in much greater detail.

Let's dive in!

Use the Correct Wait Strategy

By default, Playwright's locators will wait for at least one matching element to appear on the page. In many cases, this is all you need, but the deeper you get into automation, the more frequently this strategy will fall short.

Often, you'll need to watch for other criteria before a particular locator is ready for action. Here's a good example of why you can't always trust the default wait strategy:

// ๐ŸŸข GOOD: The default wait works for a single matching element
const $modal = page.locator('#signup-modal');
// ๐Ÿ”ด BAD: But it fails when you need to wait for multiple elements
const $confetti = page.locator('.confetto');

Wait for global state

The "dynamic confetti" problem above can be solved by waiting for the global state to normalize.

// Wait for at least 100 confetti
await page.waitForFunction(async () => {
  return await page.locator('.confetto').count() >= 100;
});
const $confetti = page.locator('.confetto');

page.waitForFunction() will run the function until the return is truthy. This allows you to write very clean code that's equally powerful.

Embrace the State of the Locator

Locators normally resolve when at least one matching DOM node is attached to the DOM. However, Playwright offers a few other convenience methods:

// Attached when added to the DOM (default)
const $attached = page.getByRole('button')
  .waitFor({state: 'attached'});

// Detached when removed from the DOM
const $detached = page.getByRole('button')
  .waitFor({state: 'detached'});

// Visible when attached and has some visible pixels
const $attached = page.getByRole('button')
  .waitFor({state: 'visible'});

// Hidden when detached, `visibility: hidden`, or `display: none`
const $attached = page.getByRole('button')
  .waitFor({state: 'hidden'});

The visible state will frequently come in handy, as a lot of dynamic HTML works by managing visibility rather than dynamically creating new subtrees.

Set a Custom Timeout

By default, Playwright will resolve a locator as soon as it possibly can. But if you're waiting on a dynamically added list of elements, you may prefer to set an extended timeout.

// Wait for a waterfall of injected JS script tags
const $dynamicScripts = page.locator('script')
  .waitFor({timeout: 60_000});

That said, use caution with the timeout approach. If you find yourself leaning on such an imprecise tool, you may want to look twice for better solutions. After all, you can potentially save a lot of time by targeting the precise event that will allow your script to proceed.

Here's one particular strategy...

Wait for DOM Events

Sometimes, it's best to go back to basics and lean on the DOM to ensure the necessary content is loaded before proceeding with your selectors.

For example, let's say that you need a particular image to have loaded before taking a screenshot of the page. You might use the load event to ensure that this is the case:

const $logo = page.getByRole('img')
  .and(page.getByAltText('BrowserCat'));

$logo.evaluate(($el) => {
  return new Promise((resolve) => {
    $el.addEventListener('load', resolve, {once: true});
  });
});

// Logo is loaded!
await $logo.screenshot();

It's worth highlighting the power of Playwright's .evaluate() API. This simple construct gives you direct access to the browser context. Use it to get down to the metal, and control the flow of your scripts directly.

Use Asynchronous Interactions

The longer your scripts become, the more you'll gain by running page interactions simultaneously. Playwright's websocket communication model makes this very performant, and the Locator API is in fact built with asynchronous code in mind.

Let's dive into some strategies worth remembering...

Reuse Locators with Dynamic Content

Playwright's locators are dynamic by design, which means they resolve fresh each time they're accessed. This is particularly handy when dealing with changeable content on a web page.

const $messages = page.locator('#chat li');
let prevMessage = null;

// listen for websocket messages
page.on('websocket', (socket) => {
  socket.on('framereceived', async (event) => {
    const lastMessage = await $messages.last().textContent();
    // verify the new message was added to the chat list
    assert(lastMessage !== prevMessage);
    prevMessage = lastMessage;
  });
});

In this example, we access the locator every time a websocket message is received, then we verify that the new message is added to the chat list.

Notice that the $messages locator is reused without being replaced. This unlocks the possibility of defining your primary locators in the initialization of your script, reusing them as necessary across otherwise complex automations.

Run Tasks in Parallel

Often, scripts wait for one task to complete before starting another. But this isn't a necessary restriction. In Playwright, you can achieve easy parallelism just by employing Promise.all, saving a lot of time in the process.

const result = await Promise.all([
  // Starts a long-running calculation...
  page.locator('#long-running-calculation').click(),
  // Waits for the calculation result to come through...
  page.waitForResponse((res) => /calculation-results/.test(res.url())),
]).then(() => {
  // Retrieve the result
  return page.locator('.calculation-results').textContent();
});

console.log('Calculation completed. Results:', result);

With Promise.all, the script initiates the calculation and waits for the resulting response simultaneously, rather than sequentially. When leveraged strategically, this can shave off precious seconds or even minutes from the total run time.

Leverage a Dependency Graph

Sometimes, you have tasks that depend on others but needn't run in a singular chain. In these cases, you can create a dependency graph by initializing numerous promises, but only awaiting the results at the end of your script. This will ensure that every interaction happens both in the correct order and as swiftly as possible.

This is a somewhat contrived example, but it's not difficult to scale the pattern up. You will assuredly find reasons to do so in your own code.

const doLogin = page.locator('#login').click();
const loadDashboard = doLogin.then(() => page.waitForNavigation({
  url: /dashboard/,
}));
const fetchData = page.waitForResponse((res) => {
  return /data-endpoint/.test(res.url());
});

// Do more stuff here...

const [dashboardLoaded, apiResponse] = await Promise.all([loadDashboard, fetchData]);
console.log('Dashboard has loaded!');
console.log('API data:', await apiResponse.json());

By setting up a structure of chained promises, this example initializes various tasks, but only forces those chains to resolve at the very end. Less time wasted, more money saved.

Next steps

Now that you're brimming with ideas on how to speed up your scripts, go play! Every automation is a little different. The more you experiment, the more you'll learn.

And if you're curious about more ways to optimize your Playwright automations, check out my Playwright locator deep-dive.

Remember, timing is everything. Happy automating!

ย