DEV Community

Cover image for Puppeteer Full-Page Screenshots: Why They Break and How to Fix Them
Dennis
Dennis

Posted on

Puppeteer Full-Page Screenshots: Why They Break and How to Fix Them

Puppeteer screenshot full page not working usually comes down to one of six problems: fixed-position elements repeating across the capture, lazy-loaded images staying as placeholders, infinite scroll pages only capturing the first viewport, CSS overflow: hidden clipping the output, incorrect page height calculations, or iframes refusing to render. This guide covers each failure mode with the actual cause and working code to fix it.

I've spent more time debugging Puppeteer full-page screenshots than I'd like to admit. The fullPage: true option sounds like it should just work. It measures the page height, resizes the viewport, and captures everything. In practice, modern web pages are hostile to that approach. Here's every way it breaks and what to do about it.

1. Fixed-Position Elements Repeating on Every Segment

What happens

You take a full-page screenshot and notice the site's sticky header, floating nav, or fixed footer appears multiple times, stamped across every viewport-height segment of the image. On long pages, you might see the same header repeated five or six times.

Why it breaks

Puppeteer captures full-page screenshots by extending the viewport to the full document height, then rendering. But position: fixed elements are positioned relative to the viewport, not the document. When Chrome renders a page at, say, 8000px tall, fixed elements paint at their viewport-relative position. Depending on Chrome's internal tiling, these elements can ghost across multiple tiles.

The fix

Force fixed elements to position: absolute before capturing, then restore them:

const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle0' });

// Convert fixed elements to absolute before screenshot
await page.evaluate(() => {
  const fixed = document.querySelectorAll('*');
  fixed.forEach(el => {
    const style = window.getComputedStyle(el);
    if (style.position === 'fixed') {
      el.setAttribute('data-was-fixed', 'true');
      el.style.position = 'absolute';
    }
  });
});

const screenshot = await page.screenshot({ fullPage: true });

// Restore if you need the page afterward
await page.evaluate(() => {
  document.querySelectorAll('[data-was-fixed]').forEach(el => {
    el.style.position = 'fixed';
    el.removeAttribute('data-was-fixed');
  });
});
Enter fullscreen mode Exit fullscreen mode

A lighter approach if you just want to hide them entirely:

await page.evaluate(() => {
  document.querySelectorAll('*').forEach(el => {
    if (window.getComputedStyle(el).position === 'fixed') {
      el.style.display = 'none';
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

Querying all elements with querySelectorAll('*') is expensive on large DOMs. If you know the selectors (.navbar, #sticky-header), target them directly.

2. Lazy-Loaded Images Showing as Placeholders

What happens

Your full-page screenshot captures the layout correctly, but half the images are grey boxes, blurred placeholders, or missing entirely. The top of the page looks fine; everything below the fold is broken.

Why it breaks

Lazy loading (via loading="lazy", Intersection Observer, or libraries like lazysizes) only loads images when they enter the viewport. When Puppeteer sets fullPage: true, it extends the viewport height but doesn't scroll. The browser never fires the intersection events that trigger image loading. The images stay in their placeholder state.

The fix

Scroll the entire page before capturing. This forces every lazy-loaded image into the viewport at least once:

async function scrollToBottom(page) {
  await page.evaluate(async () => {
    await new Promise((resolve) => {
      let totalHeight = 0;
      const distance = 400;
      const timer = setInterval(() => {
        const scrollHeight = document.body.scrollHeight;
        window.scrollBy(0, distance);
        totalHeight += distance;

        if (totalHeight >= scrollHeight) {
          clearInterval(timer);
          // Scroll back to top for a clean capture
          window.scrollTo(0, 0);
          resolve();
        }
      }, 100);
    });
  });
}

await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await scrollToBottom(page);

// Wait for images triggered by scrolling to finish loading
await page.waitForFunction(() => {
  const images = Array.from(document.images);
  return images.every(img => img.complete);
}, { timeout: 10000 });

const screenshot = await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

The scroll speed matters. Too fast (distance 2000, interval 10) and the Intersection Observer callbacks don't fire. I've found 400px every 100ms works reliably across most sites. For pages with particularly aggressive lazy loading, drop it to 200px every 150ms.

Some sites use data-src attributes and swap them into src on scroll. If images still aren't loading after scrolling, check for that pattern:

await page.evaluate(() => {
  document.querySelectorAll('img[data-src]').forEach(img => {
    img.src = img.getAttribute('data-src');
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Infinite Scroll Pages Capturing Only the Initial Viewport

What happens

You're trying to capture a page that loads more content as you scroll (social media feeds, product listings, search results). The puppeteer screenshot full page not working complaint here is that fullPage: true captures only what's loaded initially, usually one viewport's worth.

Why it breaks

fullPage: true captures the current document height. On an infinite scroll page, the document height at load time only reflects the initially loaded content. The page won't grow until scroll events trigger the next batch of content.

The fix

You need to scroll incrementally and wait for new content to load at each step. Set a limit, or you'll be scrolling forever:

async function scrollInfinite(page, maxScrolls = 10) {
  let previousHeight = 0;
  let scrollCount = 0;

  while (scrollCount < maxScrolls) {
    const currentHeight = await page.evaluate(() => document.body.scrollHeight);

    if (currentHeight === previousHeight) {
      // No new content loaded, we've hit the end
      break;
    }

    previousHeight = currentHeight;
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));

    // Wait for new content to load
    await page.waitForFunction(
      `document.body.scrollHeight > ${currentHeight}`,
      { timeout: 5000 }
    ).catch(() => {
      // Timeout means no new content, stop scrolling
    });

    scrollCount++;
  }

  // Scroll back to top
  await page.evaluate(() => window.scrollTo(0, 0));
}

await page.goto('https://example.com/feed', { waitUntil: 'networkidle2' });
await scrollInfinite(page, 5);
const screenshot = await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

Be aware of the hard limit: Puppeteer caps screenshot height at 16,384px by default (Chrome's maximum texture size on many GPUs). Some configurations support up to 32,768px, but beyond that the screenshot will silently clip. For very long infinite scroll pages, you may need to capture in segments and stitch them.

4. CSS overflow: hidden on Body Clipping the Capture

What happens

The screenshot comes out as exactly one viewport tall, even though you passed fullPage: true and the page clearly has more content when you view it in a regular browser.

Why it breaks

Many sites set overflow: hidden on <html> or <body>, often as part of a modal system, scroll-locking library, or CSS reset. When Puppeteer calculates the full page height, it looks at the scroll height of the document. With overflow: hidden, the computed scroll height equals the viewport height. Puppeteer thinks the page is only one viewport tall.

The fix

Override the overflow before capturing:

await page.goto('https://example.com', { waitUntil: 'networkidle0' });

await page.evaluate(() => {
  document.documentElement.style.overflow = 'visible';
  document.body.style.overflow = 'visible';
});

const screenshot = await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

If the site uses a CSS framework that sets overflow via a class, you might also need to remove that class:

await page.evaluate(() => {
  document.body.classList.remove('no-scroll', 'overflow-hidden', 'modal-open');
  document.documentElement.classList.remove('no-scroll', 'overflow-hidden');
});
Enter fullscreen mode Exit fullscreen mode

Watch out for sites that toggle overflow dynamically. If a cookie banner or popup fires after your override, it might re-apply overflow: hidden. Handle that by waiting for popups to appear and dismissing them first, or by running the override right before the screenshot call.

5. Wrong Page Height Calculation

What happens

The screenshot cuts off partway through the page, or includes a large blank area at the bottom. The page height is wrong.

Why it breaks

Puppeteer uses document.documentElement.scrollHeight to determine page height. But there are multiple height values that can differ:

// These can all return different numbers:
document.body.scrollHeight
document.documentElement.scrollHeight
document.body.offsetHeight
document.documentElement.offsetHeight
document.body.clientHeight
Enter fullscreen mode Exit fullscreen mode

The discrepancy comes from margins, padding, absolutely positioned elements that extend beyond the document flow, and CSS box model differences. An absolutely positioned footer sitting 200px below the last in-flow element? scrollHeight might not account for it. A body with margin: 0 vs. one with the default 8px margin? Different heights.

The fix

If the screenshot is cutting off, manually set the viewport height to the true content height before capturing:

await page.goto('https://example.com', { waitUntil: 'networkidle0' });

const bodyHeight = await page.evaluate(() => {
  const body = document.body;
  const html = document.documentElement;
  return Math.max(
    body.scrollHeight,
    body.offsetHeight,
    html.clientHeight,
    html.scrollHeight,
    html.offsetHeight
  );
});

await page.setViewport({
  width: 1280,
  height: bodyHeight,
});

// Use clip instead of fullPage for precise control
const screenshot = await page.screenshot({
  clip: {
    x: 0,
    y: 0,
    width: 1280,
    height: bodyHeight,
  },
});
Enter fullscreen mode Exit fullscreen mode

For pages with absolutely positioned elements that extend beyond the normal flow, you need a more thorough measurement:

const fullHeight = await page.evaluate(() => {
  const allElements = document.querySelectorAll('*');
  let maxBottom = 0;
  allElements.forEach(el => {
    const rect = el.getBoundingClientRect();
    const bottom = rect.bottom + window.scrollY;
    if (bottom > maxBottom) maxBottom = bottom;
  });
  return Math.ceil(maxBottom);
});
Enter fullscreen mode Exit fullscreen mode

This is slow on large DOMs but gives you the true bottom of the last visible element on the page.

6. Iframes Not Rendering

What happens

The screenshot captures the page but iframe content shows up as blank white rectangles. Embedded videos, maps, social media embeds, and third-party widgets are all missing.

Why it breaks

Several things can cause blank iframes:

  • Cross-origin iframes may block rendering in headless mode due to stricter security policies
  • Iframes that load asynchronously may not be ready when Puppeteer takes the screenshot
  • Some iframes detect headless Chrome and refuse to render (YouTube embeds, Google Maps)

The fix

First, make sure you're waiting long enough. networkidle0 waits for zero network connections for 500ms, but iframes often load their own resources independently:

await page.goto('https://example.com', { waitUntil: 'networkidle0' });

// Wait for iframes to load
const frames = page.frames();
await Promise.all(
  frames.map(frame =>
    frame.waitForNavigation({ waitUntil: 'networkidle0', timeout: 5000 }).catch(() => {})
  )
);

// Additional wait for iframe content rendering
await page.waitForTimeout(2000);

const screenshot = await page.screenshot({ fullPage: true });
Enter fullscreen mode Exit fullscreen mode

For iframes that detect headless mode, you'll need to make Puppeteer look like a real browser:

const browser = await puppeteer.launch({
  headless: 'new',
  args: [
    '--disable-web-security',
    '--disable-features=IsolateOrigins,site-per-process',
    '--no-sandbox',
  ],
});

const page = await browser.newPage();

// Override headless detection
await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'webdriver', { get: () => false });
  window.chrome = { runtime: {} };
});
Enter fullscreen mode Exit fullscreen mode

Note: --disable-web-security is fine for screenshot tooling but never use it in production contexts where users interact with the browser.

Combining Fixes: The Full-Page Screenshot Function

Here's a utility function that handles all six issues:

async function reliableFullPageScreenshot(page, url, options = {}) {
  const { width = 1280, scrollDelay = 100, scrollDistance = 400 } = options;

  await page.setViewport({ width, height: 800 });
  await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });

  // Remove overflow restrictions
  await page.evaluate(() => {
    document.documentElement.style.overflow = 'visible';
    document.body.style.overflow = 'visible';
  });

  // Convert fixed elements to absolute
  await page.evaluate(() => {
    document.querySelectorAll('*').forEach(el => {
      if (window.getComputedStyle(el).position === 'fixed') {
        el.style.position = 'absolute';
      }
    });
  });

  // Scroll to trigger lazy loading
  await page.evaluate(async (distance, delay) => {
    await new Promise((resolve) => {
      let totalHeight = 0;
      const timer = setInterval(() => {
        window.scrollBy(0, distance);
        totalHeight += distance;
        if (totalHeight >= document.body.scrollHeight) {
          clearInterval(timer);
          window.scrollTo(0, 0);
          resolve();
        }
      }, delay);
    });
  }, scrollDistance, scrollDelay);

  // Wait for all images
  await page.waitForFunction(() => {
    return Array.from(document.images).every(img => img.complete);
  }, { timeout: 10000 }).catch(() => {});

  // Wait for iframes
  await Promise.all(
    page.frames().map(f =>
      f.waitForNavigation({ waitUntil: 'load', timeout: 3000 }).catch(() => {})
    )
  );

  return page.screenshot({ fullPage: true });
}
Enter fullscreen mode Exit fullscreen mode

This handles the majority of full-page screenshot failures. It's not fast. On a content-heavy page, you're looking at 10-15 seconds for the scroll, image loading, and iframe waiting. For pages with infinite scroll, add the scrollInfinite function from section 3 instead of the basic scroll-to-bottom.

When the DIY Approach Isn't Worth It

This utility function is around 50 lines and handles the common cases. But real-world pages keep finding new ways to break screenshots. Cookie consent popups that re-enable overflow: hidden after you clear it. JavaScript-rendered content that needs specific user interactions to appear. WebGL canvases that render as black rectangles.

If you're maintaining screenshot infrastructure for a production service, the time spent chasing edge cases adds up. Screenshot APIs like SnapRender handle all of this server-side, including full-page capture up to 32,768px with fixed-element handling and lazy-load triggering built in. A single GET request replaces the entire function above. That trade-off makes sense when your core product isn't "taking screenshots" and you'd rather spend engineering time elsewhere.

Quick Reference Table

Problem Root Cause Fix
Fixed headers repeating position: fixed paints per viewport tile Switch to position: absolute before capture
Lazy images as placeholders Intersection Observer never fires Scroll the page before capturing
Infinite scroll only shows first page Document height reflects initial load only Scroll incrementally, wait for new content
Page clips at one viewport height overflow: hidden on html/body Override to overflow: visible
Screenshot cuts off or has blank space Wrong height calculation method Use Math.max() across all height properties
Iframes render as blank Async loading, headless detection, CORS Wait for frame navigation, disable web security

The fullPage: true option in Puppeteer is a starting point, not a complete solution. Most production screenshot setups end up wrapping it with at least three or four of these fixes. If your puppeteer screenshot full page not working problem has you searching Stack Overflow at midnight, start with the quick reference table above and apply the fixes that match your symptoms.

Top comments (0)