Puppeteer Screenshot Slow? 9 Fixes That Actually Work

Your Puppeteer screenshots take 5-7 seconds? Most of that time goes to Chrome startup, unnecessary network requests, and PNG encoding. These fixes cut capture time to 1-2 seconds.

Vitalii Holben Published Reading11 min

Puppeteer screenshot slow? If your screenshots take 5-7 seconds instead of one or two, that's totally normal, and totally fixable. The screenshot command itself is fast. What eats up the time is everything around it: Chrome starting up, the page downloading dozens of scripts it doesn't even need, and then saving the result as a huge PNG file.

We went through the same thing when building our screenshot pipeline. This post shares what we learned about how to speed up Puppeteer screenshots and where the time actually goes. Some fixes are simple one-liners. Others involve tradeoffs, especially when you're dealing with long pages or heavy animations. We'll talk about those too.

Why is my Puppeteer screenshot slow?

Before jumping into fixes, it helps to know where the time actually goes. A Puppeteer maintainer shared a nice breakdown in GitHub Issue #476. On a simple page, the whole capture took 138ms. More than half of that, 71ms, was just converting the image to PNG. The "take a picture" part was only 61ms.

In practice though, there are bigger time sinks. Chrome has to start up (that's about 576ms on average, per Skyvern's testing). Then the page needs to load, which can take anywhere from 1 to 15 seconds depending on how heavy the site is. And if you're capturing a full-page screenshot of a tall page, Chrome scrolls through the whole thing and stitches the pieces together, which takes even longer.

So if you want to optimize Puppeteer for speed, there are a few places to look: how you launch Chrome, what happens while the page loads, and how the screenshot gets saved.

Don't start Chrome from scratch every time

This is probably the easiest win. If you're calling puppeteer.launch() for every screenshot, Chrome starts a brand new process each time. That alone adds half a second or more. In Docker, it can add over two seconds.

Instead, launch Chrome once and reuse it:

// Starting fresh every time — slow
async function capture(url) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url);
  const buffer = await page.screenshot();
  await browser.close();
  return buffer;
}

// Launch once, reuse for every screenshot — much faster
const browser = await puppeteer.launch({ headless: 'shell' });

async function capture(url) {
  const context = await browser.createBrowserContext();
  const page = await context.newPage();
  try {
    await page.goto(url, { waitUntil: 'domcontentloaded' });
    return await page.screenshot();
  } finally {
    await context.close();
  }
}

The createBrowserContext() call creates a clean environment with its own cookies and cache. You get the isolation without paying the startup cost.

One thing to keep in mind: Chrome uses more and more memory over time. After a few hundred screenshots, things can get sluggish. A simple fix is to restart the browser every 50-100 captures. The page crashed post has more tips on dealing with memory if you run into that.

Choose what to wait for carefully

This is where things get interesting, because there's no single right answer. Most Puppeteer tutorials tell you to use networkidle0. That waits until there are zero network requests for 500 milliseconds. The idea is good: make sure everything loads. But on modern websites with analytics, tracking pixels, and ad scripts, those background requests keep going and going. We've seen pages where networkidle0 added 8 extra seconds just because Google Analytics was chatty.

// Waits for ALL network activity to stop — can be very slow
await page.goto(url, { waitUntil: 'networkidle0' });

// Allows 2 lingering connections — good balance
await page.goto(url, { waitUntil: 'networkidle2' });

// Fastest — just waits for the HTML to be parsed
await page.goto(url, { waitUntil: 'domcontentloaded' });

// Best for targeted screenshots — wait for a specific element
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.hero-image', { visible: true });

For simple pages, domcontentloaded is usually enough and it's the fastest option. If you need something more visual to be ready, like a hero image or a chart, wait for that specific element instead of waiting for all network activity to finish.

But there's an important tradeoff to consider. Pages with lots of animations, video backgrounds, or dynamically loaded content sometimes need that extra wait time. If you rush the capture, you might get a half-rendered page. In those cases, networkidle2 is a good compromise. It tolerates a couple of lingering requests but still waits for most of the page to settle. You can also add a small delay after navigation for pages that animate on scroll. It takes longer, but you get a complete result.

The page load strategies post covers this topic in more detail if you want to explore different approaches.

Block tracking scripts and analytics

A typical marketing page fires 30-80 network requests. About half of them are tracking scripts, analytics, and ad pixels. They collect data in the background but don't affect how the page looks at all. Blocking them means less for Chrome to download and process.

await page.setRequestInterception(true);

const blockedDomains = [
  'google-analytics.com', 'googletagmanager.com',
  'facebook.net', 'doubleclick.net',
  'hotjar.com', 'intercom.io',
  'segment.com', 'mixpanel.com',
];

