Playwright has shipped BrowserContext.setOffline() for years, and it does exactly what it says: flip the network on and off. But "online" and "offline" are the two ends of a much wider spectrum. Real users sit somewhere in the middle, on hotel Wi-Fi, on a train with a flaky 4G handoff, or on a phone that just dropped down to Edge. Those are the conditions that expose the interesting bugs: skeletons that never resolve, retry loops that pile up, lazy-loaded images that arrive after the user has already scrolled past. None of that shows up in an offline test, and none of it shows up at full speed either. In this post, I'll walk through how I added a network throttling API to Playwright, covering the design choices, the surprisingly small change inside the Chromium driver, the layers a new API has to pass through, and the tests that prove it actually slows things down.
Before writing any code, I went looking for the closest existing analog. setOffline was the obvious one: it's a context-level network emulation toggle, with both an init option (newContext({ offline: true })) and a runtime setter. Whatever shape the new API took, it would want to live next to that one and behave the same way.
The interesting discovery came from reading how setOffline is actually implemented under the hood. In Chromium, it doesn't use a special "go offline" command. It uses the same Chrome DevTools Protocol method that powers the throttling presets in DevTools' own Network panel: Network.emulateNetworkConditions. Here is the exact call Playwright was making inside crNetworkManager.ts:
await info.session.send("Network.emulateNetworkConditions", {
offline: this._offline,
// values of 0 remove any active throttling. crbug.com/456324#c9
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
});
Look at those three hardcoded values. latency: 0, downloadThroughput: -1, uploadThroughput: -1. The protocol already accepts everything I needed; Playwright was just choosing not to use any of it. The entire wire to the browser was already in place. What was missing was a public API to feed real numbers into those three slots, and the plumbing to carry those numbers from the user's test code down to this one CDP call.
That changed the framing of the work. Instead of "add a new feature to Playwright," it became "expose a parameter that Playwright was already hiding." The design question shrank to: what does the public API look like, where does it sit in the existing class hierarchy, and what happens on Firefox and WebKit, which don't have an equivalent CDP method?
I settled on three principles that fell out of mirroring setOffline as closely as possible. First, the new feature should expose both a runtime setter (context.setNetworkConditions(...)) and an init option on newContext and launchPersistentContext, so users can pick whichever fits their test setup. Second, offline and throttling should compose rather than collide, since they are independent states that get combined into the same CDP call, so you can throttle a context and then drop it offline without losing your throttling settings. Third, on browsers where the feature isn't available yet, calling it should throw a clear error rather than silently doing nothing. A quiet no-op is the worst possible outcome for a tester trying to debug why a flaky test isn't actually being throttled.
With that in hand, the API ended up looking like this:
// At context creation:
const context = await browser.newContext({
networkConditions: {
latency: 150,
downloadThroughput: 1_500_000 / 8,
uploadThroughput: 750_000 / 8,
},
});
// At runtime:
await context.setNetworkConditions({ latency: 40 });
// Clear throttling and restore unlimited bandwidth:
await context.setNetworkConditions(null);
Three numbers, all optional, all matching the CDP shape one-for-one. latency is in milliseconds, downloadThroughput and uploadThroughput are in bytes per second, and -1 is the documented "unlimited" sentinel, the same values Playwright was already passing as defaults. No presets, no enums, no helper objects. Anyone who needs Slow 3G can define a constant for it in their own test setup, and anyone who wants something in between two presets isn't boxed in.
That's the design. The next sections walk through the implementation: how the three numbers travel from the test runner, through the protocol layer, into the Chromium driver, and finally out to the browser as a single CDP message.
A new method on BrowserContext looks like one method, but inside Playwright it has to be threaded through four distinct layers before it reaches the browser.
How a setNetworkConditions() call travels from test code to the browser.
There's a documentation layer that doubles as the source of truth for the public types, a protocol layer that defines the wire format between the test process and the browser driver, a server core with an abstract base class that each browser engine extends, and finally the browser-specific code that actually talks to Chromium, Firefox, or WebKit. Adding setNetworkConditions means touching every one of those layers, and each one has its own conventions that are worth understanding even if you never plan to contribute upstream. The same layout explains a lot about why some Playwright features behave consistently across browsers and others quietly differ.
Playwright keeps its public API documented in a set of Markdown files under docs/src/api/. These files aren't just human-readable references; they are parsed by a generator that produces the official TypeScript declarations shipped in playwright-core/types/types.d.ts. If a method isn't documented there, it doesn't exist as far as the TypeScript definitions are concerned. So the very first edit for any new API goes into the docs.
For the runtime method, that meant a new block in class-browsercontext.md right next to setOffline. The format is strict: a heading with a since version, a parameter block with the same since tag, and typed properties. Here is the method block I added:
## async method: BrowserContext.setNetworkConditions
* since: v1.61
Emulates network conditions for the browser context. Pass `null` to clear
emulation and restore unlimited bandwidth.
**NOTE** Network throttling is currently only supported in Chromium.
### param: BrowserContext.setNetworkConditions.networkConditions
* since: v1.61
- `networkConditions` <[null]|[Object]>
- `latency` ?<[float]> Minimum latency added to every request, in milliseconds.
- `downloadThroughput` ?<[float]> Maximum download throughput in bytes/second.
- `uploadThroughput` ?<[float]> Maximum upload throughput in bytes/second.
The init option side lives in docs/src/api/params.md, which holds reusable option templates that get pulled into Browser.newContext and BrowserType.launchPersistentContext through placeholder references. I added a context-option-networkconditions template next to the existing context-option-offline one, then registered it in the shared options list so both context-creation entry points pick it up automatically. Doing it this way meant I didn't have to edit either of those class files directly. The placeholder system handled the propagation.
Playwright separates the test process from the browser driver process, and the two communicate through a custom RPC protocol. That protocol is defined in YAML files under packages/protocol/spec/, and a generator turns those YAML files into a TypeScript declaration file (channels.d.ts) plus a runtime validator. Both the client wrapper and the server-side dispatcher import from the generated file, which means any new method or option has to be declared in YAML before either side can reference it.
Two YAML files needed edits. mixins.yml holds the shared properties for context creation, including the existing offline boolean. I added networkConditions as a nested optional object right next to it. browserContext.yml holds the channel methods, and I added a setNetworkConditions method next to the existing setOffline:
# mixins.yml (shared context-init properties)
offline: boolean?
networkConditions:
type: object?
properties:
latency: float?
downloadThroughput: float?
uploadThroughput: float?
# browserContext.yml (channel method)
setNetworkConditions:
title: Set network conditions
parameters:
networkConditions:
type: object?
properties:
latency: float?
downloadThroughput: float?
uploadThroughput: float?
Running node utils/generate_channels.js regenerates channels.d.ts and the validator, after which both client and server suddenly know about BrowserContextSetNetworkConditionsParams and the new networkConditions field on context options. None of that code is hand-written, which is why editing the generated file directly is a mistake worth avoiding: the next time the generator runs, your changes vanish.
With the protocol regenerated, three small edits connect the three runtime layers. The client side, under packages/playwright-core/src/client/, is the wrapper that user code actually calls. The dispatcher, under src/server/dispatchers/, lives on the server end of the protocol and forwards incoming RPC calls to the server-side context object. The server itself, in src/server/browserContext.ts, holds shared logic and defers browser-specific work to abstract methods that each engine has to implement.
The client method is a one-liner that forwards the call across the channel. The dispatcher is also one line. The interesting code lives on the server, which mirrors the existing setOffline implementation almost exactly, including the rollback pattern that restores the previous value if the underlying driver call fails:
// server/browserContext.ts
protected abstract doUpdateNetworkConditions(): Promise<void>;
async setNetworkConditions(progress, networkConditions) {
const oldNetworkConditions = this._options.networkConditions;
this._options.networkConditions = networkConditions;
try {
await progress.race(this.doUpdateNetworkConditions());
} catch (error) {
this._options.networkConditions = oldNetworkConditions;
// Restore conditions in the background so the context doesn't stay
// in a half-applied state if the caller swallows the error.
this.doUpdateNetworkConditions().catch(() => {});
throw error;
}
}
The abstract doUpdateNetworkConditions is the seam between shared logic and engine-specific behavior. Every browser implementation (Chromium, Firefox, WebKit, plus the experimental BiDi and WebView backends) has to provide one, and that's what forces a clear decision about how each engine handles the feature. There is no way to "forget" to handle it on a browser, because the TypeScript compiler refuses to build if any subclass leaves the abstract method unimplemented. That property is what makes adding a feature like this safe rather than risky.
The Chromium implementation is where the three numbers finally turn into a CDP call. CRNetworkManager already had the wire to the browser (the hardcoded Network.emulateNetworkConditions call from earlier in this post), so the work here was small: store the latest conditions, refactor the helper that sends the CDP message so it reads both _offline and _networkConditions, and add a public method that mutates the stored conditions and re-runs the helper for every active session.
// crNetworkManager.ts
private _networkConditions: types.NetworkConditions = {};
async setNetworkConditions(networkConditions) {
this._networkConditions = networkConditions ?? {};
await this._forEachSession(info => this._emulateNetworkConditionsForSession(info));
}
private async _emulateNetworkConditionsForSession(info, initial) {
if (initial && !this._offline && !this._hasNetworkThrottling())
return;
// Workers are affected by the owner frame's Network.emulateNetworkConditions.
if (info.workerFrame)
return;
await info.session.send("Network.emulateNetworkConditions", {
offline: this._offline,
latency: this._networkConditions.latency ?? 0,
downloadThroughput: this._networkConditions.downloadThroughput ?? -1,
uploadThroughput: this._networkConditions.uploadThroughput ?? -1,
});
}
The shape of this method matters. _offline and _networkConditions are read independently and combined into a single CDP call, which is exactly how the two states compose without colliding. Calling setOffline(true) on an already-throttled context keeps the throttling settings intact; calling setNetworkConditions(null) on a context that is also offline keeps it offline. The initial-skip guard at the top makes sure fresh sessions don't get redundant CDP calls when neither feature is in use, preserving the existing "no overhead unless you opt in" behavior.
From there, three other Chromium files need to call into the network manager: CRPage on page creation and on context updates, CRServiceWorker for service worker contexts, and CRBrowser at the top of doUpdateNetworkConditions, which fans out to every page and service worker in the context. All three follow the exact pattern that already existed for offline mode, which is what made this part of the implementation feel almost mechanical.
For Firefox and WebKit, the abstract method has to exist but the feature doesn't yet. The Juggler protocol Firefox uses only exposes a binary online/offline override, and WebKit's equivalent CDP method only takes a throughput cap without a latency parameter. Rather than partially supporting the API in a way that would silently mislead users on those browsers, both implementations throw a clear error when actual throttling values are passed, while accepting null and empty objects as a no-op so that cross-browser test setups don't blow up just because they reference the API:
// ffBrowser.ts (and an almost identical block in wkBrowser.ts)
async doUpdateNetworkConditions() {
const c = this._options.networkConditions;
if (
c &&
((c.latency ?? 0) > 0 ||
(c.downloadThroughput ?? -1) >= 0 ||
(c.uploadThroughput ?? -1) >= 0)
) {
throw new Error("Network throttling is not yet supported in Firefox.");
}
}
The same guard runs both when the user calls setNetworkConditions at runtime and when the context is created with a networkConditions init option, so the error fires as early as possible. The end result is that the public API behaves consistently across all three browsers in the only way that matters: you get throttling on Chromium and a loud, immediate error everywhere else. With that, every layer is wired up, and the three numbers have a clear path from test code to CDP.
A network throttling feature is only useful if you can prove the throttling actually happens. That sounds obvious, but timing-based assertions are notoriously flaky on CI runners that share CPU with other jobs and run inside container schedulers that can pause a process for hundreds of milliseconds at a time. The tests have to be specific enough to fail when throttling is broken, and loose enough to pass on a busy worker. That tradeoff shaped every assertion in the suite. Each test asserts a lower bound on elapsed time, never an exact value, and the lower bound sits comfortably below the theoretical minimum so the test doesn't flake when the runtime adds a few extra milliseconds of its own.
The simplest case is added latency. Network.emulateNetworkConditions treats latency as a minimum delay added to every request, so a request that would normally complete in a few milliseconds should take at least that many extra milliseconds to finish. Setting latency to 500 milliseconds, firing a fetch from inside the page, and measuring the elapsed time gives a direct read on whether throttling is taking effect:
await context.setNetworkConditions({ latency: 500 });
const start = Date.now();
await page.evaluate(
url => fetch(url, { cache: "no-store" }).then(r => r.text()),
server.EMPTY_PAGE
);
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(400);
The assertion floor sits 100 milliseconds below the configured latency on purpose. CDP rounds the delay, the request-paused event has its own overhead, and the worker process scheduling can shave a handful of milliseconds off the measured elapsed time before Date.now() is read. None of that should make a 500 millisecond delay measure as 350, but giving the assertion a safety margin means the test passes on every machine it runs on without losing its signal. If throttling stops working entirely, the request completes in single-digit milliseconds and the assertion fails by a wide margin.
Throughput is trickier to assert on, because it interacts with payload size in a way that latency doesn't. If the response is small enough, the entire body fits inside the first window of bytes the throttler hands out, and the request finishes almost as fast as it would without throttling. Asserting on a 200-byte empty page with a download cap of 50 KB per second tells you nothing useful. The payload has to be large enough that the throttle has time to take effect.
For the test, that meant a custom route returning 200 KB of plain text. At a 50 KB per second cap, the request should take at least four seconds, which leaves plenty of room for a three-second lower bound:
const payload = "a".repeat(200 * 1024); // 200 KB
server.setRoute("/big-payload", (req, res) => {
res.setHeader("Content-Type", "text/plain");
res.setHeader("Cache-Control", "no-store");
res.end(payload);
});
await context.setNetworkConditions({ downloadThroughput: 50 * 1024 });
const start = Date.now();
await page.evaluate(
url => fetch(url, { cache: "no-store" }).then(r => r.text()),
server.PREFIX + "/big-payload"
);
expect(Date.now() - start).toBeGreaterThanOrEqual(3000);
The Cache-Control: no-store header on both the route and the fetch matters. Without it, the browser can serve a cached copy on subsequent runs of the same test file and the throttle never has a chance to fire. Skipping the cache headers was the kind of mistake that produces a green test today and a green test next year, while the feature it's supposed to protect is silently regressing.
The runtime setter accepts null as a way to reset throttling and restore unlimited bandwidth. That sounds trivial, but a subtle bug here would be very expensive: throttling that leaked from one test into the next would slow down unrelated tests just enough to cause flakes, without anyone realizing the source was a missing clear call. So the test has to actually prove that null restores normal speeds, not just that the call doesn't throw.
The structure of the test mirrors a controlled before-and-after: throttle hard, measure the slow case, clear, measure the fast case, assert that the second measurement is comfortably below the first:
await context.setNetworkConditions({ latency: 1000 });
const slowStart = Date.now();
await page.evaluate(
url => fetch(url, { cache: "no-store" }).then(r => r.text()),
server.EMPTY_PAGE
);
expect(Date.now() - slowStart).toBeGreaterThanOrEqual(800);
await context.setNetworkConditions(null);
const fastStart = Date.now();
await page.evaluate(
url => fetch(url, { cache: "no-store" }).then(r => r.text()),
server.EMPTY_PAGE
);
expect(Date.now() - fastStart).toBeLessThan(500);
Using one second of added latency rather than a smaller value gives the test a wider gap to assert against. The slow request has to be at least 800 milliseconds, and the fast request has to be under 500. A bug that left throttling partially active would land somewhere in between and fail both bounds. A bug that cleared throttling but accidentally also disabled the network would still fail the second assertion because fetch would throw rather than resolve.
The most subtle property of the design is that offline and networkConditions are stored independently and combined on every CDP send. That means setting one shouldn't silently reset the other. A test for that property has to exercise both states in sequence and verify that the throttling survives an offline toggle:
const context = await browser.newContext({
networkConditions: { latency: 100 },
});
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
await context.setOffline(true);
let error = null;
await page
.evaluate(url => fetch(url, { cache: "no-store" }), server.EMPTY_PAGE)
.catch(e => (error = e));
expect(error).toBeTruthy();
await context.setOffline(false);
const start = Date.now();
await page.evaluate(
url => fetch(url, { cache: "no-store" }).then(r => r.text()),
server.EMPTY_PAGE
);
// Latency throttling should still apply after coming back online.
expect(Date.now() - start).toBeGreaterThanOrEqual(80);
The test also doubles as a check that the init option survives the round trip from the test process to the browser driver. The context is created with the throttling already applied, never explicitly set at runtime, and the final assertion still measures the added latency. If the init option were being dropped somewhere in the protocol layer, this test would fail before the offline toggle ever ran.
Two more tests cover the Firefox and WebKit cases, asserting that calling setNetworkConditions with active throttling values throws the expected error and that creating a context with a networkConditions init option fails fast on those browsers. They use the same it.skip(browserName === "chromium") pattern in reverse to run only on the engines where the feature isn't supported. Together with the five Chromium tests, that gives the suite full coverage of every code path the implementation introduced, on every browser Playwright ships.
Adding an API to a mature project is rarely about writing clever code. It's about finding the right seam, matching existing patterns, and being honest about what's left unfinished. A few things stood out while working through this change that are worth carrying into the next one, regardless of which framework or codebase it lands in.
The biggest accelerator was reading the existing analog before designing anything new. setOffline wasn't just a similar feature; it was a complete reference implementation for the exact shape of plumbing the new feature needed. Following its structure across every layer meant the API ended up consistent with the rest of the framework without anyone having to argue about conventions, and the actual change was small enough that reviewers could check it against the analog rather than evaluate a new pattern from scratch. When a codebase already has a working version of the thing you're about to build, copying its skeleton is almost always cheaper than improving on it.
Generated code is unforgiving in a way that feels harsh until you trust it. The TypeScript declarations in types.d.ts and the protocol shapes in channels.d.ts aren't meant to be edited by hand. The fastest way to learn this is to try, lose the changes on the next generator run, and then go find the YAML and Markdown sources that actually drive them. Once that mental model clicks, the layered structure stops feeling like extra ceremony and starts feeling like a guard rail. You couldn't accidentally leave a layer out of sync if you tried.
Failing loud on unsupported browsers is almost always the right call. A silent no-op feels polite while you're writing the code, because it lets cross-browser test suites keep running without an extra check. But once that no-op is in production, every flaky network-dependent test on Firefox or WebKit becomes a potential mystery, because the throttling the test thought it had set never actually applied. A clear, immediate error tells the user exactly what happened and where, which is a better experience than letting them debug ghosts. The cost of the loud failure is one extra if branch in test setup. The cost of the silent one is paid by everyone who comes after you.
The last lesson was about restraint. The first version of the design considered shipping a small set of named presets (Slow3G, Fast3G, and so on) because Chrome DevTools has them and the values are well-known. The decision to leave presets out came down to a simple question: would those constants make the codebase better, or just add a surface that has to be maintained forever, with occasional debates about whether the numbers are still accurate three years from now? Anyone who needs Slow 3G can define a single constant in their own test setup in about ten seconds. The framework doesn't need to own that. Saying no to a feature that would have been easy to add but hard to remove was the most useful design decision in the whole change.
Adding network throttling to Playwright turned out to be less about writing new logic and more about exposing capability the framework already had, waiting for a public API to call it. The interesting work lived in the design choices: mirroring an existing pattern, composing two states in a single CDP call, and being honest about which browsers can actually deliver the feature today. The end result is a single method that closes one of the most common real-world test gaps, slow networks, without bloating Playwright's surface area.
The full code changes from this work are available on our GitHub repository. Until next time!