Back to Blog
Deployment

Headless Browser Screenshots: Best Practices and Tips

How to capture consistent, high-quality screenshots in headless mode covering viewport, DPI, formats, timing, and full-page capture.

Introduction

Screenshots in headless browser environments are deceptively complex. A screenshot that looks perfect in development may produce blank images, wrong dimensions, missing fonts, or incorrect colors in production. The root causes range from viewport misconfiguration and premature capture timing to display server setup issues and font availability.

BotBrowser adds another dimension to screenshot capture: fingerprint consistency. The profile defines screen resolution, device pixel ratio, and window dimensions. If your screenshot configuration conflicts with these values, you get either incorrect output or fingerprint inconsistencies. This guide covers the complete screenshot workflow, from display configuration through capture timing to format selection, ensuring both visual quality and fingerprint coherence.

Why Screenshot Best Practices Matter

Screenshots serve many purposes in browser automation: visual regression testing, content archival, page monitoring, and data capture from visually rendered content. Each use case has different quality requirements, but all share common failure modes.

The most frequent issue is timing. A screenshot captured before the page fully loads produces a blank or partially rendered image. JavaScript-heavy pages may take several seconds after the initial load event before all content is visible. Font loading adds additional delay, and custom fonts that fail to load produce fallback text that looks nothing like the intended design.

Viewport and resolution mismatches are the second most common issue. A screenshot taken at the wrong viewport size captures either too much or too little content. Device pixel ratio affects the output resolution: a 1920x1080 viewport at 2x DPI produces a 3840x2160 pixel image, which may not be what you expect.

In headless environments, the Xvfb display configuration directly affects rendering. A display configured at 16-bit color depth produces visible color banding. A display resolution smaller than the viewport clips the rendered content.

Technical Background

How Screenshots Work in Headless Chrome

When you request a screenshot through Playwright, Puppeteer, or CDP, the following happens:

  1. The browser compositor renders the page to an off-screen buffer.
  2. The pixel data is captured from this buffer.
  3. The data is encoded to the requested format (PNG or JPEG).
  4. The encoded image is returned as a base64 string or written to disk.

The viewport dimensions determine the size of the compositing buffer. The device pixel ratio determines the scaling factor. A 1920x1080 viewport at 1x DPI produces a 1920x1080 image. The same viewport at 2x DPI produces a 3840x2160 image with sharper text and graphics.

BotBrowser Profile and Screenshots

BotBrowser profiles define screen.width, screen.height, window.innerWidth, window.innerHeight, and devicePixelRatio. These values affect what JavaScript reports when a page queries display properties, but they also influence how content is laid out and rendered.

For consistent fingerprints, the actual rendering viewport should match the profile values. Setting defaultViewport: null in Puppeteer tells the framework not to override these values. In Playwright, the browser context respects profile values by default when no viewport is explicitly set.

Xvfb and Rendering Quality

On headless Linux servers, Xvfb provides the X11 display that Chrome uses for rendering initialization. The Xvfb configuration directly affects output quality:

  • Resolution: Should be at least as large as the largest viewport you use. 1920x1080 covers most desktop profiles. 2560x1440 provides headroom for larger viewports.
  • Color depth: Must be 24-bit (x24). Lower depths cause color banding and affect Canvas fingerprint output.
  • Display number: Any unused number works. :10 is conventional for BotBrowser setups.

<svg viewBox="0 0 700 260" xmlns="http://www.w3.org/2000/svg" style={{maxWidth: '100%', height: 'auto'}}> Screenshot Pipeline Navigate Load Page Wait Content Ready Fonts document.fonts Capture PNG / JPEG Common Timing Failures: Too early: blank or partial page. Missing images, unstyled content. Font not ready: fallback fonts render, text layout shifts after screenshot.

Common Approaches and Limitations

Immediate Capture After Navigation

// Problematic: captures before content loads
await page.goto('https://example.com');
await page.screenshot({ path: 'output.png' });

This captures whatever is on screen at the moment the goto promise resolves with the default load event. For many pages, significant content loads asynchronously after this event. The result is often a partially rendered page.