page.on('request', (req) => {
  const url = req.url();
  if (blockedDomains.some(d => url.includes(d))) {
    req.abort();
  } else {
    req.continue();
  }
});

In our testing, this cut capture time by 50-65% on heavy news sites. The screenshots looked exactly the same because none of those scripts produce anything visible.

Don't block images, fonts, or CSS though. Those are what make the page look right. Stick to blocking things that run invisibly: analytics, tracking, ad networks, chat popups. If you'd rather not maintain a blocklist, the ad blocking feature does this automatically.

Switch from PNG to JPEG

By default, Puppeteer saves screenshots as PNG. That's great for quality, but PNG encoding is slow and the files are big. On content-heavy pages, a single PNG can be over 5MB and take 200-500ms just to encode.

For most screenshots, JPEG at quality 80 looks the same to the eye but is way smaller and faster to create:

// Default PNG — big and slow
const buffer = await page.screenshot({ type: 'png' });

// JPEG at quality 80 — fast and small
const buffer = await page.screenshot({
  type: 'jpeg',
  quality: 80,
});

// Even faster with optimizeForSpeed (Puppeteer 21+)
const buffer = await page.screenshot({
  type: 'jpeg',
  quality: 80,
  optimizeForSpeed: true,
});

The optimizeForSpeed option tells Chrome to trade a tiny bit of file size for faster encoding. The difference is most noticeable on full-page screenshots.

When should you stick with PNG? If you need transparent backgrounds, since JPEG doesn't support that. Or if you're doing pixel-perfect visual testing where every detail counts. But for most real-world uses, JPEG is plenty. The image format page goes into more detail on when to use what.

Slim down Chrome's settings

Chrome comes with a lot of features turned on that you don't need for screenshots. Extensions, GPU rendering, background sync. They all add startup time and use extra memory. You can disable them:

const browser = await puppeteer.launch({
  headless: 'shell',
  args: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
    '--disable-software-rasterizer',
    '--disable-extensions',
    '--disable-background-networking',
    '--disable-default-apps',
    '--disable-sync',
    '--metrics-recording-only',
    '--no-first-run',
  ],
});

That headless: 'shell' option is worth knowing about. It tells Puppeteer to use Chrome's lightweight headless version instead of the full browser. Since Chrome 132 (January 2025), this is a separate build called chrome-headless-shell. It's faster and uses less memory. The new headless migration post covers how to switch if you're on an older setup.

Full-page screenshots: expect tradeoffs

Full-page captures are where things get complicated. Chrome has to scroll through the entire page and stitch together all the pieces. The taller the page, the slower this gets, and not in a predictable way. A user reported in Issue #736 that a 5000px-tall page took 5 seconds, but a 10800px page took over 4 minutes.

There's also a platform difference that catches people off guard. The same full-page capture can be 4x slower on Linux than macOS. That same Issue showed a 12000px page taking about 10 seconds on macOS but 40 seconds on Ubuntu.

A few things that help:

  • Ask yourself if you really need the whole page. Capturing the first 5000px usually covers everything important, and it's much faster.
  • If you do need the full page, scroll through it first. Lazy-loaded images won't appear until the page scrolls down to them. Without scrolling, you get blank spots where images should be. The lazy loading fix post walks through this.
  • Try captureBeyondViewport: false with a set viewport size for more predictable results.

The honest truth about full-page screenshots: sometimes you just have to wait. A page with scroll-triggered animations, lazy-loaded images, and dynamic content needs time to fully render. You can speed up Chrome and block unnecessary requests, but the page itself still needs to do its thing. For these pages, adding a small delay or using networkidle2 is a fair trade. A couple extra seconds of patience for a complete screenshot.

Puppeteer Docker screenshot: tips that save headaches

If you're running Puppeteer in Docker, there are three things to know.

First, shared memory is probably too small. Docker only gives 64MB by default. Chrome can use more than that on a single complex page, and when it runs out, tabs crash or Chrome freezes. Add --shm-size=2g when you start your container, or use the --disable-dev-shm-usage flag from the Chrome args above.

Second, use a Debian-based image, not Alpine. Alpine is tiny, which is appealing. But it's missing libraries Chrome needs, and you'll spend hours debugging crashes and weird font issues. node:20-bookworm is heavier but everything works out of the box.

Third, clean up crashed Chrome processes. If your Node script crashes mid-screenshot, Chrome stays running in the background. Over time, these orphans pile up and eat your memory. Use dumb-init as your container's entry point:

FROM node:20-bookworm
RUN apt-get update && apt-get install -y dumb-init chromium
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

For more Docker troubleshooting, the failed to launch browser post covers common issues.

How many screenshots can you run at once?

Running screenshots in parallel speeds things up, until Chrome eats all your RAM. Each open tab uses 50-300MB depending on page complexity. We found that 3-4 concurrent captures is the sweet spot for most machines.

