How to simulate slow network in chrome
Drive Chrome over the DevTools Protocol, call Network.setCacheDisabled with cacheDisabled true, then Network.emulateNetworkConditions with the Slow 3G values. A local page that loaded in 45 ms took 10398 ms. With the cache left on, the same throttled reload finished in 14 ms and measured nothing at all.
Why check this
Throttling is how you find the code that only fails on a slow connection: a fetch with a one second timeout, a spinner that never appears because the response arrives in four milliseconds, a script that races a font. Run it before releasing a change to the loading path, and again whenever a request joins the critical path.
The failure it prevents is a timeout nobody can hit on a developer machine. In step 3 the same in-page request finishes unthrottled and aborts under Slow 3G, which is the whole reason the test exists.
Throttling also flatters you in two directions. A reload that hits the cache is fast whatever the profile says, and an endpoint that spends 200 ms of its own work looks identical to one that spends none. Steps 2 and 4 measure both.
Prerequisites
- Node 22 and Chrome 120 or later.
npm i puppeteer-coredrives the Chrome already on the machine. Theopen()launcher used below is printed in full in How to check if a service worker is registered. - The Network domain reference documents
emulateNetworkConditionsandsetCacheDisabled. The DevTools Network panel sets the same two through its throttling dropdown and its "Disable cache" checkbox. - The target, saved as
target.mjs. It serves a page with one 300 KB script, a fast endpoint and one that spends 200 ms of server time.
import { createServer } from 'node:http';
const filler = 'x'.repeat(300 * 1024); // 300 KB of script
const page = `<!doctype html><html><head><title>throttle target</title></head>
<body><h1>throttle target</h1><script src="/big.js"></script></body></html>`;
createServer((req, res) => {
const cached = { 'cache-control': 'max-age=600' };
const json = { 'content-type': 'application/json', 'cache-control': 'no-store' };
if (req.url === '/big.js') {
return res.writeHead(200, { 'content-type': 'text/javascript', ...cached }).end(`/*${filler}*/`);
}
if (req.url === '/fast') return res.writeHead(200, json).end('{"ok":true}');
if (req.url === '/slow') { // 200 ms of server work
return setTimeout(() => res.writeHead(200, json).end('{"ok":true}'), 200);
}
return res.writeHead(200, { 'content-type': 'text/html', ...cached }).end(page);
}).listen(9692, '127.0.0.1', () => console.log('target listening on http://127.0.0.1:9692'));
- Every number below is one capture, Chrome 152.0.7977.76 on Windows 11, on 2026-09-12, against a server on loopback. The shape of the result holds. The milliseconds do not travel.
Steps
- Step 1.
Start the target on a port you own.
node target.mjstarget listening on http://127.0.0.1:9692 - Step 2.
Load the page three ways: unthrottled, throttled, and throttled with the cache left on.
import { open } from '../../scripts/browser/session.mjs'; import { PredefinedNetworkConditions } from 'puppeteer-core'; const URL_ = 'http://127.0.0.1:9692/'; const slow = PredefinedNetworkConditions['Slow 3G']; console.log('Slow 3G profile:', JSON.stringify(slow)); const s = await open(); try { console.log('Chrome:', await s.browser.version()); await s.cdp.send('Network.enable'); const load = async () => s.page.evaluate(() => { const n = performance.getEntriesByType('navigation')[0]; const js = performance.getEntriesByType('resource').find((r) => r.name.endsWith('/big.js')); return { loadEventEnd: Math.round(n.loadEventEnd), js_duration: Math.round(js.duration), js_transfer: js.transferSize, }; }); const off = { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }; const on = { offline: false, latency: slow.latency, downloadThroughput: slow.download, uploadThroughput: slow.upload, }; await s.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); await s.cdp.send('Network.emulateNetworkConditions', off); await s.goto(URL_); console.log('A no throttling, cache disabled :', JSON.stringify(await load())); await s.cdp.send('Network.emulateNetworkConditions', on); await s.goto(URL_, { timeout: 120000 }); console.log('B Slow 3G, cache disabled :', JSON.stringify(await load())); await s.cdp.send('Network.setCacheDisabled', { cacheDisabled: false }); await s.goto(URL_, { timeout: 120000 }); // fills the cache await s.goto(URL_, { timeout: 120000 }); console.log('C Slow 3G, cache enabled :', JSON.stringify(await load())); } finally { await s.close(); }Slow 3G profile: {"download":50000,"upload":50000,"latency":2000} Chrome: Chrome/152.0.7977.76 A no throttling, cache disabled : {"loadEventEnd":45,"js_duration":5,"js_transfer":307504} B Slow 3G, cache disabled : {"loadEventEnd":10398,"js_duration":8338,"js_transfer":307504} C Slow 3G, cache enabled : {"loadEventEnd":14,"js_duration":0,"js_transfer":0}Row C is the trap. The profile is still applied, the transfer size is zero, and the load is faster than the unthrottled one in row A. A throttled reload that hits the cache measures the disk, not the connection.
- Step 3.
Time one request, two sequential requests, two parallel ones, the server-delayed endpoint, and a fetch with a one second timeout, under each condition.
const probe = () => s.page.evaluate(async () => { const ms = async (fn) => { const t = performance.now(); await fn(); return Math.round(performance.now() - t); }; const one = await ms(() => fetch('/fast', { cache: 'no-store' })); const two = await ms(async () => { await fetch('/fast', { cache: 'no-store' }); await fetch('/fast', { cache: 'no-store' }); }); const par = await ms(() => Promise.all([fetch('/fast', { cache: 'no-store' }), fetch('/fast', { cache: 'no-store' })])); const srv = await ms(() => fetch('/slow', { cache: 'no-store' })); let timeout = 'no timeout'; try { await fetch('/big.js', { cache: 'no-store', signal: AbortSignal.timeout(1000) }); } catch (e) { timeout = e.name + ': ' + e.message; } return { one, two, par, srv, timeout }; }); await s.cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await s.goto('http://127.0.0.1:9692/'); console.log('no throttling:', JSON.stringify(await probe(), null, 0)); await s.cdp.send('Network.emulateNetworkConditions', { offline: false, latency: slow.latency, downloadThroughput: slow.download, uploadThroughput: slow.upload }); await s.goto('http://127.0.0.1:9692/', { timeout: 120000 }); console.log('Slow 3G :', JSON.stringify(await probe(), null, 0));no throttling: {"one":4,"two":4,"par":3,"srv":213,"timeout":"no timeout"} Slow 3G : {"one":2044,"two":4082,"par":2076,"srv":2032,"timeout":"TimeoutError: signal timed out"}Read the numbers as pairs. The 2000 ms latency lands on each request: two in sequence cost 4082 ms, two in parallel cost 2076 ms. The timeout is the finding, because it exists only in the second row.
- Step 4.
Repeat the comparison between the fast endpoint and the one that does 200 ms of server work.
const r = await s.page.evaluate(async () => { const ms = async (u) => { const t = performance.now(); await fetch(u, { cache: 'no-store' }); return Math.round(performance.now() - t); }; const out = { fast: [], slow: [] }; for (let i = 0; i < 3; i++) { out.fast.push(await ms('/fast')); out.slow.push(await ms('/slow')); } return out; }); console.log('Slow 3G, /fast (0 ms of server work) :', r.fast.join(' , '), 'ms'); console.log('Slow 3G, /slow (200 ms of server work):', r.slow.join(' , '), 'ms');Slow 3G, /fast (0 ms of server work) : 2028 , 2054 , 2021 ms Slow 3G, /slow (200 ms of server work): 2042 , 2021 , 2035 msThe 200 ms of server work has disappeared. Chrome holds the request for the emulated latency and the server answers inside that window, so the two costs overlap instead of adding. Unthrottled, the same two endpoints measured 4 ms and 213 ms. To model a slow endpoint, delay it in your own stub, as
/slowdoes here. - Step 5.
Check which parameters this Chrome accepts, before writing a test that depends on one.
for (const extra of [{ packetLoss: 20 }, { packetReordering: true }, { connectionType: 'cellular3g' }]) { try { await s.cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 100, downloadThroughput: 50000, uploadThroughput: 50000, ...extra }); console.log(JSON.stringify(extra), '-> accepted'); } catch (e) { console.log(JSON.stringify(extra), '-> rejected:', e.message.split('\n')[0]); } }{"packetLoss":20} -> accepted {"packetReordering":true} -> accepted {"connectionType":"cellular3g"} -> accepted - Step 6.
Accepted is not applied. Set packet loss to 100 and send three requests.
await s.cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: 50000, uploadThroughput: 50000, packetLoss: 100 }); const r = await s.page.evaluate(async () => { const out = []; for (let i = 0; i < 3; i++) { const t = performance.now(); try { const res = await fetch('/fast', { cache: 'no-store' }); out.push(res.status + ' in ' + Math.round(performance.now() - t) + ' ms'); } catch (e) { out.push('failed: ' + e.message + ' in ' + Math.round(performance.now() - t) + ' ms'); } } return out; }); console.log('packetLoss 100:', r.join(' | '));packetLoss 100: 200 in 31 ms | 200 in 33 ms | 200 in 28 msTotal loss was accepted without an error and dropped nothing over loopback. Treat throttling as bandwidth and delay only, and prove any other parameter on your own build before a test leans on it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| transferSize 0 on the throttled load | The response came from the cache | Send Network.setCacheDisabled with cacheDisabled true, or tick "Disable cache", and reload. |
| Two sequential requests cost twice the latency | The delay is applied per request | Count the requests on the critical path. Parallel ones pay it once in wall time. |
| A server-side delay that vanishes under throttling | The emulated wait covers the server's own time | Measure endpoint time unthrottled, and delay the endpoint in a stub to test it. |
| A timeout that fires only in the throttled run | The client's deadline is shorter than a real connection | Keep the case in the suite. It is the reason to throttle. |
| Identical figures across repeats | The profile is not applied, or the page is cached | Print the profile you sent, as step 2 does, and check transferSize. |
Thresholds
Common mistakes
What to check next
- How to test with a mock server: the stub that owns the request-level delay throttling cannot produce.
- How to test offline mode in chrome devtools: the same CDP domain with
offlineset instead of a profile. - How to check TTFB: the server-side number that throttling hides.
- How to check LCP of a page: the metric worth re-reading once a profile is applied.
- How to check page size: the bytes that decide how long a throttled load takes.
FAQ
Does throttling in DevTools affect other tabs?
No. The conditions are set per page target, so another tab in the same Chrome loads at full speed. A script that drives several pages has to send Network.emulateNetworkConditions on each session it wants throttled.
How do I turn throttling off again?
Send the command with latency 0 and both throughput fields set to -1, which means no limit. Closing the DevTools window also clears it, and so does closing the CDP session, which is why a driver script must measure before it detaches.
Why is my throttled number different every run?
The profile fixes bandwidth and delay, not the machine. Three repeats of the same request here varied by 33 ms. Compare a throttled run against an unthrottled run from the same session, and never against a number from another day.
Can I test a dropped connection this way?
Not with a profile. offline true cuts everything at once, and packet loss did nothing in step 6. For a connection that dies mid-response, destroy the socket in a stub server and assert on what the client throws.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny