“The next assertion runs before the API call triggered by the button click has finished” — this is one of the most common problems in Playwright tests that deal with asynchronous behavior. In Playwright for Python, page.expect_response() waits until a specific API response comes back, and lets you verify its status code and even the contents of its JSON. This article covers the basic syntax, how to specify URLs, how to wait for several requests at once, and the failures that tend to show up in real projects.
The key point is to “declare the wait before you perform the action.” If you reverse the order, the response slips through and you end up with a timeout that is hard to explain.
📌 Who this article is for
- Playwright (Python) users whose tests are unstable because of API timing after a click
- Anyone who currently relies on
time.sleep()ornetworkidleand wants to replace it - Anyone who wants to verify things the screen never shows, such as the API status code or the request body
- Anyone reading the Node.js docs who isn’t sure how to write the same thing in Python
✅ What you’ll get from this article
- The basic form of
expect_response()and the correct order to write it in - When to use a URL pattern, a regular expression, or a predicate function
- How to verify the contents of both the response and the request
👤 About the author
Written by Yoshi, a QA and test automation engineer with more than 15 years of hands-on experience. Has worked on stabilizing UI tests that involve asynchronous processing, using Playwright in real projects.
📌 Conclusion
- In Python, perform the action inside
with page.expect_response(...) as info:and read the response frominfo.value - Declare the wait before the action. Writing it after the action can miss the response
- Naming the API you want to wait for, instead of using
time.sleep()ornetworkidle, makes tests faster and more stable
Tests that wait for an API response tend to run into problems like these:
- The assertion runs right after the click, and fails on a screen that doesn’t have the data yet
- A
time.sleep(3)gets added “just to be safe,” and the whole suite slows down - The screen looks fine, but the API actually returned a 400
- Node.js samples for
waitForResponsedon’t work as-is in Python
expect_response() gives you a clean way to sort these out.
- Where expect_response() Fits
- ① The Basic Form: Declare the Wait First
- ② How to Specify the Response to Wait For
- ③ Verifying the Contents of the Response and Request
- ④ How It Differs from sleep and networkidle
- ⑤ Waiting for Multiple Responses and Setting a Timeout
- Common Failure Patterns in Real Projects
- FAQ
- 📋 Summary
Where expect_response() Fits
Auto-waiting handles the wait for “an element to become actionable,” but waiting for an API response is outside its scope. expect_response() fills that gap as an explicit wait. It also differs from its neighbors: page.route() controls (replaces) network traffic, whereas expect_response() waits for traffic and verifies it. And unlike the DevTools Network tab, which only lets you observe traffic, it runs inside the test itself.
page.waitForResponse(). In Python it is page.expect_response(). For the latest behavior in Python, check the official documentation for the version you use.① The Basic Form: Declare the Wait First
def test_create_order(page):
page.goto("/orders/new")
# ① Declare the wait, and perform the action inside that with block
with page.expect_response("**/api/orders") as response_info:
page.get_by_role("button", name="Place Order").click()
# ② After leaving the with block, take the response and verify it
response = response_info.value
assert response.status == 201
assert response.json()["status"] == "created"❌ The Order You Should Avoid
# ❌ The wait is declared after the click
page.get_by_role("button", name="Place Order").click()
with page.expect_response("**/api/orders") as response_info:
pass # a response that already came back can't be caught, so this times outwith block.② How to Specify the Response to Wait For
| Method | Example | Best suited for |
|---|---|---|
| Glob pattern | "**/api/orders" | A fixed URL you want to specify simply |
| Regular expression | re.compile(r"/api/orders/\d+") | URLs where part of the path, such as an ID, changes every time |
| Predicate function | lambda r: ... | Narrowing by something other than the URL, such as HTTP method or status |
import re
# Regular expression: URLs like /api/orders/123
with page.expect_response(re.compile(r"/api/orders/\d+")) as info:
page.get_by_role("link", name="Order details").click()
# Predicate: only successful responses to a POST
with page.expect_response(
lambda r: "/api/orders" in r.url
and r.request.method == "POST"
and r.ok
) as info:
page.get_by_role("button", name="Place Order").click()GET (list) and a POST (create) hit the same URL, specifying only the URL can pick up the wrong one. When in doubt, narrow it down to the HTTP method with a predicate.③ Verifying the Contents of the Response and Request
with page.expect_response("**/api/orders") as info:
page.get_by_role("button", name="Place Order").click()
response = info.value
# Verify the response
assert response.status == 201
assert "application/json" in response.headers.get("content-type", "")
body = response.json()
assert body["total"] == 1500
# Verify the request that was sent (including what the screen never shows)
request = response.request
assert request.method == "POST"
assert request.post_data_json["quantity"] == 3response.request is a real strength of expect_response().④ How It Differs from sleep and networkidle
| Approach | Problem |
|---|---|
time.sleep(3) | Too short on a slow environment, wasted time on a fast one. Always slow and still unstable |
wait_for_load_state("networkidle") | Never settles when there is polling or a persistent connection. The official docs also discourage it for testing |
expect_response() | Lets you name only the API you care about, and moves on the moment it returns |
expect(locator).to_be_visible().⑤ Waiting for Multiple Responses and Setting a Timeout
# Wait for both of the two APIs that fire when the page loads
with page.expect_response("**/api/user") as user_info, \
page.expect_response("**/api/orders") as orders_info:
page.goto("/dashboard")
assert user_info.value.ok
assert orders_info.value.ok
# For a slow API, extend the timeout for that request only (in milliseconds)
with page.expect_response("**/api/report", timeout=60_000) as info:
page.get_by_role("button", name="Generate Report").click()Common Failure Patterns in Real Projects
① Writing the wait after the action and hitting a timeout
If the response comes back first, it can’t be caught. Always put the action inside the with block.
② A URL pattern that is too broad picks up a different request
A wide pattern such as "**/api/**" ends the wait on the first unrelated response that matches. Specify a concrete path and, where needed, the HTTP method too.
③ Reading info.value inside the with block
The response is only settled after you leave the with block. Do the extraction and assertions outside it.
④ Adding the wait to every test and slowing the run down
There’s no need to add expect_response() where Auto-waiting is enough. Keep it for the cases where “the next assertion depends on the result of the API.”
📖 Related articles
FAQ
Q. Is it the same as waitForResponse in Node.js?
The purpose is the same, but the syntax differs. The Node.js version is usually written together with Promise.all, while the Python version uses a with statement as a context manager.
Q. Can it wait for 404 or 500 responses too?
Yes. expect_response() can capture 404 and 500 responses as long as they match your condition. If you only want successful responses, specify r.ok in the predicate.
Q. Can it wait for responses mocked with page.route()?
Even when you mock a response with page.route(), you can still wait for the same traffic with expect_response(). When verifying a mocked response, though, the intent is clearer if the test is designed to check that what route.fulfill() returned is reflected correctly on screen. For how to build the mock itself, see an article on API mocking with page.route().
Q. What if I only want to confirm that a request was sent, not the response?
You can use page.expect_request(). It is written the same way as expect_response(), and you get the request from info.value.
Q. What is the default timeout?
Around 30 seconds is the usual guideline, but it can change with the version and your configuration. For slow APIs, it is a good idea to pass timeout= in milliseconds for that call alone.
📋 Summary
- In Python, perform the action inside
with page.expect_response(...) as info:and get the result frominfo.value - Declare the wait before the action. Reversing the order can miss the response
- Specify the URL with a glob, a regular expression, or a predicate, and narrowing down to the HTTP method is the safer choice
- Rather than leaning on
sleepornetworkidle, name the API you want to wait for for faster, more stable tests
Waiting for API responses is one of the most frequent causes of flaky tests. As a first step, pick one place where you currently use time.sleep() and try replacing it with expect_response().