const pLimit = require('p-limit');
const limit = pLimit(4);

const urls = ['https://example.com/page1', /* ...more */];

const results = await Promise.all(
  urls.map(url => limit(() => capture(url)))
);

On a 4GB machine, going above 5 usually makes things slower, not faster. The CPU gets overwhelmed running multiple Chrome processes at once. Start with 3-4 and adjust based on what you see.

If you're dealing with hundreds of URLs, the rate limiting strategies post covers queue patterns that handle large batches well.

Putting it all together

This setup combines all the puppeteer performance improvements from above into one place:

const puppeteer = require('puppeteer');
const pLimit = require('p-limit');

let browser;

async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await puppeteer.launch({
      headless: 'shell',
      args: [
        '--no-sandbox',
        '--disable-setuid-sandbox',
        '--disable-dev-shm-usage',
        '--disable-gpu',
        '--disable-extensions',
        '--disable-background-networking',
        '--no-first-run',
      ],
    });
  }
  return browser;
}

const blockedDomains = [
  'google-analytics.com', 'googletagmanager.com',
  'facebook.net', 'doubleclick.net',
  'hotjar.com', 'intercom.io',
];

async function screenshot(url, opts = {}) {
  const b = await getBrowser();
  const context = await b.createBrowserContext();
  const page = await context.newPage();

  await page.setRequestInterception(true);
  page.on('request', (req) => {
    if (blockedDomains.some(d => req.url().includes(d))) {
      req.abort();
    } else {
      req.continue();
    }
  });

  try {
    await page.goto(url, {
      waitUntil: opts.waitUntil || 'domcontentloaded',
      timeout: 30000,
    });
    if (opts.waitForSelector) {
      await page.waitForSelector(opts.waitForSelector, {
        visible: true,
        timeout: 10000,
      });
    }
    return await page.screenshot({
      type: opts.format || 'jpeg',
      quality: opts.quality || 80,
      fullPage: opts.fullPage || false,
      optimizeForSpeed: true,
    });
  } finally {
    await context.close();
  }
}

const limit = pLimit(4);
const urls = [/* your URL list */];
const results = await Promise.all(
  urls.map(url => limit(() => screenshot(url)))
);

With this setup, a simple page captures in about 1-2 seconds instead of 4-7. For long-running services, restart the browser every 50-100 captures to keep memory fresh.

When it's easier to just use an API

All these tips work, but they do add up to a lot of code. Browser management, request blocking, memory monitoring, Docker configuration, concurrency limits. And Chrome updates every six weeks, so things shift regularly.

If you're taking 50+ screenshots a day and spending time on performance tuning, a screenshot API handles all of this for you. One HTTP request replaces the whole pipeline. The Puppeteer vs screenshot API comparison lays out where the tradeoff makes sense. The Node.js integration takes about five minutes to try:

const resp = await fetch(
  'https://api.screenshotrun.com/v1/screenshots/capture?' +
  new URLSearchParams({
    url: 'https://example.com',
    format: 'jpeg',
    quality: '80',
    block_ads: 'true',
    block_cookies: 'true',
  }),
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
);
const imageBuffer = Buffer.from(await resp.arrayBuffer());

No Chrome to manage, no memory leaks, no Docker tweaks. If Puppeteer screenshot slow is a recurring problem in your project, this might be the simplest solution. Check the pricing page to see if it fits your volume.

FAQ#

Most of the time goes to three things: Chrome startup (500ms-2s per launch), page load with tracking scripts (1-15s), and PNG encoding (200-500ms on heavy pages). Reusing the browser, blocking trackers, and switching to JPEG fixes the biggest bottlenecks.

Full-page captures get slower as pages get taller. Cap the height to what you actually need, scroll the page first to trigger lazy-loaded images, and use captureBeyondViewport: false with a fixed viewport size. For pages with animations, expect to trade some speed for completeness.

For most screenshots, domcontentloaded is faster and sufficient. It waits for the HTML to parse but skips waiting for analytics and tracking scripts. Use networkidle2 for pages with animations or dynamic content that need more time to settle.

Each Chrome tab uses 50-300MB of memory. On most machines, 3-4 concurrent captures is the sweet spot. Going above 5 on a 4GB machine usually makes things slower because the CPU gets overwhelmed running multiple Chrome processes.

Docker adds overhead to Chrome in three ways: shared memory defaults to 64MB (too small for Chrome), Alpine images lack Chrome dependencies, and crashed Chrome processes pile up as zombies. Fix with --shm-size=2g, a Debian base image, and dumb-init as PID 1.

JPEG at quality 80 is faster to encode and produces much smaller files. A PNG can be 5MB+ while the same JPEG is around 400KB, with no visible difference. Use PNG only when you need transparent backgrounds or pixel-perfect visual regression testing.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call