Fixed Delay

// Fragile: may be too short or too long
await page.goto('https://example.com');
await new Promise(r => setTimeout(r, 3000));
await page.screenshot({ path: 'output.png' });

Adding a fixed delay is unreliable. Three seconds may not be enough for slow pages and is wasteful for fast ones. Network conditions, server response times, and page complexity all vary.

networkidle0 Only

// Better but not perfect
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
await page.screenshot({ path: 'output.png' });

The networkidle0 strategy waits until there are no more than 0 network connections for 500ms. This handles most asynchronous content loading but misses JavaScript-only rendering that does not involve network requests, and it can hang on pages with persistent WebSocket connections or polling requests.

Ignoring Viewport Configuration

Relying on the default viewport (usually 800x600 in Puppeteer) instead of configuring it to match the profile produces screenshots at the wrong dimensions. Content reflows to fit the smaller viewport, producing a mobile-like layout on a desktop profile.

BotBrowser's Approach

BotBrowser profiles include viewport and screen dimension data that represents a real device configuration. The recommended approach is to let the profile control the viewport dimensions rather than setting them manually. This ensures that the screenshot represents what a real user with that device would see.

Preserving Profile Dimensions

Puppeteer: Set defaultViewport: null to prevent Puppeteer from overriding the viewport:

const browser = await puppeteer.launch({
  executablePath: '/opt/botbrowser/chrome',
  args: [
    '--bot-profile=/opt/profiles/profile.enc',
    '--window-size=1920,1080',
  ],
  headless: true,
  defaultViewport: null,
});

Playwright: The browser context inherits profile dimensions by default. If you need to override, do so explicitly:

const browser = await chromium.launch({
  executablePath: '/opt/botbrowser/chrome',
  args: [
    '--bot-profile=/opt/profiles/profile.enc',
  ],
  headless: true,
});

const context = await browser.newContext();
// Profile viewport is preserved

Custom Window Dimensions

When you need a specific viewport regardless of the profile:

chromium-browser \
  --bot-profile="/opt/profiles/profile.enc" \
  --bot-config-window="1920x1080" \
  --bot-config-screen="2560x1440" \
  --window-size=1920,1080 \
  --headless

The --bot-config-window flag updates what JavaScript APIs report. The --window-size flag sets the actual Chromium viewport.

Configuration and Usage

Reliable Page Load Waiting

Combine multiple wait strategies for maximum reliability:

async function waitForPageReady(page, url) {
  // Navigate with networkidle0 for most content
  await page.goto(url, {
    waitUntil: 'networkidle0',
    timeout: 30000,
  });

  // Wait for a specific content element if known
  try {
    await page.waitForSelector('.main-content', {
      visible: true,
      timeout: 5000,
    });
  } catch (e) {
    // Element may not exist on all pages
  }

  // Wait for fonts to finish loading
  await page.evaluate(() => document.fonts.ready);

  // Optional: wait for lazy-loaded images
  await page.evaluate(async () => {
    const images = document.querySelectorAll('img[loading="lazy"]');
    await Promise.all(
      Array.from(images)
        .filter(img => !img.complete)
        .map(img => new Promise(resolve => {
          img.onload = resolve;
          img.onerror = resolve;
        }))
    );
  });
}

Full-Page Screenshots

Capture the entire page, including content below the fold:

await page.screenshot({
  path: 'fullpage.png',
  fullPage: true,
  type: 'png',
});

Note: full-page screenshots can be very large for long pages. A 1920-pixel-wide page that scrolls to 10,000 pixels produces a 19,200,000-pixel image at 1x DPI. Consider capturing only the visible viewport for performance monitoring.

Element-Specific Screenshots

Capture a specific element without the surrounding page:

const element = await page.$('.target-element');
if (element) {
  await element.screenshot({
    path: 'element.png',
    type: 'png',
  });
}

High-DPI Screenshots

For retina-quality screenshots, use a profile with a 2x device pixel ratio, or override it:

// Playwright: set device scale factor in context
const context = await browser.newContext({
  deviceScaleFactor: 2,
});

The resulting PNG will be twice the viewport dimensions in each direction.

Format Selection

PNG is lossless and produces pixel-perfect output. Use it for:

  • Visual regression testing where exact pixel comparison matters
  • Archival where quality cannot be compromised
  • Images with text, lines, or sharp edges

JPEG is lossy but much smaller. Use it for:

  • High-volume capture where storage matters
  • Thumbnails or previews where pixel-perfect quality is unnecessary
  • Photographs or complex images where compression artifacts are less visible
// PNG for quality
await page.screenshot({ path: 'output.png', type: 'png' });

// JPEG for size (quality 0-100)
await page.screenshot({ path: 'output.jpg', type: 'jpeg', quality: 85 });

Viewport-Only vs. Full-Page Comparison

AspectViewport ScreenshotFull-Page Screenshot
SizeFixed, predictableVariable, can be very large
SpeedFastSlower for long pages
ContentAbove-the-fold onlyComplete page content
Use caseMonitoring, comparisonArchival, content extraction

Production Screenshot Function

const puppeteer = require('puppeteer-core');

async function captureScreenshot(url, outputPath, options = {}) {
  const {
    format = 'png',
    quality = 85,
    fullPage = false,
    waitSelector = null,
    timeout = 30000,
  } = options;

  const browser = await puppeteer.launch({
    executablePath: '/opt/botbrowser/chrome',
    args: [
      '--bot-profile=/opt/profiles/profile.enc',
      '--window-size=1920,1080',
    ],
    headless: true,
    defaultViewport: null,
  });

  try {
    const page = await browser.newPage();

    // Navigate and wait for content
    await page.goto(url, {
      waitUntil: 'networkidle0',
      timeout: timeout,
    });

    // Wait for specific element if provided
    if (waitSelector) {
      await page.waitForSelector(waitSelector, {
        visible: true,
        timeout: 10000,
      });
    }

    // Wait for fonts
    await page.evaluate(() => document.fonts.ready);

    // Capture
    const screenshotOptions = {
      path: outputPath,
      type: format,
      fullPage: fullPage,
    };
    if (format === 'jpeg') {
      screenshotOptions.quality = quality;
    }

    await page.screenshot(screenshotOptions);
    console.log(`Screenshot saved: ${outputPath}`);
  } finally {
    await browser.close();
  }
}

// Usage examples
captureScreenshot('https://example.com', '/output/example.png');

captureScreenshot('https://example.com', '/output/example-full.jpg', {
  format: 'jpeg',
  quality: 90,
  fullPage: true,
  waitSelector: '.main-content',
});

Batch Screenshot Capture

For capturing many pages efficiently:

async function batchCapture(urls, outputDir) {
  const browser = await puppeteer.launch({
    executablePath: '/opt/botbrowser/chrome',
    args: [
      '--bot-profile=/opt/profiles/profile.enc',
      '--window-size=1920,1080',
    ],
    headless: true,
    defaultViewport: null,
  });

  try {
    for (let i = 0; i < urls.length; i++) {
      const page = await browser.newPage();
      try {
        await page.goto(urls[i], {
          waitUntil: 'networkidle0',
          timeout: 20000,
        });
        await page.evaluate(() => document.fonts.ready);
        await page.screenshot({
          path: `${outputDir}/page-${i}.png`,
          type: 'png',
        });
      } catch (err) {
        console.error(`Failed: ${urls[i]}`, err.message);
      } finally {
        await page.close();  // Free memory between captures
      }
    }
  } finally {
    await browser.close();
  }
}

Xvfb Configuration for Screenshots

Match the Xvfb resolution to your largest expected viewport:

# Standard 1080p profiles
Xvfb :10 -screen 0 1920x1080x24 &

# 1440p or high-DPI profiles
Xvfb :10 -screen 0 2560x1440x24 &

# Extra headroom for full-page captures
Xvfb :10 -screen 0 3840x2160x24 &

