How to Fix Puppeteer Screenshot Differences Between Local and CI Runs
Make Puppeteer screenshots more consistent across local and CI runs by aligning browser versions, fonts, display settings, and page readiness.
To make Puppeteer screenshots consistent between local development and CI, first align the Puppeteer and browser versions, operating environment and fonts. Then set the same viewport and device scale, use the same headless mode, and wait for the same application state before capturing. Save that configuration as metadata alongside each image so you can narrow down any remaining difference.
Matching settings reduces avoidable rendering drift, but it does not guarantee pixel-identical output across different operating systems, graphics drivers or browser builds. Work through the checks below in order, starting with the environment and capture dimensions before investigating page-specific behavior.
1. Record the versions and browser executable
Puppeteer normally downloads a compatible Chrome for Testing browser when it is installed. If a package manager blocks install scripts, that download may be skipped. Local and CI can then run different browsers, or one environment can use a configured executable that differs from Puppeteer’s expected browser. [Puppeteer installation]
Check the versions and executable on both sides. For a JavaScript project, these commands are useful in the local shell and CI job:
node --version
npm ls puppeteer
npx puppeteer browsers list
Also inspect your Puppeteer launch configuration for executablePath. If you intentionally install a browser separately, install the same browser build in both environments and point both runs to it. If install scripts were disabled, use Puppeteer’s documented browser installation procedure rather than assuming a compatible browser was downloaded.
Keep the lockfile in version control and install from it in CI. Pinning Puppeteer helps keep its expected browser version consistent; pinning the CI image or browser installation prevents an independently changing system browser from introducing drift.
2. Align the operating system, libraries and fonts
Use the same container image or operating-system family locally and in CI where practical. Chrome depends on system libraries, and CI images may lack fonts that are present on a developer machine. Missing font coverage can change glyph shapes, line wrapping and element dimensions, even when the HTML and CSS are unchanged. Puppeteer’s Docker and troubleshooting guidance covers browser dependencies, fonts, sandbox setup and writable profile or cache locations. [Puppeteer troubleshooting]
- Use a pinned container image for repeatable CI runs. If developers need close local parity, provide the same image or document the supported environment.
- Install the fonts required by the page, including fonts that cover the scripts and character sets it renders.
- Ensure Chrome’s required shared libraries are installed in the image.
- Provide writable locations for Chrome’s profile and cache when the CI environment restricts filesystem access.
- Keep sandbox configuration consistent where possible. Do not add launch flags without understanding the environment requirement they address.
If text differs, check font availability and font loading before changing layout code. A fallback font can be subtle but alter line breaks enough to move elements lower on the page.
3. Set viewport and device scale explicitly
Set the same viewport width, height and device scale factor in both runs. Puppeteer documents page screen configuration, including size and device pixel ratio. Record these values next to each screenshot: a device scale mismatch can change output dimensions and rasterization. [Puppeteer setViewport]
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 1
});
Use the same screenshot options too. In particular, check whether one run uses a full-page screenshot and the other a viewport capture, or whether options such as scale, type or transparency differ. The screenshot API documents capture options. [Puppeteer Page.screenshot]
4. Keep headless mode and graphics path consistent
Puppeteer runs headless by default. Make sure local and CI use the same headless mode and browser path; do not compare a regular Chrome run with a headless-shell run and assume their graphics behavior is identical. Puppeteer’s troubleshooting guidance notes a GPU-compositing consideration for headless shell. For pages with canvas, WebGL or effects sensitive to compositing, compare the browser mode, GPU availability and graphics dependencies first. [Puppeteer headless modes, troubleshooting]
When investigating, change one environment variable at a time. Switching browser mode, GPU flags and operating-system libraries together makes it difficult to identify which change affected the image.
5. Wait for the same page state before capture
A completed navigation event does not necessarily mean an application has finished rendering its data, web fonts, animations or timer-driven content. Puppeteer’s screenshot guide demonstrates navigation with a wait condition before capture, but there is no universal wait condition that guarantees every application is visually ready. [Puppeteer screenshots]
Use an application-specific readiness signal where possible, such as a selector that appears after the page has rendered the content under test. Then wait for fonts and images that matter to the screenshot, and disable or freeze animations and time-based content in the test environment if they are not what you intend to test.
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
await page.waitForSelector('[data-test="report-ready"]');
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
});
await page.screenshot({ path: 'page.png', fullPage: true });
Replace the example selector with a readiness marker your application controls. Network-idle waits can be unsuitable for pages that keep requests open or poll continuously; in those cases, navigate with a suitable lifecycle condition and rely on the explicit application signal. A fixed delay can help diagnose a timing problem, but it is usually less reliable than waiting for a meaningful state.
6. A reproducible Puppeteer capture script
This CommonJS example makes the main capture settings explicit and writes a PNG. Run it with Puppeteer installed and a browser available through Puppeteer’s normal installation or a configured executable. Replace the URL and readiness selector with those for your application.
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
headless: true
// If using a separately installed browser, configure the same
// executablePath in local development and CI.
});
try {
const page = await browser.newPage();
await page.setViewport({
width: 1440,
height: 900,
deviceScaleFactor: 1
});
await page.goto('https://example.com', {
waitUntil: 'networkidle2',
timeout: 60000
});
await page.waitForSelector('[data-test="report-ready"]', {
timeout: 15000
});
await page.evaluate(async () => {
if (document.fonts) await document.fonts.ready;
});
await page.screenshot({
path: 'page.png',
type: 'png',
fullPage: true,
animations: 'disabled'
});
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
If your page has no application readiness selector, remove that wait and choose a navigation condition appropriate to the page. If the installed Puppeteer version does not support a screenshot option shown here, check the API documentation for the version pinned by your project and use options supported by that version.
7. Compare metadata and narrow down the difference
Keep these details with both the local and CI screenshot artifacts:
- Puppeteer package version and browser version.
- Operating-system family and pinned container image identifier.
- Configured browser executable path and headless mode.
- Viewport width and height, device scale factor, and screenshot options.
- Installed fonts, especially those needed by the page’s scripts and character sets.
- Navigation condition, application readiness signal and any animation controls.
| Observed difference | Check first |
|---|---|
| Image dimensions differ | Viewport, device scale factor, full-page setting and screenshot scale. |
| Text shape or wrapping differs | Font inventory, web font readiness, browser version and operating environment. |
| Only canvas, WebGL or compositing differs | Headless mode, GPU availability, drivers and graphics dependencies. |
| Content appears missing or shifted intermittently | Application readiness, asynchronous data, image loading, timers and animations. |
| CI cannot launch Chrome | Browser installation, shared libraries, sandbox environment and writable profile/cache locations. |
This mapping is a diagnostic aid, not proof of a single cause. Change one factor at a time and recapture with the same page state so the comparison stays meaningful.
8. Troubleshooting common Puppeteer screenshot differences
CI uses a different Chrome build
Cause: the Puppeteer package or browser installation differs, or install scripts did not download the expected Chrome for Testing build.
Fix: compare package and browser versions, inspect executablePath, install the documented compatible browser and pin the environment used by CI.
Chrome launches locally but fails in CI
Cause: missing shared libraries, sandbox restrictions, or unwritable profile and cache locations.
Fix: follow Puppeteer’s environment-specific troubleshooting and Docker guidance, install required dependencies and provide writable paths. Apply sandbox configuration only as appropriate to the CI environment.
Text wraps differently although the viewport matches
Cause: a font is missing, a different fallback is selected, or the web font has not loaded before capture.
Fix: install the same font coverage, wait for document.fonts.ready, and use the same browser and operating environment.
Screenshot dimensions differ
Cause: viewport size, device scale factor, full-page mode or screenshot scaling differs.
Fix: log and align viewport and capture options. Compare output dimensions before examining pixel-level rendering.
Dynamic content appears at different positions
Cause: the screenshot was taken after navigation but before asynchronous data, images or other required content settled.
Fix: wait for an application-specific readiness selector and any required fonts or images. Avoid relying on an arbitrary delay when a state signal is available.
Only graphics-heavy areas differ
Cause: different headless modes, GPU or driver behavior, or graphics dependencies.
Fix: align browser mode and environment, then investigate the GPU and compositing path for the specific page.
9. Performance, reliability and cost considerations
Browser startup, page loading and readiness waits are part of capture time. Reusing a browser process can avoid repeated startup in a long-running worker, while closing pages and browser processes reliably prevents resource accumulation. Choose timeouts that reflect the application and CI environment; an overly short timeout creates intermittent failures, while an unbounded wait can stall a job. These are operational tradeoffs, not a guarantee of a particular runtime.
For reliability, preserve the screenshot and metadata when a visual comparison fails. That lets you distinguish a capture configuration change from a real application change. Pinning the image and dependencies also makes upgrades intentional and reviewable. Cost depends on your CI runner, browser runtime and job volume; the research sources provide no benchmark or price comparison, so measure your own pipeline before optimizing.
Or skip the browser setup
If you want a screenshot without maintaining Puppeteer, Chrome and the CI runtime, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP or PDF. See the ScreenshotNeo API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
ScreenshotNeo accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts and failed loads are never billed, and cache hits cost nothing. Responses include page-verdict and billing headers. Its MCP server gives AI agents tools to take screenshots, get page information and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Will matching settings make screenshots pixel-identical?
Not necessarily. Different operating systems, graphics drivers and browser builds can still render differently. Matching the environment reduces sources of drift and makes remaining differences easier to investigate.
Should I always use network idle as my wait condition?
No. Pages that poll or keep requests open may never reach network idle. Use a wait condition that fits the page and an application-specific readiness signal where possible.
What should I investigate first if only the text differs?
Check font availability and font readiness, then compare browser versions and operating environments. Text metrics can change layout even when the viewport is the same.


