Fix Puppeteer net::ERR_ABORTED: causes and solutions
Puppeteer throws net::ERR_ABORTED on downloads, 204 responses, and aborted requests. These are the real causes and code fixes that work in production.
Your Puppeteer script crashes with net::ERR_ABORTED, and the frustrating part is that the page loads fine in a regular browser. I wasted two hours on this the first time I hit it, because I assumed it was a network problem. It wasn't.
net::ERR_ABORTED is not a network error. Chromium generates it internally when it decides to cancel a load. No server refused the connection, no DNS lookup failed. The browser itself chose to stop. net::ERR_ABORTED is Chrome's way of saying "I changed my mind." Once you get that, the debugging approach flips completely.
What net::ERR_ABORTED Actually Means in Puppeteer
Regular HTTP errors like 404 or 500 still fire Puppeteer's requestfinished event. The server responded, Puppeteer got the response, and your script continues normally. ERR_ABORTED fires requestfailed instead, because Chromium killed the request before any meaningful response arrived.
There are half a dozen distinct causes, and they look identical in the error output. That's what makes this error so annoying to debug. A file download, a CSP violation, and a race condition between two navigations all produce the exact same net::ERR_ABORTED string.
Before Puppeteer v24.3.0 (February 2025), aborted navigations always threw. PR #13621 changed that behavior so aborted navigations no longer throw by default. If you're on an older version and can upgrade, that alone might fix your issue. But if you need to understand why the abort happens in the first place, read on.
Playwright throws the same error under the hood. Chromium's network stack is shared, so if you've migrated from Puppeteer to Playwright (or vice versa), net::ERR_ABORTED follows you. The causes and fixes below apply to both libraries.
File Downloads That Kill Your Navigation
This one drove me nuts the first time. You call page.goto() on a URL, and it throws ERR_ABORTED. The URL works perfectly in Chrome. What gives?
If the server responds with Content-Disposition: attachment, Chrome doesn't render anything. It triggers a download instead, and cancels the page navigation. The download actually succeeds. Puppeteer just doesn't know what to do with it, because page.goto() expected a page, not a file.
// This will throw ERR_ABORTED on any download URL
await page.goto('https://example.com/report.pdf');
// Fix: tell Chrome where to save downloads via CDP
const client = await page.createCDPSession();
await client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: '/tmp/downloads',
});
// Now wrap goto in a try-catch and check for the abort
try {
await page.goto('https://example.com/report.pdf', { timeout: 10000 });
} catch (err) {
if (err.message.includes('net::ERR_ABORTED')) {
console.log('Download triggered — check /tmp/downloads');
} else {
throw err;
}
}
The catch block tells you the download started, but not when it finishes. Use CDP's Page.downloadProgress event to detect completion. Without that, you're guessing whether the file is fully written before you try to read it.
One thing to watch: PDF files specifically behave differently depending on your headless mode. In old headless (headless: true), Chrome can't render the PDF viewer, so it aborts. In new headless (headless: 'new'), it downloads instead. If you're still running old headless, switching to headless: 'new' is worth trying before anything else.
HTTP 204 and the Empty Response Trap
A 204 No Content response means the server handled your request successfully but has nothing to send back. Browsers handle this gracefully in normal browsing. Puppeteer does not.
When page.goto() hits a 204, Chromium aborts because there's literally nothing to render. No HTML, no DOM, no page. The navigation gets canceled internally.
// Capture the response before the abort kills it
let lastResponse = null;
page.on('response', (response) => {
if (response.url() === targetUrl) {
lastResponse = response;
}
});
try {
await page.goto(targetUrl);
} catch (err) {
if (err.message.includes('net::ERR_ABORTED') && lastResponse?.status() === 204) {
console.log('Server returned 204 — nothing to render');
// Handle the 204 case: maybe the URL redirected or changed
}
}
If you're screenshotting user-submitted URLs, you'll hit this eventually. Health check endpoints, API callbacks, webhook receivers — they all return 204. Validate the URL's Content-Type with a HEAD request before sending Puppeteer there.
Navigation Canceled by Another Navigation
Two navigations can't run at the same time on the same page. If you trigger a second page.goto() while the first is still loading, Chromium cancels the first one with ERR_ABORTED. The first one just dies.
The most common way this happens is looping through URLs with forEach:
// BROKEN: all navigations fire in parallel, each one cancels the previous
urls.forEach(async (url) => {
await page.goto(url); // ERR_ABORTED on all but the last
});
// FIXED: sequential execution with for...of
for (const url of urls) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: `screenshot-${Date.now()}.png` });
}
forEach doesn't await async callbacks. Every iteration fires immediately, stacking navigations on top of each other. This is a JavaScript fundamentals issue, not a Puppeteer bug, but it shows up here constantly because page.goto() is where the error surfaces. Search [puppeteer] ERR_ABORTED forEach on Stack Overflow and you'll find dozens.
A subtler version happens when clicking a link that triggers navigation while a page.goto() is still resolving. Use the Promise.all pattern to synchronize them:
// Click a button that causes navigation
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.click('#submit-button'),
]);
If you need the page to fully settle before taking a screenshot, including lazy-loaded images and async widgets, the wait for full page load post covers the different waitUntil strategies in detail.
Mixed Content and CSP Blocks
An HTTPS page that tries to load an HTTP resource gets blocked by Chrome's mixed content policy. The sub-resource request fires ERR_ABORTED, but here's the tricky part: these failures don't show up in Puppeteer's normal requestfailed event with useful context. You need CDP's Network.loadingFailed to see the blockedReason field.
const client = await page.createCDPSession();
await client.send('Network.enable');
client.on('Network.loadingFailed', (params) => {
if (params.blockedReason) {
console.log(`Blocked: ${params.blockedReason} — ${params.type}`);
}
if (params.canceled) {
console.log(`Canceled: ${params.errorText}`);
}
});
await page.goto('https://example.com');
CSP bypass is usually fine for headless screenshot work. You're capturing a visual snapshot, not running a security audit. If mixed content is breaking your screenshots (images not loading, fonts missing), you have two options. One is page.setBypassCSP(true), which disables Content Security Policy enforcement entirely. Quick fix, but it changes the page behavior. The other option is to block the offending resources so they don't trigger the error at all:
// Bypass CSP entirely
await page.setBypassCSP(true);
await page.goto('https://example.com');
For a more aggressive approach, launching Chrome with --disable-web-security disables both CSP and mixed-content blocking entirely. Only use this in isolated headless environments where security doesn't matter. If you're dealing with other types of blank white screenshots where resources fail to load, that post covers additional causes beyond CSP.
Request Interception Side Effects
Enabling setRequestInterception(true) means you're responsible for handling every single request that passes through. Miss one, and it hangs. Abort one incorrectly, and it can cascade.
There's a known Puppeteer bug (Issue #5688) where calling request.abort() on one request can cancel additional requests that share the same connection. You block a font, and suddenly a stylesheet goes down with it.
await page.setRequestInterception(true);
page.on('request', (request) => {
const type = request.resourceType();
// Block media to avoid headless audio issues
if (['media', 'font'].includes(type)) {
request.abort();
return;
}
// CRITICAL: always continue unhandled requests
request.continue();
});
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
That request.continue() at the bottom is easy to forget, especially when you add new conditions over time and accidentally create a code path where neither abort() nor continue() gets called. Every request must resolve. No exceptions.
Audio and video resources deserve special mention. In headless mode, Chrome hardcodes --mute-audio, and media requests returning 206 Partial Content often get ERR_ABORTED. Blocking media resource types upfront avoids a whole class of sporadic failures. For production screenshot pipelines, I also block font resources when typography doesn't matter. It cuts load time and eliminates another abort source. If you're using a wait-for-selector approach to capture specific elements, blocking fonts can speed up selector resolution too.
Why Does It Only Fail Half the Time?
Some ERR_ABORTED errors only happen about 50% of the time. Same URL, same code, different outcomes on each run. This is the hardest variant to debug because there's no single broken thing to fix.
External protocol navigations (mailto: links, tel: links) triggered by page JavaScript can cancel pending loads mid-flight. Third-party scripts that fire at unpredictable times introduce race conditions. A script that redirects after a 2-second delay might collide with your navigation's settling phase.
For these cases, I wrap the navigation in a retry with a short delay between attempts:
async function gotoWithRetry(page, url, options = {}, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30000,
...options,
});
} catch (err) {
if (err.message.includes('net::ERR_ABORTED') && i < retries - 1) {
console.log(`Attempt ${i + 1} aborted, retrying...`);
await new Promise((r) => setTimeout(r, 1000));
continue;
}
throw err;
}
}
}
const response = await gotoWithRetry(page, 'https://example.com');
Not elegant, but it works. Retries with a one-second gap give the page's timing-sensitive scripts a fresh start. I don't have a clean solution for the underlying race condition. Nobody does, because it's a Chromium implementation detail, not a bug you can patch from the outside.
Stop Treating Every Puppeteer ERR_ABORTED as Fatal
Most ERR_ABORTED errors are harmless. A sub-resource failed to load, but the page rendered fine. A font got blocked, but the text is still there in a fallback typeface. Only navigation-level aborts (the ones from page.goto()) actually break your workflow.
Set up a listener that logs aborts without crashing your process:
page.on('requestfailed', (request) => {
const failure = request.failure();
if (failure && failure.errorText === 'net::ERR_ABORTED') {
// Log but don't throw — most aborts are harmless sub-resources
console.log(`Aborted: ${request.resourceType()} — ${request.url().slice(0, 80)}`);
}
});
// Your goto stays clean
const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
if (response && response.ok()) {
await page.screenshot({ path: 'output.png' });
}
Checking response.ok() catches HTTP errors (4xx/5xx), while the requestfailed listener catches aborts separately. Together they cover both failure modes without your script dying on a blocked font file. If your page.goto() is also hitting navigation timeout errors, the timeout post covers five additional causes specific to that error. For a broader look at how screenshot APIs handle these failures compared to running your own browser, the Puppeteer vs screenshot API comparison breaks down the tradeoffs.
One more thing: request.failure() can return null. Always check before accessing .errorText. I've seen production pipelines crash not on the original error, but on an unhandled TypeError from reading a property on null.
Skip the Browser Entirely
Every cause above comes back to the same fundamental problem: you're managing a browser process, and that browser has opinions about which loads to keep and which to cancel. Mixed content policies, download triggers, protocol navigations, CSP rules — all of them are the browser's decisions, not yours.
That batch job I mentioned — 3,000 URLs nightly — stopped failing when I moved the browser management off my server. A few dozen URLs that broke every run for different reasons (downloads, 204s, CSP blocks) just started returning screenshots because the error handling lived somewhere else. The API absorbs all of it before your code ever sees a problem, so your code stays a single HTTP call:
const response = await fetch(
'https://api.screenshotrun.com/v1/screenshots/capture?url=https://example.com&format=png',
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
const screenshot = await response.arrayBuffer();
Every URL returns either a screenshot or a structured error that tells you exactly what went wrong — no more decoding CDP abort reasons. No CDP sessions, no retry loops. If you're weighing whether to keep maintaining your own Puppeteer setup, my build vs buy breakdown goes into the cost math.
Quick Reference: Cause → Fix
Pin this for later.
- File download: use CDP
Page.setDownloadBehaviorand catch the abort. - HTTP 204: detect with a response listener and validate URLs before navigating.
- Competing navigations: switch from
forEachtofor...of. - Mixed content:
page.setBypassCSP(true)or block offending resources. - Request interception: make sure every request path calls
continue()orabort(). - Sporadic failures: retry with a delay.
- Puppeteer older than v24.3.0: upgrade first — the fix might already be shipped.
If your aborted navigation leaves behind a protocol error on screenshot capture, that's usually the page context dying after the abort. And if you're running headless Chrome in Docker and seeing ERR_CONNECTION_REFUSED instead, that's a different problem entirely — container networking, not browser internals.
Frequently Asked Questions
net::ERR_ABORTED is an internal Chromium error, not a network failure. The browser itself cancels a load — for example when a URL triggers a file download, returns HTTP 204, or when a second navigation interrupts the first. Regular HTTP errors like 404 or 500 do not produce this error.
When a URL responds with Content-Disposition: attachment, Chrome cancels the page navigation and starts a file download instead. page.goto() throws ERR_ABORTED because it expected a rendered page. Fix this with CDP Page.setDownloadBehavior and a try-catch around goto.
Every intercepted request must call either request.continue(), request.respond(), or request.abort(). If any code path skips all three, the request stalls and can trigger ERR_ABORTED on subsequent navigations. Also note that request.abort() can cascade and cancel unrelated requests sharing the same connection.
Yes. Playwright uses the same Chromium engine as Puppeteer, so downloads, 204 responses, mixed content blocks, and competing navigations produce the same net::ERR_ABORTED error. The causes and fixes are nearly identical in both libraries.
net::ERR_FAILED means a genuine network failure — the server did not respond at all. net::ERR_ABORTED means Chromium intentionally canceled the load. The server may have responded successfully (like with a 204 or a download header), but Chrome decided not to continue the navigation.
Yes. Strict CSP headers or mixed-content rules can block sub-resource loads, producing ERR_ABORTED on those requests. Use page.setBypassCSP(true) to disable CSP enforcement in headless environments, or use CDP Network.loadingFailed to identify which resources are being blocked and why.
Timing-dependent aborts occur when external protocol navigations (mailto, tel links), third-party scripts, or delayed redirects collide with your page.goto() call. Retrying with a short delay between attempts usually resolves intermittent cases. Upgrading to Puppeteer v24.3.0 or later also helps, as PR #13621 changed abort behavior.
Vitalii Holben