Always use 24-bit color depth. Set DISPLAY=:10.0 before launching BotBrowser.

Verification

Verify screenshot quality with a test capture:

DISPLAY=:10.0 node -e "
const puppeteer = require('puppeteer-core');
(async () => {
  const b = await puppeteer.launch({
    executablePath: '/opt/botbrowser/chrome',
    args: ['--bot-profile=/opt/profiles/profile.enc', '--window-size=1920,1080'],
    headless: true,
    defaultViewport: null,
  });
  const p = await b.newPage();
  await p.goto('https://example.com', { waitUntil: 'networkidle0' });
  await p.evaluate(() => document.fonts.ready);
  await p.screenshot({ path: '/tmp/test-screenshot.png', type: 'png' });
  console.log('Screenshot saved to /tmp/test-screenshot.png');
  await b.close();
})();
"

Check the output file for:

  • Correct dimensions matching the viewport
  • Full color (no banding or color artifacts)
  • All text rendered with proper fonts
  • Complete page content (no missing sections)

Best Practices

Always set defaultViewport: null in Puppeteer. This preserves the profile's viewport dimensions. Without it, Puppeteer defaults to 800x600.

Wait for fonts before capturing. await page.evaluate(() => document.fonts.ready) prevents fallback font rendering.

Use networkidle0 for initial load, then wait for specific elements. This two-stage approach handles most pages reliably.

Close pages between captures in batch operations. This prevents memory accumulation that degrades quality for later screenshots.

Use PNG for accuracy, JPEG for volume. PNG is lossless but larger. JPEG at quality 85-90 is a good balance for most use cases.

Match Xvfb resolution to your viewport. An Xvfb display smaller than your viewport clips the rendering area.

Use 24-bit color depth. Always 1920x1080x24, never 1920x1080x16 or 1920x1080x8.

Frequently Asked Questions

Why are my screenshots blank?

The most common cause is capturing before the page finishes loading. Use waitUntil: 'networkidle0' and add explicit waits for key content elements. Another cause is a missing or misconfigured Xvfb display on Linux.

Why is the screenshot the wrong size?

In Puppeteer, the default viewport is 800x600. Set defaultViewport: null to use the actual window size. For BotBrowser, also check that --window-size or --bot-config-window matches your expected dimensions.

How do I capture a screenshot of an element behind a modal?

Close or hide the modal first using page.evaluate(), then capture the element. Alternatively, capture the full page and crop to the element's bounding box.

Does the device pixel ratio affect screenshot size?

Yes. A viewport of 1920x1080 at 2x DPI produces a 3840x2160 pixel image. This is correct behavior for high-DPI screenshots. If you want a 1920x1080 pixel output, use 1x DPI.

Can I capture screenshots with specific CSS media queries?

Yes. Use page.emulateMediaType('print') for print layout, or inject CSS via page.addStyleTag() for custom media conditions.

How do I handle pages with infinite scroll?

Scroll to the desired position first using page.evaluate(() => window.scrollTo(0, 5000)), wait for content to load, then capture. Full-page screenshots on infinite scroll pages will only capture content that has been loaded.

What is the maximum screenshot size?

Chrome limits the rendering surface. Very long pages (above ~16,000 pixels in height) may produce truncated screenshots. For extremely long pages, capture sections and stitch them together.

How do I compare screenshots for visual regression testing?

Use tools like pixelmatch (npm package) or resemblejs to compare two PNG files pixel by pixel. Set a tolerance threshold to account for minor anti-aliasing differences across runs.

Summary

Reliable screenshot capture in headless environments requires attention to viewport configuration, page load timing, font readiness, and display server setup. With BotBrowser, the profile's viewport and DPI values should drive your configuration. Use defaultViewport: null in Puppeteer, wait for both network idle and font loading before capture, and always use 24-bit Xvfb color depth.

For display setup, see Headless Server Setup. For Docker-based screenshot pipelines, see Docker Deployment Guide. For optimizing screenshot volume at scale, see Optimizing BotBrowser Performance.

#screenshot#headless#deployment#best-practices#production