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');
});
});
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';
}
});
});
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 });
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');
});
});
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 });
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 });
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');
});
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
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,
},
});
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);
});
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 });
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: {} };
});
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 });
}
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)