08 · Performance & Tracing¶
Playwright can capture real browser performance data — not just "did it load" but "how long did it take, and where did the time go" — using the Chrome DevTools Protocol (CDP) and the browser's own Performance/Navigation Timing APIs, no external tooling required.
Navigation timing via page.evaluate¶
def test_homepage_load_performance(page):
page.goto("/")
timing = page.evaluate("""() => {
const nav = performance.getEntriesByType('navigation')[0];
return {
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
loadComplete: nav.loadEventEnd - nav.startTime,
ttfb: nav.responseStart - nav.startTime,
};
}""")
assert timing["ttfb"] < 500, f"TTFB too slow: {timing['ttfb']}ms"
assert timing["domContentLoaded"] < 2000, f"DCL too slow: {timing['domContentLoaded']}ms"
# performance.getEntriesByType('navigation') is a standard
# browser API returning precise timestamps for every phase of
# page load — running it through page.evaluate() gets real
# browser-measured numbers back into Python as plain data
Core Web Vitals: LCP, CLS, and friends¶
def test_largest_contentful_paint(page):
page.goto("/")
lcp = page.evaluate("""() => new Promise(resolve => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
resolve(entries[entries.length - 1].startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
})""")
assert lcp < 2500, f"LCP too slow: {lcp}ms"
# LCP only fires once, asynchronously, so this wraps a
# PerformanceObserver in a Promise and awaits it inside
# page.evaluate() — buffered: true lets it retrieve an entry
# that already happened before the observer was attached
CDP session: network and CPU throttling¶
def test_page_usable_on_slow_network(page):
client = page.context.new_cdp_session(page)
client.send("Network.emulateNetworkConditions", {
"offline": False,
"downloadThroughput": 400 * 1024 / 8, # 400kbps
"uploadThroughput": 400 * 1024 / 8,
"latency": 400,
})
page.goto("/")
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible(timeout=15000)
# new_cdp_session() opens a raw Chrome DevTools Protocol
# channel — Network.emulateNetworkConditions throttles the
# actual network stack the browser uses, which is far more
# realistic than route-based delay injection because it
# affects every resource (JS, CSS, images) proportionally,
# not just the ones you've explicitly mocked
def test_page_usable_on_throttled_cpu(page):
client = page.context.new_cdp_session(page)
client.send("Emulation.setCPUThrottlingRate", {"rate": 4}) # 4x slower CPU
page.goto("/dashboard")
expect(page.get_by_text("Loading complete")).to_be_visible(timeout=10000)
Tracing for performance, not just failures¶
Level 2 covered tracing for debugging failures; the same trace also records a full CPU/network timeline useful for spotting performance regressions:
def test_checkout_flow_traced(page, context):
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page.goto("/checkout")
page.get_by_role("button", name="Place order").click()
expect(page.get_by_text("Order confirmed")).to_be_visible()
context.tracing.stop(path="checkout-trace.zip")
# the Trace Viewer's timeline includes a CPU/network graph
# alongside the action log — a long gap between two actions
# with no network activity underneath usually means expensive
# synchronous JS, which the "Call" and "Network" tabs at that
# timestamp can pinpoint
Full worked example: a performance budget test¶
# tests/test_performance_budget.py
import pytest
BUDGETS = {
"/": {"ttfb": 500, "dcl": 2000},
"/dashboard": {"ttfb": 700, "dcl": 3000},
}
@pytest.mark.parametrize("path", BUDGETS.keys())
def test_page_meets_performance_budget(page, path):
page.goto(path)
timing = page.evaluate("""() => {
const nav = performance.getEntriesByType('navigation')[0];
return {
ttfb: nav.responseStart - nav.startTime,
dcl: nav.domContentLoadedEventEnd - nav.startTime,
};
}""")
budget = BUDGETS[path]
assert timing["ttfb"] < budget["ttfb"], f"{path} TTFB {timing['ttfb']}ms exceeds {budget['ttfb']}ms"
assert timing["dcl"] < budget["dcl"], f"{path} DCL {timing['dcl']}ms exceeds {budget['dcl']}ms"
# treating performance numbers as an explicit, parametrized
# budget per route turns "the site feels slower lately" into a
# CI failure with a specific route and specific metric attached,
# the same way a functional assertion turns a bug report into
# a specific failing line
How It Actually Works¶
page.context.new_cdp_session(page) is the module's most direct exposure
of what every other API in this site abstracts away: it hands you a raw CDP
session object, letting you call CDP domains/methods (Network.
emulateNetworkConditions, Emulation.setCPUThrottlingRate) that have no
higher-level Playwright wrapper at all. Network.emulateNetworkConditions
works by throttling traffic inside the browser's own network stack — the
same layer real requests flow through — rather than delaying route handlers
in Playwright's own process (Level 2 Module 4's page.route approach),
which is exactly why it affects every resource (subresources, fonts,
images fetched by the browser directly) proportionally instead of only the
specific patterns you've written route() handlers for.
The PerformanceObserver/performance.getEntriesByType('navigation') calls
run as plain JavaScript inside the page via Runtime.evaluate — Playwright
isn't measuring anything itself here; it's retrieving numbers the browser's
own Navigation Timing and Performance Observer APIs already computed
internally as part of normal page-load instrumentation, then marshaling the
JSON-serializable result back to Python. context.tracing.start(...)
overlaps with this: the trace's CPU/network graph is built from the same
underlying Tracing.start CDP domain Chrome DevTools itself uses for its
Performance panel, recording detailed timeline events (paint, script
execution, network) continuously rather than sampling discrete metrics on
demand — which is why a trace can show where time went between two
actions, while a single performance.getEntriesByType() call only gives
you the endpoint numbers.
Exercise¶
- Write a test that reads Navigation Timing data via
page.evaluate()and asserts a TTFB budget on a real page. - Capture LCP with a
PerformanceObserverwrapped in aPromiseand assert it's under 2500ms. - Open a CDP session and throttle the network with
Network.emulateNetworkConditions; confirm a page that loads instantly at full speed now visibly takes longer, and that your test's timeout still accounts for that. - Define a per-route performance budget dict like the one above for at least two routes in your app, and parametrize a single test function across both.