Puppeteer Full-Page Screenshots Cut Off Sticky Headers: How to Fix It
Diagnose clipped, misplaced, or duplicated sticky headers in Puppeteer full-page screenshots. Check capture options, CSS positioning, and stitching tradeoffs.
If a Puppeteer full-page screenshot clips or misplaces a sticky header, first check what you are capturing and which screenshot options you pass. Use page.screenshot({ fullPage: true }) for the whole document, inspect any clip and captureBeyondViewport settings, then check how the header’s CSS behaves during capture. fullPage requests a full-page image; it does not promise that fixed or sticky elements will appear in the position you expect.
1. Confirm the capture target and options
For a document capture, start with the documented full-page option and avoid adding a clip until you have a specific reason to crop. Puppeteer’s screenshot reference documents captureBeyondViewport separately: its default is false when there is no clip and true when a clip is supplied. Inspect the actual options passed by your code or wrapper instead of assuming these settings are interchangeable. See the Puppeteer ScreenshotOptions reference.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const image = await page.screenshot({
path: 'page.png',
type: 'png',
fullPage: true,
});
} finally {
await browser.close();
}
This is a complete baseline. Substitute the page you control or are authorized to capture. If your current call includes clip, remove it temporarily and compare. A clip is a region capture, so its coordinates and dimensions can exclude content even when the page itself is taller.
| Goal | Capture method | What to check |
|---|---|---|
| Whole document | page.screenshot({ fullPage: true }) |
Remove unintended clip settings; inspect the viewport and page layout. |
| One element | elementHandle.screenshot() |
Confirm the selected element contains the desired header/content. |
| Specific rectangle | page.screenshot({ clip: ... }) |
Verify x, y, width, and height against the intended region. |
2. Determine whether the header is clipped, misplaced, or duplicated
These symptoms point to different causes. A header absent from the top edge may be outside the captured region or may have changed position as the page was laid out. A fixed banner appearing in the middle can be a capture behavior involving viewport clipping. Repeated headers often indicate scroll-and-stitch logic that captured the fixed or sticky element in more than one segment.
- Clipped: inspect the screenshot dimensions, any explicit clip, and whether the header is outside the captured document area.
- Misplaced: inspect
position: stickyorposition: fixed, its containing block, and the scroll position at capture time. - Duplicated: look for custom code or a library that scrolls, captures, and stitches viewport-sized images.
For an element-specific screenshot, Puppeteer’s ElementHandle.screenshot() is a separate option from a full-document screenshot. The guide says it tries to scroll a hidden element into view by default. That can help when the target is one visible element, but it does not solve the different requirement of representing an entire long document. See the Puppeteer screenshots guide.
const header = await page.$('header.site-header');
if (!header) throw new Error('Header selector did not match an element');
await header.screenshot({ path: 'header.png' });
3. Check CSS positioning and page layout
Sticky and fixed positioning depend on the page’s layout and scroll context. Before changing browser settings, inspect the target page’s computed styles and ancestors. A sticky element can be constrained by its scrolling container; a fixed element is positioned relative to its containing context. Also check whether the header is hidden, transformed, or restyled by scripts at the moment the screenshot is taken.
const details = await page.$eval('header.site-header', element => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return {
position: style.position,
top: style.top,
display: style.display,
visibility: style.visibility,
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
scrollY: window.scrollY,
};
});
console.log(details);
Wait for the page’s own layout or header state to settle if it changes after navigation. Use a selector wait when the header is inserted asynchronously:
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('header.site-header', { visible: true });
await page.screenshot({ path: 'page.png', fullPage: true });
Do not assume that increasing the viewport height is neutral. CSS using viewport units can produce a different layout at a different height, and fixed or sticky elements can consequently render differently.
4. Treat resizing and stitching as tradeoffs
One workaround is to resize the viewport to the document height before capture. This can avoid some clipping patterns, but it changes the viewport used for layout. Pages with viewport-relative sizing or fixed elements may then look different from a normal browser view. Compare the output against the intended rendering before adopting this approach.
Another approach scrolls through the page, captures viewport-sized sections, and stitches them together. This adds browser work and can duplicate sticky or fixed elements at segment boundaries. If you control the stitcher, account for overlap and deliberately handle headers between segments; otherwise prefer the full-page capture path and diagnose its options and page CSS first.
Puppeteer issue #5080 is a historical discussion of screenshot behavior changes around Puppeteer v2.0.0 and Chromium viewport clipping. It describes fixed banners appearing in the middle of some full-page captures and discusses the resizing and stitching tradeoffs. Use it as background on the class of problem, not as a current API specification.
5. Use the historical Blink flag cautiously
A comment in that issue, dated 2021, reported using this Chromium launch argument to restore older behavior:
const browser = await puppeteer.launch({
args: ['--blink-settings=mainFrameClipsContent=false'],
});
This is a historical, version-dependent report. It is not listed as a supported Puppeteer screenshot option in the current ScreenshotOptions reference, and the issue discussion does not establish that it works for every Chromium/Puppeteer version. Verify it against the exact versions and deployment environment you use, and keep a regression capture if you rely on it.
6. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Bottom or top of page is missing | An unintended clip, incorrect dimensions, or capture target mismatch. | Try a minimal fullPage: true call without clip; inspect the resulting image dimensions. |
| Header appears halfway down the image | Fixed-position behavior during full-page capture or a prior screenshot workaround. | Remove custom stitching/resizing first; check header styles and compare a viewport screenshot with a full-page one. |
| Header appears more than once | Viewport segments were captured and stitched while the header remained fixed or sticky. | Prefer native full-page capture where it meets the need, or make stitching account for fixed elements and overlap. |
| Header is missing only on some pages | Page-specific CSS, delayed rendering, a different scroll container, or responsive layout. | Wait for the relevant selector, log computed styles and bounding box, and use the same viewport as the target rendering. |
| Flag has no effect | The Chromium version or behavior differs from the historical report. | Check the bundled Chromium version and do not treat the flag as a current supported option. |
| Element screenshot is blank or wrong | The selector matched the wrong node or the element is not in the expected state. | Check the selector result and visibility, then capture the document if the goal is the whole page. |
7. Performance, reliability, and cost
A single full-page screenshot is usually the simplest capture path to maintain. Resizing and scroll-and-stitch methods add steps; stitching also requires handling segment boundaries and fixed content. Wait only for the state your capture needs: waiting for an element can be more targeted than waiting for every network connection to become idle on a page with persistent requests.
For reliable captures, pin and record the Puppeteer and Chromium versions in your environment, use a consistent viewport, wait for the header and important page content, and keep representative screenshots for pages with sticky navigation. There is no universal flag that guarantees identical output across every page and browser version.
Running your own browser also means managing browser installation, runtime, and capture failures. If you capture pages through an API, account for the provider’s pricing and failure billing rules. ScreenshotNeo says clean screenshots are billed while bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing; the response includes X-Page-Verdict and X-Billed headers. Its plans include 1,000 shots/month free with no card, then Starter at $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000; yearly billing gives two months free.
8. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. Its capture flow removes cookie banners, newsletter popups, and chat widgets before the shot; each cleanup step can be turned off. Bot checks, blank pages, and failed loads are never billed. An MCP server exposes screenshot tools to AI agents, and 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000.
One GET request returns an image or PDF. For options such as full-page capture, viewport/device settings, custom waits, and format, see the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.
9. FAQ
Does fullPage: true guarantee a sticky header is shown at the top?
No. It requests a full-page screenshot. Header placement also depends on page layout, positioning, and capture behavior.
Should I use ElementHandle.screenshot() to fix a full-page capture?
Use it when the target is one element. It is a different capture goal from the whole document.
Is the Blink setting a current Puppeteer option?
No. It is a historical workaround reported in an issue comment, not a documented screenshot option. Check compatibility with your versions before relying on it.


