Table of contents
When you invest time and effort into creating a well-running end-to-end test suite or adopt Playwright synthetic monitoring with Checkly, you should focus on two things.
Your tests must be stable because few things are worse than an unreliable test suite. But also, your tests must be fast because no one wants to wait hours to receive the green light when you're on the edge of your seat to deploy this critical production hotfix.
What if I told you you’re slowing down your tests with the most basic Playwright action ever — page.goto()
?
Check out this video or read on to learn more. Both include examples and ways to speed up your Playwright tests.
Ready? Let’s go!
The start of every Playwright script — page.goto()
Here’s a pretty basic Playwright script.
import { expect, test } from "@playwright/test";
test("Login works", async ({ page }) => {
await page.goto("/");
const loginLink = page.getByRole("link", { name: "Login" });
await loginLink.click();
const loginHeader = page.getByRole("heading", { name: "Login" });
await expect(loginHeader).toBeVisible();
// More things to check after the login link was clicked
// ...
});
It navigates to the root of a baseURL
defined in the playwright.config
. It locates a link, clicks it, and then tests whether the site’s UI has been updated accordingly (I’ve hidden most of the actions and assertions because they don’t matter for this post).
All this works great, and I’m sure you’ve written similar Playwright code before. If you inspect your tests and their duration, you might have discovered that page.goto()
execution times can vary greatly.
Let me show you an example.
In the Playwright trace above, page.goto()
takes ten seconds! Surprisingly, the film strip on top of the trace viewer shows that the example site was visible for almost the entire waiting time.
And this is a single test for a super-speedy local e-commerce site. Testing all the core functionality of a reasonably sized shop could easily include a hundred tests, and then such a small delay results in more than 15 minutes of waiting time in your CI/CD pipeline!
(Of course, you can shorten this waiting time by parallelizing your tests, but the argument of unnecessary waiting still holds.)
What are we waiting for?
page.goto()
and the load
event
If you check the Playwright docs, you’ll discover that, by default, page.goto()
waits for the load
event.
await page.goto("/");
// is the same as
await page.goto("/", {waitUntil: "load"});
What’s the load
event? A quick peek at MDN gives us the answer.
The load event is fired when the whole page has loaded, including all dependent resources such as stylesheets, scripts, iframes, and images.
Let’s think this through. Whenever you call page.goto()
you’re waiting for all stylesheets, scripts, iframes, and images to be loaded. In summary, you’re waiting for everything(!) to be loaded before running your test.
When inspecting the network tab of my example trace file, I found an SVG image delaying all the test actions. Why is this file so slow? I don’t know, but there’s always a chance of resources taking longer to load.
Now, this is only a small demo application. Consider a full-blown e-commerce shop; there will be hundreds of images, let alone all the scripts coming from “somewhere”. For such a site, the page.goto()
default configuration will wait for all the resources to be squeezed through the network before clicking the first button. Is this the best way?
Your users aren’t waiting for your requests — why should your tests do it?
Let’s take a step back for a moment…
Why are you testing and monitoring your sites end-to-end with real browsers? You want to ensure that your site and infrastructure work properly, sure. But you also want to do this by mimicking actual user behavior. That’s the entire point of end-to-end testing, right?
Do your users wait for the little spinner in their URL bar to disappear before they click on something? I doubt it. I’m pretty sure your average visitor won’t look at your site’s loading state before clicking the “Add to cart” button. People click and interact with your site whenever something’s visible. That’s why the best approach to writing Playwright scripts is to stick to natural human behavior.
How should you navigate your tests, then?
The page.goto()
waiting behavior can be tweaked with the waitUntil
config property. And you can define four waitUntil
settings.
// Wait until the HTML starts loading.
await page.goto("/", {waitUntil: "commit"});
// Wait until the HTML is parsed
// and deferred scripts (`<script deferred>` and `<script type="module">`) are loaded.
await page.goto("/", {waitUntil: "domcontentloaded"});
// Wait until all initially included resources are loaded.
await page.goto("/", {waitUntil: "load"});
// Wait until every resource is loaded and the network is silent for 500ms.
await page.goto("/", {waitUntil: "networkidle"});
When you look at these waitUntil
options, all but commit
heavily rely on the network and the embedded resources. This means all options but commit
check implementation details, potentially slowing down your tests when one request is stuck in the network layer.
How do these options compare in speed? Here are the results for my example test case.
Option | "page.goto" execution time |
commit | 62ms |
domcontentloaded | 159ms |
load | 10.1s |
networkidle | 12.6s |
Of course, I extracted these numbers from a local Playwright test running against a local site. But the absolutes don’t matter. What’s important is to look at the differences between the waitUntil
options.
Unsurprisingly, the commit
waiting option is the fastest goto()
configuration because it does not wait for anything resource-related except the first bytes of the initial HTML. networkIdle
is by far the slowest because it waits for every resource to be loaded and then adds 500ms of idle network time on top.
But here’s a funny thing: All these tests succeeded regardless of the waitUntil
option. The overall test duration for this small test case varied from roughly 10 to 25 seconds, but the tests were all green.
What is going on, and how does this work?
Writing fast tests that don’t rely on the network
In my experience, there are only two reasons for flaky or slow tests: either your application is untestable, or you fail to follow Playwright's best practices.
If the site you want to test isn’t stable, you won’t succeed in creating a stable and fast test suite. You can’t write stable tests for a flaky app — end of story. Similarly, if your site has bad UX and includes poor hydration patterns, your tests will be full of workarounds to succeed on the happy path. Ideally, you would fix these application issues, but I know that this is not always possible.
But if you, on the other hand, aren’t putting the user-first hat on your Playwright scripts, you’ll also be a flakiness offender. The best way to speed up and fight the flake is to rely on auto-waiting and web-first assertions and let Playwright figure out the rest.
The example script from the beginning of this post includes a “simple” click()
instruction. And this click()
is your best friend because it executes magic, aka actionability checks.
// this click() will wait for the login to be
// - visible
// - stable, as in not animating or completed animation
// - able to receives events as in not obscured by other elements
// - enabled
await loginLink.click();
Whenever you call click()
, fill()
or the other actions, Playwright waits until this element is ready for the user. At this point, the element is rendered, visible, stable and enabled.
If your app now provides good UX and renders ready-to-use elements, Playwright will interact with them as quickly as possible. There’s no need to wait for any network requests.
Similarly, if you rely on web-first assertions, Playwright will wait until the UI reaches your desired state. There’s no need to wait for HTML or API calls behind a specific UI state either.
// wait until this element is visible
await expect(loginHeader).toBeVisible();
And if you’re relying on these two core Playwright principles, you can forget the network layer and test what matters — UI actions and the resulting UI state. There’s barely a need to wait for network events and requests.
import { expect, test } from "@playwright/test";
test("Login works", async ({ page }) => {
// don’t wait for all the resources to be loaded
await page.goto("/", {waitUntil: "commit"});
// let Playwright wait for this link to be visible
const loginLink = page.getByRole("link", { name: "Login" });
await loginLink.click();
const loginHeader = page.getByRole("heading", { name: "Login" });
await expect(loginHeader).toBeVisible();
// More things to check after the login link was clicked
// ...
});
Looking at the adjusted script, we’re not waiting for network events but for UI state. Playwright will wait until the “Login” link is visible and click it as fast as possible. And with this minor tweak, we made our test 10s quicker while still covering the core login functionality. Win-win!
But... There’s always a but…
All that said, there are scenarios when you want to keep an eye on the network.
When you adopt end-to-end testing, it’s very common to test preview deployments to avoid core feature regressions. Ideally, your preview and staging environments are production replicas. Unfortunately, that’s rarely the case. Staging is often deployed to a different infrastructure, and the frontend often loads different resources for tracking, user engagement and monitoring.
And these differences might be okay because you only want to test your new feature and avoid regressions. But when you go the extra mile and adopt Playwright to monitor your sites, keeping an eye on all the loaded resources will help you avoid production issues. I’ve seen a third-party script take down production more than once.
For example, we at Checkly monitor checklyhq.com
with Playwright, and like every marketing site, we include Intercom and analytics there. One day, all our synthetic Playwright checks failed because Intercom failed to load in a reasonable time. page.goto()
took more than a minute because of a timing-out JavaScript snippet.
Was checklyhq.com
broken? No, everything was still functioning. Was it good to know that Intercom was having issues? You bet!
Monitoring the network layer can give you valuable insights into your site's overall health and performance.
// fail when all resources aren’t loaded after 10 seconds
await page.goto("/", { waitUntil: "load", timeout: 10_000 });
Pro tip: Checkly aggregates Core Web Vitals in your Playwright scripts for you.
Conclusion
Should you now flip every page.goto()
action to commit
or domcontentloaded
to save some time in CI/CD? The answer is the usual "It depends".
If you value test execution speed, run many tests, and rely on user-first testing with auto-waiting, not waiting for all resources to be loaded will save you minutes, if not hours, in CI/CD. Give it a try, Playwright is very good at figuring out when and what to click.
But remember that a green preview deployment light isn’t giving you a proper safety net. The only thing that matters is a well-running production environment. To ensure that there are no production issues, you must continuously test your live product. This is when synthetic monitoring shines.
And when you're then testing and monitoring your site with Playwright, keeping an eye on slow network dependencies can be very beneficial because you’ll be the first to know when something’s off with your infrastructure, the loaded third-party resources or your application code. That’s the real safety net you need to sleep well at night.
But as always, deciding what makes the most sense is up to you.
If you have any questions or comments, come and say hi in the Checkly community. We’re a lovely bunch — I promise. And I’ll see you there!