<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vitalii Holben</title>
    <description>The latest articles on DEV Community by Vitalii Holben (@webmox).</description>
    <link>https://dev.to/webmox</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3842314%2F73646452-030c-4c9e-8633-79bbc79167fe.jpg</url>
      <title>DEV Community: Vitalii Holben</title>
      <link>https://dev.to/webmox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webmox"/>
    <language>en</language>
    <item>
      <title>How to Handle Anti-Bot Measures When Taking Screenshots Programmatically</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Thu, 03 Sep 2026 12:33:16 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-handle-anti-bot-measures-when-taking-screenshots-programmatically-1dc4</link>
      <guid>https://dev.to/webmox/how-to-handle-anti-bot-measures-when-taking-screenshots-programmatically-1dc4</guid>
      <description>&lt;p&gt;How to Handle Anti-Bot Measures When Taking Screenshots Programmatically&lt;/p&gt;

&lt;p&gt;You send a request. The page loads. The screenshot comes back blank, or shows a CAPTCHA, or captures a "Please verify you're human" wall. This is one of the most common problems when building any screenshot pipeline.&lt;/p&gt;

&lt;p&gt;Here's what's actually happening and how to deal with it.&lt;/p&gt;

&lt;h2&gt;Why headless browsers get flagged&lt;/h2&gt;

&lt;p&gt;Bot detection works by looking for patterns that differ from real users. Headless Chrome has several tells:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;navigator.webdriver&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; by default&lt;/li&gt;
&lt;li&gt;Missing Chrome-specific properties like &lt;code&gt;window.chrome&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Inconsistent screen dimensions (no monitor attached means no GPU info)&lt;/li&gt;
&lt;li&gt;Mouse events fire at pixel-perfect coordinates with no jitter&lt;/li&gt;
&lt;li&gt;Font fingerprints differ from headed browsers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Modern detection services (Cloudflare, Akamai, Datadome) look for combinations of these signals, not individual flags. Spoofing one without the others often makes the fingerprint &lt;em&gt;more&lt;/em&gt; suspicious, not less.&lt;/p&gt;

&lt;h2&gt;The practical spectrum of detection&lt;/h2&gt;

&lt;p&gt;Most sites fall into one of three categories:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No active detection&lt;/strong&gt; — a basic bot check via User-Agent string at most. Simple fix: set a realistic UA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passive fingerprinting&lt;/strong&gt; — loads a detection script, collects signals, blocks on second or third visit. You'll see this on news sites, e-commerce, media platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Active challenges&lt;/strong&gt; — Cloudflare Turnstile, hCaptcha, reCAPTCHA v3 score-based. These require real interaction or a solving service.&lt;/p&gt;

&lt;p&gt;Know which category your target falls into before spending time on it.&lt;/p&gt;

&lt;h2&gt;Fixes that work for most cases&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Use a stealth plugin&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For Playwright, &lt;code&gt;playwright-extra&lt;/code&gt; with &lt;code&gt;puppeteer-extra-plugin-stealth&lt;/code&gt; patches the most common fingerprinting vectors:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;npm install playwright-extra puppeteer-extra-plugin-stealth
&lt;/code&gt;&lt;/pre&gt;

&lt;pre&gt;&lt;code&gt;import { chromium } from 'playwright-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';

chromium.use(StealthPlugin());
const browser = await chromium.launch();
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This handles &lt;code&gt;navigator.webdriver&lt;/code&gt;, &lt;code&gt;window.chrome&lt;/code&gt;, and several other flags automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Set realistic headers&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;await page.setExtraHTTPHeaders({
  'Accept-Language': 'en-US,en;q=0.9',
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
  'sec-ch-ua': '"Chromium";v="120", "Google Chrome";v="120"',
  'sec-ch-ua-mobile': '?0',
  'sec-ch-ua-platform': '"macOS"',
});
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;3. Use a real Chrome binary, not Chromium&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chromium's font set differs from Chrome. Detection services track which fonts are available. Running headful Chrome via &lt;code&gt;executablePath&lt;/code&gt; gives you a more convincing fingerprint than the bundled Chromium.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Add realistic delays and mouse movement&lt;/strong&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// Move mouse before clicking
await page.mouse.move(100 + Math.random() * 50, 200 + Math.random() * 30);
await page.waitForTimeout(300 + Math.random() * 500);
await page.click('#target');
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Perfectly timed, pixel-precise interactions are a dead giveaway. Add jitter.&lt;/p&gt;

&lt;h2&gt;When the above isn't enough&lt;/h2&gt;

&lt;p&gt;Some sites run heavy fingerprinting that's hard to spoof at the browser level. Options at this point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Residential proxies&lt;/strong&gt; — rotate real residential IPs. The IP reputation matters as much as the browser fingerprint. Datacenter IPs are often pre-blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser profiles&lt;/strong&gt; — maintain persistent browser profiles with cookies, browsing history, and localStorage. A "fresh" headless browser looks different from a browser with 3 weeks of history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Playwright with persistent context&lt;/strong&gt;:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const context = await chromium.launchPersistentContext('./user-data-dir', {
  headless: false, // or true with stealth
});
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;The ethical and legal line&lt;/h2&gt;

&lt;p&gt;There's a difference between taking screenshots of public pages for archival, testing, or display purposes — which is generally fine — and bypassing authentication or rate limits to scrape data at scale. The former is what most screenshot tools do. The latter runs into Terms of Service issues and, in some jurisdictions, legal risk.&lt;/p&gt;

&lt;p&gt;Worth being clear on which side of that line your use case sits before investing in bypass techniques.&lt;/p&gt;

&lt;h2&gt;What to monitor&lt;/h2&gt;

&lt;p&gt;Once you have a working pipeline, track failure reasons explicitly:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const response = await page.goto(url, { waitUntil: 'networkidle2' });
if (response.status() === 403 || response.status() === 429) {
  // Bot detection hit — log, rotate IP, back off
}

const pageTitle = await page.title();
if (pageTitle.toLowerCase().includes('captcha') || pageTitle.includes('verify')) {
  // Challenge page — flag for manual review
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Silent failures — a 200 status but a CAPTCHA screenshot — are harder to catch. Use a content hash comparison or check for expected elements before saving the screenshot.&lt;/p&gt;

&lt;p&gt;The problem doesn't go away entirely, but being systematic about detection and response turns a reliability nightmare into something manageable.&lt;/p&gt;

</description>
      <category>websitemonitoring</category>
      <category>screenshot</category>
      <category>archive</category>
      <category>changedetection</category>
    </item>
    <item>
      <title>How to Add Visual Regression Testing to Your CI Pipeline</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:15:13 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-add-visual-regression-testing-to-your-ci-pipeline-341e</link>
      <guid>https://dev.to/webmox/how-to-add-visual-regression-testing-to-your-ci-pipeline-341e</guid>
      <description>&lt;h2&gt;How to Add Visual Regression Testing to Your CI Pipeline&lt;/h2&gt;

&lt;p&gt;Your CSS change looked fine in the browser. You merged it. The deployment went out. Then someone noticed the pricing page hero section collapsed on mobile. The button text wrapped. The testimonial card lost its shadow.&lt;/p&gt;

&lt;p&gt;Visual regression testing catches these before they reach production. Here's how to set it up without overcomplicating your pipeline.&lt;/p&gt;

&lt;h3&gt;The Concept&lt;/h3&gt;

&lt;p&gt;Take screenshots of key pages before and after a code change. Compare them pixel by pixel. If the difference exceeds a threshold, fail the build.&lt;/p&gt;

&lt;p&gt;It's the visual equivalent of unit tests — you define what "correct" looks like, and the system tells you when something deviates.&lt;/p&gt;

&lt;h3&gt;Baseline Screenshots&lt;/h3&gt;

&lt;p&gt;First, capture reference screenshots of your pages in a known-good state. These are your baselines:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# capture-baselines.sh
PAGES=(
  "/"
  "/pricing"
  "/features"
  "/docs/getting-started"
  "/blog"
)

for page in "${PAGES[@]}"; do
  slug=$(echo "$page" | sed 's/\//_/g' | sed 's/^_//')
  [ -z "$slug" ] &amp;amp;&amp;amp; slug="index"
  
  curl -s "https://screenshotrun.com/api/v1/screenshot?\
url=https://staging.yourapp.com${page}&amp;amp;\
width=1280&amp;amp;\
format=png&amp;amp;\
full_page=false" \
    -H "Authorization: Bearer $SCREENSHOT_API_KEY" \
    -o "baselines/${slug}.png"
    
  echo "Captured baseline: $slug"
done
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Store baselines in your repository or artifact storage. They update only when you intentionally approve visual changes.&lt;/p&gt;

&lt;h3&gt;CI Comparison Script&lt;/h3&gt;

&lt;p&gt;On each pull request, capture the same pages from your preview/staging environment and compare against baselines:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
"""
visual_regression.py — compare current screenshots against baselines.
Exit code 1 if any page exceeds the diff threshold.
"""

import os
import sys
import requests
from pathlib import Path

try:
    from PIL import Image
    import numpy as np
except ImportError:
    print("pip install Pillow numpy")
    sys.exit(1)

API_URL = "https://screenshotrun.com/api/v1/screenshot"
API_KEY = os.environ["SCREENSHOT_API_KEY"]
STAGING_URL = os.environ.get("STAGING_URL", "https://staging.yourapp.com")
THRESHOLD = float(os.environ.get("VISUAL_DIFF_THRESHOLD", "2.0"))

PAGES = {
    "index": "/",
    "pricing": "/pricing",
    "features": "/features",
    "docs": "/docs/getting-started",
    "blog": "/blog",
}


def capture(url: str) -&amp;gt; bytes:
    params = {
        "url": url,
        "width": 1280,
        "format": "png",
        "full_page": False,
        "delay": 2,
    }
    resp = requests.get(
        API_URL,
        params=params,
        headers={"Authorization": f"Bearer {API_KEY}"},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.content


def compare_images(baseline_path: str, current_bytes: bytes) -&amp;gt; float:
    baseline = np.array(Image.open(baseline_path).convert("RGB"))
    current = np.array(Image.open(
        __import__("io").BytesIO(current_bytes)
    ).convert("RGB"))

    # Resize if dimensions don't match
    if baseline.shape != current.shape:
        h = min(baseline.shape[0], current.shape[0])
        w = min(baseline.shape[1], current.shape[1])
        baseline = baseline[:h, :w]
        current = current[:h, :w]

    diff = np.abs(baseline.astype(int) - current.astype(int))
    changed_pixels = np.any(diff &amp;gt; 25, axis=2).sum()
    total_pixels = diff.shape[0] * diff.shape[1]
    return (changed_pixels / total_pixels) * 100


def main():
    baselines_dir = Path("baselines")
    failures = []

    for slug, path in PAGES.items():
        baseline_file = baselines_dir / f"{slug}.png"
        if not baseline_file.exists():
            print(f"  SKIP {slug}: no baseline")
            continue

        url = f"{STAGING_URL}{path}"
        print(f"  Checking {slug} ({url})...")

        img_bytes = capture(url)
        diff_pct = compare_images(str(baseline_file), img_bytes)

        if diff_pct &amp;gt; THRESHOLD:
            failures.append((slug, diff_pct))
            print(f"  FAIL {slug}: {diff_pct:.2f}% changed (threshold: {THRESHOLD}%)")

            # Save the current screenshot for review
            Path("diffs").mkdir(exist_ok=True)
            (Path("diffs") / f"{slug}_current.png").write_bytes(img_bytes)
        else:
            print(f"  OK   {slug}: {diff_pct:.2f}% changed")

    if failures:
        print(f"\n{len(failures)} page(s) exceeded visual diff threshold.")
        sys.exit(1)

    print("\nAll pages passed visual regression check.")


if __name__ == "__main__":
    main()
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;GitHub Actions Integration&lt;/h3&gt;

&lt;pre&gt;&lt;code&gt;name: Visual Regression
on: [pull_request]

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      - name: Install dependencies
        run: pip install requests Pillow numpy
      
      - name: Run visual regression tests
        env:
          SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }}
          STAGING_URL: ${{ env.PREVIEW_URL }}
          VISUAL_DIFF_THRESHOLD: "2.0"
        run: python visual_regression.py
      
      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: diffs/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When the check fails, reviewers can download the diff artifacts and see exactly which pages changed and how.&lt;/p&gt;

&lt;h3&gt;Choosing What to Test&lt;/h3&gt;

&lt;p&gt;Don't screenshot every page. Focus on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Revenue-critical pages&lt;/strong&gt; — pricing, checkout, signup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-traffic pages&lt;/strong&gt; — homepage, main landing pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Component-heavy pages&lt;/strong&gt; — pages that use many shared components, so a single CSS change cascades&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip pages with dynamic content (dashboards, feeds) unless you can seed them with consistent test data.&lt;/p&gt;

&lt;h3&gt;Managing Baseline Updates&lt;/h3&gt;

&lt;p&gt;When you intentionally change a page's appearance, the visual test will fail. That's expected. The workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;PR changes CSS/layout&lt;/li&gt;
&lt;li&gt;Visual test fails (expected)&lt;/li&gt;
&lt;li&gt;Review the diff artifacts to confirm changes look correct&lt;/li&gt;
&lt;li&gt;Update baselines: &lt;code&gt;./capture-baselines.sh&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Commit new baselines&lt;/li&gt;
&lt;li&gt;Visual test passes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Some teams automate baseline updates with a bot comment — reply "update baselines" on the PR, and a workflow captures fresh screenshots and commits them.&lt;/p&gt;

&lt;h3&gt;Threshold Tuning&lt;/h3&gt;

&lt;p&gt;A 0% threshold catches everything, including subpixel rendering differences between environments. Too noisy.&lt;/p&gt;

&lt;p&gt;A 5% threshold misses subtle changes — a shifted button, a truncated label. Too loose.&lt;/p&gt;

&lt;p&gt;Start at 1-2% and adjust based on your false positive rate. Different pages might need different thresholds — a text-heavy docs page is more stable than a marketing page with animations.&lt;/p&gt;

&lt;h3&gt;Cost and Performance&lt;/h3&gt;

&lt;p&gt;Each screenshot API call takes 2-5 seconds. Testing 10 pages adds under a minute to your CI pipeline. The cost is minimal compared to the engineering time spent debugging visual bugs that shipped to production.&lt;/p&gt;

&lt;p&gt;The tricky part isn't the technology — it's the discipline of maintaining baselines and reviewing diffs. Treat visual tests like any other test: if they fail, investigate before merging.&lt;/p&gt;

&lt;p&gt;Using &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;ScreenshotRun&lt;/a&gt; for the capture step keeps the infrastructure simple — no need to maintain your own headless browser pool in CI. Send a URL, get a screenshot, compare locally.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Build a Link Preview Thumbnail Service in Node.js</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:42:03 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-build-a-link-preview-thumbnail-service-in-nodejs-2ael</link>
      <guid>https://dev.to/webmox/how-to-build-a-link-preview-thumbnail-service-in-nodejs-2ael</guid>
      <description>&lt;p&gt;Ever built a link aggregator, bookmarking tool, or dashboard that shows URLs? At some point you need thumbnails for those links. Users paste a URL, your app should show a preview — like Slack, Twitter cards, or Notion bookmarks.&lt;/p&gt;

&lt;p&gt;You could self-host a browser instance and manage Puppeteer yourself. I did that for about a year. Scaling it was painful, and the memory leaks were real.&lt;/p&gt;

&lt;p&gt;Here's how I rebuilt it with a screenshot API instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A simple Express service that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Accepts a URL&lt;/li&gt;
&lt;li&gt;Returns a thumbnail image (cached)&lt;/li&gt;
&lt;li&gt;Handles errors gracefully&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing is under 80 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;link-thumbs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;link-thumbs
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;express node-cache axios sharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The service
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NodeCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node-cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;axios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NodeCache&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stdTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 24h cache&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SCREENSHOT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://screenshotrun.com/api/screenshot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getThumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;thumbWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;thumbWidth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;full_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;responseType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arraybuffer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// resize to thumbnail&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inside&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/thumb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url parameter required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// basic URL validation&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getThumbnail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public, max-age=86400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;screenshot failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Thumbnail service on :3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# get a thumbnail&lt;/span&gt;
curl &lt;span class="s2"&gt;"http://localhost:3000/thumb?url=https://github.com"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; github.webp

&lt;span class="c"&gt;# custom width&lt;/span&gt;
curl &lt;span class="s2"&gt;"http://localhost:3000/thumb?url=https://dev.to&amp;amp;w=600"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; devto.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your frontend, just use it as an image source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; 
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/thumb?url=https://example.com"&lt;/span&gt; 
  &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"example.com preview"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why not self-host Puppeteer
&lt;/h2&gt;

&lt;p&gt;I ran a self-hosted setup for a year. Here's what I dealt with:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; Each Chromium instance eats 200-400MB. With 10 concurrent requests, you're looking at 4GB just for the browser processes. My 8GB droplet would OOM about once a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zombie processes.&lt;/strong&gt; Browsers crash. Tabs hang. Pages with infinite scroll or heavy JavaScript would occasionally lock up a tab. I wrote a watchdog script to kill stuck processes, which itself had bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Font rendering.&lt;/strong&gt; Missing fonts on Linux servers produce garbage screenshots. I installed a font pack, but CJK sites still looked wrong. Kept finding edge cases for months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts.&lt;/strong&gt; Some pages just take forever. A 30-second timeout seems generous until you hit a page that loads 47 third-party scripts and triggers a cookie consent modal that blocks rendering.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; handles all of this. Browser pool management, font libraries, timeout handling, retries — someone else's problem. My service just makes HTTP calls and resizes images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Improvements worth adding
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting.&lt;/strong&gt; Without it, someone will feed your service 10,000 URLs and you'll burn through API credits. I use &lt;code&gt;express-rate-limit&lt;/code&gt; — 10 requests per minute per IP is reasonable for most use cases.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rateLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express-rate-limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/thumb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;windowMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Persistent cache.&lt;/strong&gt; NodeCache is in-memory — restarts kill it. For production, swap to Redis or just write thumbnails to disk with the URL hash as filename. Disk cache is underrated for images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fallback image.&lt;/strong&gt; When a screenshot fails (site is down, blocked by firewall, etc.), return a generic placeholder instead of an error. Your UI shouldn't break because one link preview failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#f0f0f0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;webp&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Queue for bulk requests.&lt;/strong&gt; If you're generating thumbnails for an import of 500 bookmarks, don't fire 500 concurrent API calls. Use a simple queue — bull or even just a promise pool with concurrency of 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Open Graph images?
&lt;/h2&gt;

&lt;p&gt;OG images are great when they exist. But in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~30% of URLs have no OG image at all&lt;/li&gt;
&lt;li&gt;Many OG images are generic company logos, not page-specific&lt;/li&gt;
&lt;li&gt;Some are broken CDN links&lt;/li&gt;
&lt;li&gt;Quality varies wildly — some are 100x100 pixels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use OG images as a first try, falling back to a screenshot when OG is missing or low quality. Best of both worlds — fast when OG exists, accurate when it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production numbers
&lt;/h2&gt;

&lt;p&gt;Running this for a bookmarking app with ~2K daily active users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average thumbnail generation: 2-3 seconds (cold), &lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>Generating PDFs From Web Pages Without a PDF Library</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:11:02 +0000</pubDate>
      <link>https://dev.to/webmox/generating-pdfs-from-web-pages-without-a-pdf-library-2i61</link>
      <guid>https://dev.to/webmox/generating-pdfs-from-web-pages-without-a-pdf-library-2i61</guid>
      <description>&lt;p&gt;Most PDF generation tutorials start with "install this PDF library" and then you spend an hour fighting with fonts, CSS support, and layout quirks. There's a simpler path if your content already lives on a web page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;Instead of rendering HTML to PDF inside your app, render the page in a real browser and print it to PDF. Chrome's DevTools Protocol has &lt;code&gt;Page.printToPDF&lt;/code&gt; — same engine that powers "Save as PDF" in your browser, but automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick setup with Puppeteer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageToPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;15mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;15mm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;pageToPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/invoice/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Full CSS support, web fonts, flexbox, grid — everything renders exactly as it does in Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that tripped me up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Print stylesheets matter.&lt;/strong&gt; If the page has &lt;code&gt;@media print&lt;/code&gt; rules, they'll apply. This is actually useful — hide navigation, sidebars, cookie banners. But if you're not expecting it, your PDF might look nothing like the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="n"&gt;print&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;nav&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;footer&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.cookie-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;waitUntil: 'networkidle0'&lt;/code&gt; isn't always enough.&lt;/strong&gt; Pages with lazy-loaded images or JS-rendered charts might need explicit waits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.chart-container canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chart not found, proceeding anyway&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I learned this the hard way — generated 200 invoices with blank chart sections before noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Headers and footers.&lt;/strong&gt; The &lt;code&gt;headerTemplate&lt;/code&gt; and &lt;code&gt;footerTemplate&lt;/code&gt; options exist but they're fiddly. They use a tiny isolated context with limited CSS. For anything complex, I just bake the header into the page HTML itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this approach makes sense
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Invoices, reports, receipts — anything you already display on a web page&lt;/li&gt;
&lt;li&gt;Generating PDFs from user-created content (blog posts, documentation)&lt;/li&gt;
&lt;li&gt;Any situation where you want the PDF to match the web version pixel-for-pixel&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where it doesn't: high-volume generation (thousands per minute) where the browser overhead adds up, or cases where you need PDF-specific features like form fields or digital signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it without managing Chrome
&lt;/h2&gt;

&lt;p&gt;If you don't want to deal with keeping a Chrome instance running on your server — especially in containerized environments where Chromium dependencies are a pain — screenshot APIs like ScreenshotRun handle the browser part. You send a URL, get back a PDF or PNG. Offloads the rendering infrastructure entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;page.pdf()&lt;/code&gt; method has a &lt;code&gt;scale&lt;/code&gt; option. Default is 1. Setting it to 0.8 or 0.9 can help fit content that's slightly too wide for the page. Beats reworking your CSS for print.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;output.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not something I see mentioned often but it's saved me from the "content gets cut off on the right edge" problem more than once.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Screenshot Dynamic Content That Changes on Every Page Load</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Mon, 03 Aug 2026 19:47:31 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-screenshot-dynamic-content-that-changes-on-every-page-load-360j</link>
      <guid>https://dev.to/webmox/how-to-screenshot-dynamic-content-that-changes-on-every-page-load-360j</guid>
      <description>&lt;p&gt;Some pages look different every time you load them. Randomized hero banners, A/B test variants, personalized product recommendations, rotating testimonials. If you're automating screenshots for testing or documentation, this is a problem.&lt;/p&gt;

&lt;p&gt;You capture a baseline. You capture again tomorrow. The diff lights up red — not because anything broke, but because the carousel rotated and a different testimonial is showing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two types of dynamic content
&lt;/h2&gt;

&lt;p&gt;There's &lt;strong&gt;server-side dynamic&lt;/strong&gt; (different HTML on each request) and &lt;strong&gt;client-side dynamic&lt;/strong&gt; (same HTML, JavaScript shuffles things around after load). The fix is different for each.&lt;/p&gt;

&lt;p&gt;Server-side: you need to control the request. Pass a seed parameter, set a cookie, or hit a specific URL variant. If the page personalizes based on geo-IP, you need to pin your request to a consistent location.&lt;/p&gt;

&lt;p&gt;Client-side: you need to run JavaScript before or after the page renders to freeze the randomization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freezing Math.random
&lt;/h2&gt;

&lt;p&gt;A lot of front-end randomization uses &lt;code&gt;Math.random()&lt;/code&gt;. Override it before the page loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateOnNewDocument&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;random&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;16807&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2147483647&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2147483646&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every "random" value is deterministic. The carousel always shows the same slide. The A/B test always picks the same variant. Your screenshots become comparable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling date/time displays
&lt;/h2&gt;

&lt;p&gt;Pages that show "Posted 3 hours ago" or "Last updated: August 3, 2026" will always diff because the timestamp changes. Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: Freeze the clock&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateOnNewDocument&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-15T12:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OrigDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;OrigDate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option 2: Mask the regions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If freezing the clock breaks page logic (timers, session management), mask the date regions in your diff comparison instead. Mark those areas as "ignore" in your visual diff tool.&lt;/p&gt;

&lt;p&gt;I prefer option 1 for screenshot capture and option 2 for visual regression testing. Depends on whether you need the screenshot to look realistic or just be consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS animations and transitions
&lt;/h2&gt;

&lt;p&gt;An animated element captured mid-transition looks different every time. Kill all animations before capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addStyleTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`
    *, *::before, *::after {
      animation-duration: 0s !important;
      animation-delay: 0s !important;
      transition-duration: 0s !important;
      transition-delay: 0s !important;
    }
  `&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one saved me a ton of debugging. Had a landing page where a fade-in animation on the hero image made every screenshot slightly different in opacity. Took me a while to figure out it wasn't a rendering bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy-loaded images and intersection observers
&lt;/h2&gt;

&lt;p&gt;Content that loads on scroll won't appear in a screenshot if the viewport never scrolls to it. For full-page screenshots, scroll the page programmatically first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scroll to the bottom, trigger all the lazy loads, scroll back to top, then capture. Not elegant but it works reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you can't control the content
&lt;/h2&gt;

&lt;p&gt;Sometimes you're screenshotting third-party pages you don't own. You can't inject seeds or override &lt;code&gt;Math.random&lt;/code&gt;. In that case, accept that the screenshots will vary and adjust your comparison threshold.&lt;/p&gt;

&lt;p&gt;Instead of pixel-perfect diffs, use structural comparison. Did the layout change? Did elements move? Is text content different? A 2-3% pixel difference from a rotated banner is noise. A 15% difference from a missing navigation bar is a real issue.&lt;/p&gt;

&lt;p&gt;The goal isn't identical screenshots — it's catching meaningful changes while ignoring expected variation.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>Building a Visual Changelog for Your Web App</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Sun, 26 Jul 2026 22:28:15 +0000</pubDate>
      <link>https://dev.to/webmox/building-a-visual-changelog-for-your-web-app-3neo</link>
      <guid>https://dev.to/webmox/building-a-visual-changelog-for-your-web-app-3neo</guid>
      <description>&lt;p&gt;You ship a frontend change, merge the PR, deploy to production. Two days later your PM asks "what exactly changed on the pricing page last Tuesday?" and you're digging through git logs trying to reconstruct what the page looked like before and after.&lt;/p&gt;

&lt;p&gt;Git tracks code. It doesn't track what the rendered page actually looked like to users.&lt;/p&gt;

&lt;p&gt;A visual changelog fixes that. Capture a screenshot of key pages after every deploy, store them with timestamps, and you've got a browsable history of how your app looked at any point. Here's how to set one up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic idea
&lt;/h2&gt;

&lt;p&gt;After each deploy, a script hits a list of URLs and saves screenshots with metadata:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;URL&lt;/li&gt;
&lt;li&gt;Timestamp&lt;/li&gt;
&lt;li&gt;Git commit hash&lt;/li&gt;
&lt;li&gt;Deploy environment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You end up with a folder (or database) of timestamped screenshots you can scroll through. "What did the homepage look like three deploys ago?" becomes a five-second lookup instead of a twenty-minute archaeology project.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal implementation
&lt;/h2&gt;

&lt;p&gt;Here's a Node.js script that captures screenshots of a list of pages using Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;homepage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.com/pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.com/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COMMIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GIT_COMMIT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;:.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;changelog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PAGES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`captured &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// save metadata&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this as a post-deploy hook. In a GitHub Actions workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Capture visual changelog&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node capture-changelog.js&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;GIT_COMMIT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The problem with running your own browser
&lt;/h2&gt;

&lt;p&gt;This works. I used a setup like this for about four months. Then I hit the issues:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; Chromium eats 300-500MB per instance. On a CI runner with 4GB RAM, capturing 15 pages sequentially takes a while. Doing it in parallel kills the runner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Font rendering.&lt;/strong&gt; Screenshots taken on Ubuntu CI runners look different from macOS or Windows. If you're comparing screenshots across deploys and your CI runner image updated, every single screenshot shows a "diff" that's really just font antialiasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flaky waits.&lt;/strong&gt;  doesn't mean "the page looks right." SPAs that fetch data on mount will show loading spinners in half your captures. You end up writing per-page wait logic and maintaining it forever.&lt;/p&gt;

&lt;p&gt;After dealing with all three I switched to hitting a screenshot API (I use ScreenshotRun for this) instead of running Playwright locally. Same script structure, but instead of launching a browser you make an HTTP call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node-fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureViaApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.screenshotrun.com/capture?`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;width=1440&amp;amp;height=900&amp;amp;full_page=true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer YOUR_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No browser to manage, consistent rendering environment, and it handles the wait logic for you. Tradeoff is you're paying per screenshot, but for a changelog that captures 10-20 pages per deploy it's negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the changelog browsable
&lt;/h2&gt;

&lt;p&gt;A folder of PNGs isn't great for anyone except the person who set it up. Build a simple viewer.&lt;/p&gt;

&lt;p&gt;The laziest version that actually works: generate an HTML file that lists screenshots grouped by deploy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changelogDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deploys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changelogDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changelogDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deploy&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;deploys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changelogDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meta.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;h2&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h2&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`
        &amp;lt;h3&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/h3&amp;gt;
        &amp;lt;img src="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png" style="max-width: 100%; border: 1px solid #ccc;" /&amp;gt;
      `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;changelogDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy this to a static hosting bucket or serve it from an internal tool. PMs and designers can now browse the visual history without asking engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-side diffing
&lt;/h2&gt;

&lt;p&gt;The real power comes when you compare consecutive deploys. For each page, show the previous and current screenshot next to each other.&lt;/p&gt;

&lt;p&gt;You can get fancier with pixel-level diffing using a library like :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pixelmatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pixelmatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PNG&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pngjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;diffScreenshots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;beforePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;afterPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diffPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PNG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;beforePath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PNG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;afterPath&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PNG&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mismatchedPixels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pixelmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diffPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PNG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;mismatchedPixels&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If  is above zero, something changed visually. You can even fail the deploy or send a Slack notification when unexpected visual changes are detected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pages to track
&lt;/h2&gt;

&lt;p&gt;Don't capture everything. Start with pages that matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Landing page and pricing&lt;/strong&gt; — these change often and have business impact&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth flows&lt;/strong&gt; — login, signup, password reset. Regressions here directly cost you users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core product screens&lt;/strong&gt; — the 3-5 pages users spend the most time on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email templates&lt;/strong&gt; — if you render them as HTML, screenshot those too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I started with 6 pages and now track about 20. Add pages when something breaks that you wish you'd been tracking. Don't over-engineer it upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd skip
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pages with lots of dynamic content (dashboards with live data, feeds with user-generated content). The screenshots will always look "different" and you'll stop paying attention.&lt;/li&gt;
&lt;li&gt;Admin panels. Unless you've got non-technical admins who report UI bugs, probably not worth it.&lt;/li&gt;
&lt;li&gt;Every single marketing page. Track the ones that convert, not the ones that exist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole setup takes maybe an afternoon. The first time someone asks "when did the pricing page change" and you pull it up in ten seconds, it pays for itself.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Know When Your Cron Job Silently Stops Running</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:50:00 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-know-when-your-cron-job-silently-stops-running-d94</link>
      <guid>https://dev.to/webmox/how-to-know-when-your-cron-job-silently-stops-running-d94</guid>
      <description>&lt;p&gt;Had a backup script running every night at 2 AM for about six months. Worked great. Then one day I needed to restore something and discovered the backups stopped three weeks ago. The cron entry was still there. No errors in syslog. The script just... wasn't running.&lt;/p&gt;

&lt;p&gt;Turned out a server update changed the PATH and the script couldn't find &lt;code&gt;pg_dump&lt;/code&gt; anymore. It failed silently because I was redirecting stderr to /dev/null like a genius.&lt;/p&gt;

&lt;h2&gt;The core problem&lt;/h2&gt;

&lt;p&gt;Cron doesn't care if your job succeeds. It cares if it &lt;em&gt;runs&lt;/em&gt;. And even "runs" is generous — if cron fires the command and it exits immediately with an error, cron considers that a success.&lt;/p&gt;

&lt;p&gt;Most setups have zero monitoring on whether scheduled jobs actually complete. You find out they broke when the thing they were supposed to do didn't happen, which could be days or weeks later.&lt;/p&gt;

&lt;h2&gt;Dead man's switch — the simplest fix&lt;/h2&gt;

&lt;p&gt;The idea is backwards from normal monitoring. Instead of checking if something fails, you check if it &lt;em&gt;doesn't report success&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;At the end of your cron script, ping a URL:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/bin/bash
pg_dump mydb &amp;gt; /backups/mydb_$(date +%F).sql

if [ $? -eq 0 ]; then
    curl -fsS --retry 3 https://your-monitoring/ping/abc123 &amp;gt; /dev/null
fi
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If the monitoring service doesn't receive that ping within the expected window (say, 30 minutes after 2 AM), it alerts you.&lt;/p&gt;

&lt;p&gt;That's it. No agent to install, no daemon running, no config files. One curl at the end of your script.&lt;/p&gt;

&lt;h2&gt;What trips people up&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Forgetting to check the exit code.&lt;/strong&gt; If you just put the curl at the end without the &lt;code&gt;if&lt;/code&gt; check, it pings even when the job fails. Defeats the entire purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeout too tight.&lt;/strong&gt; Your job usually takes 5 minutes but sometimes takes 20. If your monitoring window is 10 minutes, you'll get false alerts. Set it to 2x your worst case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple schedules.&lt;/strong&gt; A job that runs every hour needs a different timeout than one that runs daily. I've seen people set up monitoring for a daily job and use a 1-hour timeout — so it alerts 23 times before the next run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Swallowed output.&lt;/strong&gt; Stop doing &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; on cron jobs. Send stderr somewhere useful:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;*/5 * * * * /path/to/script.sh &amp;gt;&amp;gt; /var/log/myjob.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;DIY vs hosted&lt;/h2&gt;

&lt;p&gt;You can build this yourself. A small endpoint that logs pings, a checker that runs every minute and compares last-ping time against expected schedule, and a notification sender. Maybe 200 lines of code.&lt;/p&gt;

&lt;p&gt;I did that once. It worked until the monitoring server itself went down and nobody noticed because nothing was monitoring the monitor. Classic.&lt;/p&gt;

&lt;p&gt;Hosted services handle the reliability part — redundant infrastructure, multiple notification channels, escalation if you don't acknowledge. Worth it if you have more than a handful of jobs to track.&lt;/p&gt;

&lt;h2&gt;The minimum I'd set up for any project&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Every cron job pings a URL on successful completion&lt;/li&gt;
&lt;li&gt;Every ping has a timeout matched to the job's schedule&lt;/li&gt;
&lt;li&gt;Failed pings send notifications to Slack or email, not just a dashboard&lt;/li&gt;
&lt;li&gt;Cron output goes to a log file, not /dev/null&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Takes maybe 15 minutes per job. Saves you from the "wait, when did this stop working?" conversation that nobody enjoys.&lt;/p&gt;

</description>
      <category>cronmonitoring</category>
      <category>scheduledtasks</category>
      <category>uptime</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Build Rich Link Previews Without Maintaining a Browser Instance</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:46:36 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-build-rich-link-previews-without-maintaining-a-browser-instance-4jp8</link>
      <guid>https://dev.to/webmox/how-to-build-rich-link-previews-without-maintaining-a-browser-instance-4jp8</guid>
      <description>&lt;h2&gt;
  
  
  The problem with link previews
&lt;/h2&gt;

&lt;p&gt;You paste a URL in Slack and get a nice preview card. Title, description, maybe a thumbnail. Users expect the same thing in your app.&lt;/p&gt;

&lt;p&gt;The "standard" approach is parsing Open Graph tags. Fetch the page, grab &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, done. Works for maybe 60% of URLs. The rest either don't have OG tags, have broken ones, or the image URL is a relative path that resolves to nothing.&lt;/p&gt;

&lt;p&gt;So you think: I'll just screenshot the page and use that. Spin up Puppeteer, navigate to the URL, take a screenshot, crop it, serve it. Now you're maintaining a headless browser in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why self-hosted Puppeteer gets painful
&lt;/h2&gt;

&lt;p&gt;I ran this setup for about four months. Here's what went wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory leaks.&lt;/strong&gt; Chromium tabs that don't close properly. After a few hundred screenshots, the container would eat 2GB+ of RAM and start timing out. Had to add a cron job that restarted the service every 6 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Font rendering.&lt;/strong&gt; Pages with custom fonts looked wrong because the server didn't have them installed. You can install common font packages but you'll never have everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SPA timing.&lt;/strong&gt; React and Vue apps need you to wait for JS to finish rendering. &lt;code&gt;waitUntil: 'networkidle0'&lt;/code&gt; works sometimes. Other times you need custom wait logic per site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaling.&lt;/strong&gt; Each screenshot blocks a browser tab for 2-10 seconds. Want to handle 50 concurrent requests? That's 50 Chromium instances. Good luck with your cloud bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API approach
&lt;/h2&gt;

&lt;p&gt;After the third OOM incident, I moved to &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;ScreenshotRun&lt;/a&gt; and the implementation got boring — in a good way. One HTTP call, get back an image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLinkPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.screenshotrun.com/capture`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SCREENSHOT_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// CDN URL to the screenshot&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No browser pool, no memory management, no font issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching strategy
&lt;/h2&gt;

&lt;p&gt;You don't want to screenshot the same URL every time someone loads a page. I cache the preview image URL in Redis with a 24-hour TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCachedPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`preview:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;hashUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLinkPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most apps, a 24-hour cache is fine. Users rarely share the same URL twice within minutes, and sites don't change their layout that often.&lt;/p&gt;

&lt;h2&gt;
  
  
  OG tags as fallback
&lt;/h2&gt;

&lt;p&gt;I still parse OG tags first. If the page has a decent &lt;code&gt;og:image&lt;/code&gt; (actual URL, not a relative path, responds with 200), I use that. Screenshots are the fallback for pages without good metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;parseOGTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isValidImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Fall back to screenshot&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screenshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCachedPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;screenshot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Numbers after switching
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Response time&lt;/strong&gt;: ~800ms for cache miss (API screenshot), ~5ms for cache hit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt;: from 2GB+ (self-hosted Chromium) down to baseline Node.js (~150MB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt;: zero OOM crashes in 3 months vs. weekly restarts before&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: roughly $15/month for our volume vs. $80/month for the beefy container&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every link needs a screenshot. But for the ones that do, offloading the browser to an API saved us from a maintenance headache that kept growing.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>Handling Lazy-Loaded Content in Automated Screenshots</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Sun, 12 Jul 2026 09:24:23 +0000</pubDate>
      <link>https://dev.to/webmox/handling-lazy-loaded-content-in-automated-screenshots-2k2f</link>
      <guid>https://dev.to/webmox/handling-lazy-loaded-content-in-automated-screenshots-2k2f</guid>
      <description>&lt;p&gt;You set up Puppeteer, navigate to a page, call &lt;code&gt;page.screenshot()&lt;/code&gt;, and the bottom half of your image is blank placeholder boxes. Welcome to lazy loading.&lt;/p&gt;

&lt;p&gt;Most modern sites defer images and heavy content until the user scrolls. Your headless browser never scrolls. So those elements never load.&lt;/p&gt;

&lt;p&gt;Here's how to deal with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scroll trick
&lt;/h2&gt;

&lt;p&gt;The most common fix is to programmatically scroll down the page before taking the screenshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scrollToBottom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;scrollToBottom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 150ms delay between scrolls gives &lt;code&gt;IntersectionObserver&lt;/code&gt;-based lazy loaders time to trigger. Too fast and you'll scroll past elements before they start loading.&lt;/p&gt;

&lt;p&gt;That final &lt;code&gt;waitForTimeout&lt;/code&gt; after scrolling back to top lets any remaining images finish rendering. Not elegant, but necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why networkidle2 isn't enough
&lt;/h2&gt;

&lt;p&gt;You'd think &lt;code&gt;waitUntil: "networkidle2"&lt;/code&gt; would handle this. It waits until there are no more than 2 network connections for 500ms. But lazy-loaded images haven't even been &lt;em&gt;requested&lt;/em&gt; yet at that point — they're waiting for a scroll event that never happens.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;networkidle2&lt;/code&gt; only helps with content that loads on page init. For scroll-triggered content, you need the scroll.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loading="eager" override
&lt;/h2&gt;

&lt;p&gt;Some sites use the native &lt;code&gt;loading="lazy"&lt;/code&gt; attribute. You can override it before images load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateOnNewDocument&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HTMLImageElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eager&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eager&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;evaluateOnNewDocument&lt;/code&gt; runs before any page script, so it intercepts lazy loading before it kicks in. This won't work for custom JS-based lazy loaders (which check scroll position or use IntersectionObserver), but it handles the native HTML attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dealing with IntersectionObserver loaders
&lt;/h2&gt;

&lt;p&gt;Libraries like &lt;code&gt;lazysizes&lt;/code&gt;, &lt;code&gt;lozad&lt;/code&gt;, or custom implementations use &lt;code&gt;IntersectionObserver&lt;/code&gt;. The scroll trick usually works, but here's a more reliable approach — force all observed elements to intersect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Find all images with data-src (common lazy pattern)&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img[data-src]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Same for background images&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-bg]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backgroundImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`url(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brute force, but it works when you know the lazy loading pattern. The downside is it's brittle — every site structures their lazy loading differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infinite scroll pages
&lt;/h2&gt;

&lt;p&gt;Social feeds, product listings, search results — pages that load more content as you scroll. You usually don't want the &lt;em&gt;entire&lt;/em&gt; feed. Cap it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scrollWithLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxScrolls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previousHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;scrollCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxScrolls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentHeight&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;previousHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;previousHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;scrollCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 2-second wait is generous but safe. Some APIs take a while to respond, and if you scroll again before new content renders, you'll think you've hit the bottom when you haven't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When scrolling isn't practical
&lt;/h2&gt;

&lt;p&gt;Some pages are just hard to capture reliably. Heavy SPAs with virtual scrolling, pages that require authentication state, sites with aggressive bot detection that block headless browsers.&lt;/p&gt;

&lt;p&gt;At some point the complexity of handling every edge case exceeds the value of doing it yourself. I've used &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;ScreenshotRun&lt;/a&gt; for projects where I needed consistent captures without babysitting the rendering pipeline — it handles the lazy loading, wait conditions, and viewport stuff on their end.&lt;/p&gt;

&lt;p&gt;Not always necessary, but worth knowing the option exists when your homegrown setup starts eating more time than it saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;p&gt;Before you take a full-page screenshot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scroll the full page height with delays between steps&lt;/li&gt;
&lt;li&gt;Wait 1-2 seconds after scrolling for images to finish loading&lt;/li&gt;
&lt;li&gt;Override &lt;code&gt;loading="lazy"&lt;/code&gt; if the site uses native lazy loading&lt;/li&gt;
&lt;li&gt;Set a realistic viewport width (not the 800x600 default)&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;fullPage: true&lt;/code&gt; — otherwise you only capture the viewport area&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most screenshot issues come down to timing. The browser is ready before the content is. When in doubt, wait longer.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>A Developer Guide to Viewport Sizes for Screenshots</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Wed, 08 Jul 2026 10:35:46 +0000</pubDate>
      <link>https://dev.to/webmox/a-developer-guide-to-viewport-sizes-for-screenshots-4aek</link>
      <guid>https://dev.to/webmox/a-developer-guide-to-viewport-sizes-for-screenshots-4aek</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// desktop full HD&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// most common laptop&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;375&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;812&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;    &lt;span class="c1"&gt;// iPhone X/11/12&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's probably 80% of what you need. But if you're automating screenshots at scale — for visual regression, PDF generation, social previews — "probably" isn't good enough, and the edge cases will find you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The viewport problem nobody warns you about
&lt;/h2&gt;

&lt;p&gt;I wasted an entire afternoon once trying to figure out why a client's dashboard screenshots looked fine on my machine but came out wrong in CI. The page had a sidebar that collapsed at 1024px. My local browser was 1440px wide, the headless Chrome in CI defaulted to 800x600.&lt;/p&gt;

&lt;p&gt;800x600. In 2026.&lt;/p&gt;

&lt;p&gt;That's the default viewport for headless Chromium if you don't set one. Puppeteer uses it too unless you override it in &lt;code&gt;launch()&lt;/code&gt; or &lt;code&gt;page.setViewport()&lt;/code&gt;. Playwright defaults to 1280x720, which is slightly better but still catches people off guard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Puppeteer — you HAVE to set this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Playwright (Python) — set in browser context
&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which sizes actually matter
&lt;/h2&gt;

&lt;p&gt;Here's the thing — the "right" viewport depends on what you're screenshotting and why. There's no universal list.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;visual regression testing&lt;/strong&gt;, match your actual user base. Pull your analytics. If 40% of your traffic is 1366x768 laptop users and 35% is mobile, test those two. Maybe throw in 1920x1080 for good measure. Three viewports covers most projects.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;social preview images&lt;/strong&gt; (og:image, Twitter cards), you want exactly 1200x630. Not approximately. Exactly. Some tools crop from center, some from top-left, and if your viewport doesn't match the output ratio you'll get weird results.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;PDF generation from HTML&lt;/strong&gt;, it's different again. You're not matching a real screen — you're matching a paper size. A4 at 96dpi is roughly 794x1123 pixels. Letter is 816x1056.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PDF-oriented viewport&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;794&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1123&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Device emulation vs. viewport size
&lt;/h2&gt;

&lt;p&gt;Setting the viewport width to 375px doesn't make your page behave like an iPhone.&lt;/p&gt;

&lt;p&gt;Two things are missing: device pixel ratio and the user agent string. A real iPhone has a DPR of 3, so a 375px wide screen actually renders at 1125 physical pixels. If your page serves different images based on &lt;code&gt;srcset&lt;/code&gt; or uses &lt;code&gt;window.devicePixelRatio&lt;/code&gt; in JS, you'll get different results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;375&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUserAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Playwright has a nicer API for this — device descriptors built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;iphone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iPhone 13&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;iphone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That handles viewport, DPR, user agent, and touch events all at once. I wish Puppeteer had something this clean out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full-page vs. visible-area trap
&lt;/h2&gt;

&lt;p&gt;This trips people up constantly. When you set a viewport to 1366x768 and take a full-page screenshot, the width stays at 1366 but the height extends to cover the entire scrollable content. Your viewport height setting gets effectively ignored.&lt;/p&gt;

&lt;p&gt;That's usually fine. Unless your page has sticky elements, lazy-loaded images, or scroll-triggered animations.&lt;/p&gt;

&lt;p&gt;Sticky headers will repeat in every "fold" of a full-page screenshot in some tools. Lazy images below the fold won't load because the browser never actually scrolled. And scroll-triggered CSS animations will stay in their initial state.&lt;/p&gt;

&lt;p&gt;Fixes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Force-scroll to trigger lazy loading&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;distance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Then scroll back to top&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Now take the screenshot&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not elegant. Works though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Responsive breakpoints worth testing
&lt;/h2&gt;

&lt;p&gt;Skip the generic "test every device" advice. In practice, the breakpoints that catch bugs are the ones where your CSS layout shifts. Look at your own stylesheets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* These are YOUR breakpoints — test at these widths */&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1024px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* tablet layout kicks in */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;768px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* mobile nav appears */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;480px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* single column */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test at the breakpoint and one pixel above it. 1024 and 1025. 768 and 769. That's where the visual glitches hide — elements half-collapsed, overlapping text, buttons that are technically visible but pushed off to the side.&lt;/p&gt;

&lt;p&gt;I usually test at these widths for a typical marketing site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1920 — full desktop&lt;/li&gt;
&lt;li&gt;1366 — laptop (this is the one people forget)&lt;/li&gt;
&lt;li&gt;1024 — tablet landscape / small desktop&lt;/li&gt;
&lt;li&gt;768 — tablet portrait&lt;/li&gt;
&lt;li&gt;375 — mobile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five viewports. Covers the real-world spread without going overboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retina and HiDPI output
&lt;/h2&gt;

&lt;p&gt;If you're generating screenshots for documentation, marketing pages, or app stores, you probably want 2x output even from desktop viewports. Set &lt;code&gt;deviceScaleFactor: 2&lt;/code&gt; and your 1366x768 viewport produces a 2732x1536 image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1366&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;File sizes roughly quadruple. Keep that in mind if you're storing thousands of screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Width&lt;/th&gt;
&lt;th&gt;Height&lt;/th&gt;
&lt;th&gt;DPR&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Desktop screenshot&lt;/td&gt;
&lt;td&gt;1920&lt;/td&gt;
&lt;td&gt;1080&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Laptop&lt;/td&gt;
&lt;td&gt;1366&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tablet&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile (iPhone-ish)&lt;/td&gt;
&lt;td&gt;375&lt;/td&gt;
&lt;td&gt;812&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social preview (og:image)&lt;/td&gt;
&lt;td&gt;1200&lt;/td&gt;
&lt;td&gt;630&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PDF (A4)&lt;/td&gt;
&lt;td&gt;794&lt;/td&gt;
&lt;td&gt;1123&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retina desktop&lt;/td&gt;
&lt;td&gt;1440&lt;/td&gt;
&lt;td&gt;900&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't treat this table as gospel — check your own analytics and test the breakpoints that match your CSS. That's what actually catches bugs.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>screenshots</category>
      <category>css</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Automate OG Image Generation for Your Blog Using a Screenshot API</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Thu, 02 Jul 2026 09:48:48 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-automate-og-image-generation-for-your-blog-using-a-screenshot-api-3o30</link>
      <guid>https://dev.to/webmox/how-to-automate-og-image-generation-for-your-blog-using-a-screenshot-api-3o30</guid>
      <description>&lt;p&gt;Every blog post needs an OG image. Without one, your links look blank on Twitter, LinkedIn, and Slack — just a plain URL that nobody clicks. Most developers solve this by spinning up a headless browser, loading an HTML template, taking a screenshot, and uploading it somewhere. It works, but now you're maintaining a Puppeteer instance, dealing with font rendering quirks, and burning server resources on something that should be simple.&lt;/p&gt;

&lt;p&gt;There's a faster approach: design your OG images as HTML templates and let a screenshot API handle the rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea: HTML Templates as OG Images
&lt;/h2&gt;

&lt;p&gt;Think of your OG image as a tiny webpage. You already know HTML and CSS. Build a 1200×630 template with your blog title, author name, maybe a gradient background — whatever fits your brand. Host it or pass it as raw HTML. Then call an API to screenshot it. Done.&lt;/p&gt;

&lt;p&gt;A basic template might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width:1200px;height:630px;display:flex;align-items:center;
  justify-content:center;background:linear-gradient(135deg,#1a1a2e,#16213e);
  font-family:Inter,sans-serif;padding:60px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"color:#fff;text-align:center"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-size:48px;margin:0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{title}}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-size:24px;color:#8892b0;margin-top:20px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{author}} · {{date}}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the placeholders on your server, then send the resulting HTML (or a URL pointing to it) to the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling the API
&lt;/h2&gt;

&lt;p&gt;With &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;ScreenshotRun&lt;/a&gt;, a single curl request captures the rendered template as a PNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.screenshotrun.com/v1/screenshot"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "url": "https://yourblog.com/og-template?title=My+Post+Title",
    "viewport_width": 1200,
    "viewport_height": 630,
    "format": "png"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you the image file. Save it to your CDN, set the &lt;code&gt;og:image&lt;/code&gt; meta tag, and you're done. No browser to manage, no Chrome binary eating RAM on your CI server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into Your Build
&lt;/h2&gt;

&lt;p&gt;If you publish with a static site generator or a CMS, hook this into your build step or post-publish webhook. For each new post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate the template URL with the post's title and metadata.&lt;/li&gt;
&lt;li&gt;Call the screenshot API.&lt;/li&gt;
&lt;li&gt;Upload the returned image to S3 or your storage of choice.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;&amp;lt;meta property="og:image"&amp;gt;&lt;/code&gt; in the page head.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole pipeline runs in a few seconds per post. No headless browser dependencies, no Docker containers for rendering. Your CI stays clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use Puppeteer Directly?
&lt;/h2&gt;

&lt;p&gt;You can. But managing Chromium in production is annoying. Font loading breaks between environments. Lambda has size limits. Cold starts add latency. A screenshot API offloads all of that — you send a request, you get an image. Someone else worries about the browser.&lt;/p&gt;

&lt;p&gt;If you want to see more patterns like this, ScreenshotRun has an &lt;a href="https://screenshotrun.com/use-cases/og-image-generator" rel="noopener noreferrer"&gt;OG image generator&lt;/a&gt; use-case page with additional examples and template ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep your template at 1200×630. That's the standard OG image size.&lt;/li&gt;
&lt;li&gt;Use web-safe fonts or Google Fonts loaded via &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; in your template.&lt;/li&gt;
&lt;li&gt;Cache aggressively. OG images rarely change after publish.&lt;/li&gt;
&lt;li&gt;Test with the Twitter Card Validator and Facebook's Sharing Debugger before going live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole setup. HTML template, one API call, and your blog posts stop looking naked when someone shares them.&lt;/p&gt;

</description>
      <category>screenshot</category>
      <category>webdev</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to take screenshots of password-protected pages with a screenshot API</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Fri, 10 Apr 2026 13:07:05 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-take-screenshots-of-password-protected-pages-with-a-screenshot-api-pmc</link>
      <guid>https://dev.to/webmox/how-to-take-screenshots-of-password-protected-pages-with-a-screenshot-api-pmc</guid>
      <description>&lt;p&gt;Not every page you need to screenshot is open to the world. Sometimes you need a screenshot of a service admin panel, an internal dashboard, a staging server, or a page behind basic auth. And that's where the problem starts: you send the URL to a screenshot API, and what comes back is a screenshot of the login form. The headless browser on the API side doesn't know your credentials and visits the page as a brand new user.&lt;/p&gt;
&lt;p&gt;I want to walk through how to handle this. As an example, I'll use my own project fixheaders.com, a security headers scanner. It has a Filament admin panel, and I periodically need a screenshot of the dashboard showing scan counts without logging in manually every time.&lt;/p&gt;
&lt;p&gt;I'll cover three authentication methods: cookies, custom HTTP headers, and Basic Auth. Each method comes with code examples in cURL, Node.js, and PHP.&lt;/p&gt;
&lt;h2&gt;Why a regular screenshot request won't work&lt;/h2&gt;
&lt;p&gt;When you ask a screenshot API (or &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/screenshot-api-vs-puppeteerplaywright-when-to-build-and-when-to-buy"&gt;Playwright, or Puppeteer&lt;/a&gt;) to capture a URL, the headless browser opens it as a completely new visitor. No session, no cookies, no saved credentials. If the page requires a login, the server sees an unauthenticated request and redirects to the login form. Or just shows it directly.&lt;/p&gt;
&lt;p&gt;Here's what happens when you send a regular request to the fixheaders.com admin URL without any cookies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://screenshotrun.com/api/v1/screenshots \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://fixheaders.com/admin"}'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FLtbgkhmeYPuYCfLLT6OFrELpcIXQmSkJ0Ycfojm0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FLtbgkhmeYPuYCfLLT6OFrELpcIXQmSkJ0Ycfojm0.png" alt="API response with id and status: pending" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;We wait a few seconds and download the result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  https://screenshotrun.com/api/v1/screenshots/SCREENSHOT_ID/image \
  -o screenshot-no-auth.png&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FlG11n6hGLgE5cRBYJomTqSW14rOBXZA3oqr5GjBo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FlG11n6hGLgE5cRBYJomTqSW14rOBXZA3oqr5GjBo.png" alt="terminal download command" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Open the file and you see this:&lt;br&gt;&lt;br&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FZKAupWI3096qJgdh17oc0CgZdJ0xTeRo4XsBLZRW.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FZKAupWI3096qJgdh17oc0CgZdJ0xTeRo4XsBLZRW.png" alt="FixHeaders login page" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The login form. This is exactly what the headless browser sees when it visits a protected URL without authentication. It's the same thing that happens when you open a private URL in an incognito window. The browser has no cookies, so the server treats you as a stranger.&lt;/p&gt;
&lt;p&gt;The fix is to give the headless browser the same credentials your regular browser already has. There are a few ways to do that, depending on how the target site handles authentication.&lt;/p&gt;
&lt;h2&gt;Method 1: pass session cookies (the most common case)&lt;/h2&gt;
&lt;p&gt;Most web apps (Laravel, Django, Rails, WordPress, Filament, any admin panel) use cookie-based sessions. When you log in, the server creates a session and sends a cookie. Every subsequent request includes that cookie, and the server knows who you are.&lt;/p&gt;
&lt;p&gt;The idea is simple: grab the session cookie from your browser and pass it to the screenshot API. The headless browser will send that cookie with the request, the server will see a valid session, and you'll get a screenshot of the actual dashboard, not the login page.&lt;/p&gt;
&lt;h3&gt;Step 1: find your session cookie&lt;/h3&gt;
&lt;p&gt;Open the site you want to screenshot (in my case, the fixheaders.com admin panel). Log in normally. Then open DevTools → Application → Cookies (in Chrome) or Storage → Cookies (in Firefox).&lt;br&gt;&lt;br&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FRp3bjRBPuMISk3OFcdYJLbWtVEgOnsbHEipy7xT3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FRp3bjRBPuMISk3OFcdYJLbWtVEgOnsbHEipy7xT3.png" alt="DevTools cookies tab" width="800" height="719"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Look for the session cookie. In Laravel apps it's usually called &lt;code&gt;laravel_session&lt;/code&gt; or a custom name from &lt;code&gt;config/session.php&lt;/code&gt;. In my case it's &lt;code&gt;fixheaders-session&lt;/code&gt;. The name varies across frameworks. In some apps it's &lt;code&gt;PHPSESSID&lt;/code&gt;, in Django it's &lt;code&gt;sessionid&lt;/code&gt;, in Rails it's &lt;code&gt;_yourapp_session&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Copy the cookie value. It'll look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;eyJpdiI6IkxMNk1DVjZhN0FKWjZ2a3...long_base64_string&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: pass it to the screenshot API&lt;/h3&gt;
&lt;p&gt;Here's how to send that cookie with a screenshotrun API request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cURL:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://screenshotrun.com/api/v1/screenshots \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://fixheaders.com/admin",
    "cookies": [
      {
        "name": "fixheaders-session",
        "value": "eyJpdiI6IkxMNk1DVjZhN0FKWjZ2a3...",
        "domain": "fixheaders.com"
      }
    ]
  }'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FMMt32B3QDT6jzaWYl2uoLMh9IjNy29yIhpEb4a80.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FMMt32B3QDT6jzaWYl2uoLMh9IjNy29yIhpEb4a80.png" alt="terminal with cookie JSON" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;cookies&lt;/code&gt; parameter takes an array of objects. Each one needs a &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt;, and &lt;code&gt;domain&lt;/code&gt;. The domain tells the browser which site to send the cookie to. Without it, the cookie might not get attached to the request.&lt;/p&gt;
&lt;p&gt;Download the result the same way, using the ID from the response:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  https://screenshotrun.com/api/v1/screenshots/SCREENSHOT_ID/image \
  -o screenshot-with-auth.png&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here's what we get this time:&lt;br&gt;&lt;br&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FJKRYtPs6g9lrZ6bqQvJ5ep08w6NJHNpGWopdL9mo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fscreenshotrun.com%2Fstorage%2Fblog-attachments%2FJKRYtPs6g9lrZ6bqQvJ5ep08w6NJHNpGWopdL9mo.png" alt="FixHeaders dashboard with stats" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The actual dashboard. Scan counts, average score, recent checks. Exactly what I see when I'm logged in through the browser. One cookie made all the difference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Node.js:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const response = await fetch("https://screenshotrun.com/api/v1/screenshots", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://fixheaders.com/admin",
    cookies: [
      {
        name: "fixheaders-session",
        value: "eyJpdiI6IkxMNk1DVjZhN0FKWjZ2a3...",
        domain: "fixheaders.com",
      },
    ],
  }),
});

const { data } = await response.json();
console.log(data.id);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;PHP (Laravel):&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you're working with PHP and haven't set up the screenshot API yet, I covered the full setup process in &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/take-website-screenshot-php"&gt;the PHP screenshot tutorial&lt;/a&gt;. Here's the short version for authenticated pages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$response = Http::withToken(env('SCREENSHOTRUN_API_KEY'))
    -&amp;gt;post('https://screenshotrun.com/api/v1/screenshots', [
        'url' =&amp;gt; 'https://fixheaders.com/admin',
        'cookies' =&amp;gt; [
            [
                'name' =&amp;gt; 'fixheaders-session',
                'value' =&amp;gt; 'eyJpdiI6IkxMNk1DVjZhN0FKWjZ2a3...',
                'domain' =&amp;gt; 'fixheaders.com',
            ],
        ],
    ]);

$screenshotId = $response-&amp;gt;json('data.id');&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the request completes, poll the screenshot status or use a &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/docs/webhooks"&gt;webhook&lt;/a&gt; to get notified when it's ready.&lt;/p&gt;
&lt;h3&gt;What can go wrong with cookies&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Session expiration.&lt;/strong&gt; This is the biggest one. Session cookies have a lifetime. In Laravel the default is 2 hours. After that, the session is invalid and the screenshot API will get the login page again. If you're automating this, you need to refresh the cookie periodically. One option is to write a script that logs in via HTTP (POST to the login endpoint with credentials), grabs the fresh session cookie from the &lt;code&gt;Set-Cookie&lt;/code&gt; response header, and uses that for the screenshot request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multiple cookies.&lt;/strong&gt; Some apps require more than one cookie. CSRF tokens, "remember me" cookies, or multi-cookie session setups. If the screenshot comes back as a login page even though you're sending the session cookie, check whether the site sets additional cookies during login. You can pass up to 20 cookies in a single screenshotrun request, which covers pretty much any setup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;HttpOnly and Secure flags.&lt;/strong&gt; Session cookies are often marked as &lt;code&gt;HttpOnly&lt;/code&gt; (can't be read by JavaScript) and &lt;code&gt;Secure&lt;/code&gt; (only sent over HTTPS). These flags don't affect the screenshot API. The headless browser handles them the same way a regular browser does. Just make sure you're using HTTPS URLs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SameSite cookies.&lt;/strong&gt; If the cookie has &lt;code&gt;SameSite=Strict&lt;/code&gt;, it might not get sent in certain cross-origin scenarios. With a screenshot API, the headless browser navigates directly to the URL (it's a top-level navigation, not a cross-origin fetch), so &lt;code&gt;SameSite=Strict&lt;/code&gt; cookies are included normally.&lt;/p&gt;
&lt;h2&gt;Method 2: custom HTTP headers (API tokens and Bearer auth)&lt;/h2&gt;
&lt;p&gt;Not every protected page uses cookies. Internal tools, API documentation portals, and headless CMS interfaces sometimes protect pages with custom HTTP headers: an API token, a Bearer token, or a custom &lt;code&gt;X-Auth-Token&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;If the page you need to screenshot checks for a specific header instead of (or in addition to) cookies, you can pass custom headers through the screenshot API.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cURL:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://screenshotrun.com/api/v1/screenshots \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://internal-tool.example.com/dashboard",
    "headers": {
      "X-Auth-Token": "your-internal-token-here"
    }
  }'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Node.js:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const response = await fetch("https://screenshotrun.com/api/v1/screenshots", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://internal-tool.example.com/dashboard",
    headers: {
      "X-Auth-Token": "your-internal-token-here",
    },
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;PHP:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$response = Http::withToken(env('SCREENSHOTRUN_API_KEY'))
    -&amp;gt;post('https://screenshotrun.com/api/v1/screenshots', [
        'url' =&amp;gt; 'https://internal-tool.example.com/dashboard',
        'headers' =&amp;gt; [
            'X-Auth-Token' =&amp;gt; 'your-internal-token-here',
        ],
    ]);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;headers&lt;/code&gt; parameter accepts an object where keys are header names and values are header values. You can send up to 20 custom headers per request.&lt;/p&gt;
&lt;p&gt;One thing to watch out for: the &lt;code&gt;Authorization&lt;/code&gt; header in the &lt;code&gt;headers&lt;/code&gt; parameter is the one sent to the &lt;strong&gt;target page&lt;/strong&gt;, not to the screenshotrun API itself. The API key for authenticating with screenshotrun goes in the top-level &lt;code&gt;Authorization&lt;/code&gt; header of the HTTP request. The &lt;code&gt;headers&lt;/code&gt; object is what the headless browser sends when loading the target URL. These are two different things, and it's easy to mix them up.&lt;/p&gt;
&lt;h2&gt;Method 3: Basic Auth for staging and htpasswd-protected sites&lt;/h2&gt;
&lt;p&gt;If you've ever password-protected a staging server with Nginx's &lt;code&gt;auth_basic&lt;/code&gt; or Apache's &lt;code&gt;.htpasswd&lt;/code&gt;, you know how it works. The browser shows a modal dialog asking for a username and password. Headless browsers can't interact with that dialog, so the request just fails or returns a 401.&lt;/p&gt;
&lt;p&gt;The fix is simple: send the credentials as a Basic Auth header. The browser won't see the dialog at all because the server gets the credentials before it has a chance to ask for them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;cURL:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://screenshotrun.com/api/v1/screenshots \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://staging.example.com",
    "headers": {
      "Authorization": "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
    }
  }'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Basic dXNlcm5hbWU6cGFzc3dvcmQ=&lt;/code&gt; part is just &lt;code&gt;username:password&lt;/code&gt; encoded in Base64. You can generate it from the terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo -n "username:password" | base64
# Output: dXNlcm5hbWU6cGFzc3dvcmQ=&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Node.js:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const credentials = Buffer.from("username:password").toString("base64");

const response = await fetch("https://screenshotrun.com/api/v1/screenshots", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://staging.example.com",
    headers: {
      "Authorization": `Basic ${credentials}`,
    },
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;PHP:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$credentials = base64_encode('username:password');

$response = Http::withToken(env('SCREENSHOTRUN_API_KEY'))
    -&amp;gt;post('https://screenshotrun.com/api/v1/screenshots', [
        'url' =&amp;gt; 'https://staging.example.com',
        'headers' =&amp;gt; [
            'Authorization' =&amp;gt; 'Basic ' . $credentials,
        ],
    ]);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This method works for any site that uses HTTP Basic Authentication. It's common on staging environments, dev servers, and some internal tools. If you're monitoring your staging after deployments, I wrote a separate article about &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/when-a-screenshot-tells-you-what-a-log-cant-5-situations-that-matter"&gt;why screenshots catch things logs miss&lt;/a&gt; that covers the reasoning behind this.&lt;/p&gt;
&lt;h2&gt;Combining cookies with other capture options&lt;/h2&gt;
&lt;p&gt;All three authentication methods work alongside the regular screenshot parameters. You can take a full-page screenshot of an authenticated dashboard in dark mode, at a mobile viewport, with cookie banners blocked. All in one request:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -X POST https://screenshotrun.com/api/v1/screenshots \
  -H "Authorization: Bearer $SCREENSHOTRUN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://fixheaders.com/admin",
    "cookies": [
      {
        "name": "fixheaders-session",
        "value": "your-session-cookie-value",
        "domain": "fixheaders.com"
      }
    ],
    "full_page": true,
    "dark_mode": true,
    "device": "mobile",
    "format": "webp"
  }'&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I covered the full list of parameters (dark mode, device presets, &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/screenshot-specific-element-css-selector"&gt;CSS injection and element selectors&lt;/a&gt;) in the &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/docs/screenshots"&gt;API documentation&lt;/a&gt;. If you haven't looked at the other capture options yet, the &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/how-to-take-website-screenshots-with-curl"&gt;cURL examples article&lt;/a&gt; walks through each one with copy-paste commands.&lt;/p&gt;
&lt;h2&gt;Automating the login (when cookies expire too fast)&lt;/h2&gt;
&lt;p&gt;If you want to capture authenticated screenshots on a schedule (say, every hour for a dashboard you embed somewhere), manually copying cookies won't cut it. Sessions expire, and your screenshot will revert to a login page.&lt;/p&gt;
&lt;p&gt;The workaround is to automate the login step itself. Instead of grabbing cookies from a browser by hand, write a script that logs in programmatically, extracts the session cookie, and passes it to the screenshot API.&lt;/p&gt;
&lt;p&gt;Here's a Node.js example using plain &lt;code&gt;fetch&lt;/code&gt; (if you need more context on the Node.js setup, check out &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/how-to-take-a-website-screenshot-with-nodejs"&gt;the full Node.js tutorial&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function getSessionCookie(loginUrl, email, password) {
  const response = await fetch(loginUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
    redirect: "manual",
  });

  const setCookie = response.headers.get("set-cookie");
  if (!setCookie) {
    throw new Error("No Set-Cookie header in login response");
  }

  const match = setCookie.match(/(\w+[-_]session)=([^;]+)/);
  if (!match) {
    throw new Error("Could not find session cookie in Set-Cookie header");
  }

  return { name: match[1], value: match[2] };
}

// Usage
const cookie = await getSessionCookie(
  "https://fixheaders.com/login",
  "your@email.com",
  "your-password"
);

const response = await fetch("https://screenshotrun.com/api/v1/screenshots", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://fixheaders.com/admin",
    cookies: [
      {
        name: cookie.name,
        value: cookie.value,
        domain: "fixheaders.com",
      },
    ],
  }),
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The login endpoint depends on your app. Laravel's default route is &lt;code&gt;POST /login&lt;/code&gt; with &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; fields (plus a CSRF token, more on that below). Django uses &lt;code&gt;POST /accounts/login/&lt;/code&gt;. WordPress uses &lt;code&gt;POST /wp-login.php&lt;/code&gt; with &lt;code&gt;log&lt;/code&gt; and &lt;code&gt;pwd&lt;/code&gt; fields.&lt;/p&gt;
&lt;h3&gt;The CSRF token problem&lt;/h3&gt;
&lt;p&gt;Most frameworks protect login forms with CSRF tokens. If you just POST to &lt;code&gt;/login&lt;/code&gt; without a valid token, you'll get a 419 (Laravel) or 403 (Django/Rails). You need to first GET the login page, extract the CSRF token from the HTML or cookies, then include it in the POST request.&lt;/p&gt;
&lt;p&gt;In Laravel, the CSRF token lives in a cookie called &lt;code&gt;XSRF-TOKEN&lt;/code&gt; (URL-encoded) and needs to be sent back as an &lt;code&gt;X-XSRF-TOKEN&lt;/code&gt; header or as a &lt;code&gt;_token&lt;/code&gt; form field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function laravelLogin(baseUrl, email, password) {
  // Step 1: GET the login page to grab the CSRF cookie
  const loginPage = await fetch(`${baseUrl}/login`);
  const csrfCookie = loginPage.headers
    .get("set-cookie")
    ?.match(/XSRF-TOKEN=([^;]+)/)?.[1];

  if (!csrfCookie) {
    throw new Error("Could not extract CSRF token");
  }

  const csrfToken = decodeURIComponent(csrfCookie);

  // Step 2: POST login credentials with the CSRF token
  const response = await fetch(`${baseUrl}/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-XSRF-TOKEN": csrfToken,
      Cookie: `XSRF-TOKEN=${csrfCookie}`,
    },
    body: JSON.stringify({ email, password }),
    redirect: "manual",
  });

  // Step 3: extract the session cookie from the response
  const sessionCookie = response.headers
    .get("set-cookie")
    ?.match(/(\w+[-_]session)=([^;]+)/);

  if (!sessionCookie) {
    throw new Error("Login failed — no session cookie returned");
  }

  return { name: sessionCookie[1], value: sessionCookie[2] };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is more involved than just copying a cookie from DevTools. But it runs unattended, and the session is always fresh. I use a similar approach inside screenshotrun's own renderer when I need to test authenticated pages during development.&lt;/p&gt;
&lt;h2&gt;When to use which method&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Cookies&lt;/strong&gt; — for any standard web app with form-based login. This is the most common case: admin panels, dashboards, CMS backends, SaaS tools. If you log in through a form in your browser and the site remembers you via a cookie, this is your method.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Custom headers&lt;/strong&gt; — for internal tools and API-protected pages. If the page checks for an &lt;code&gt;X-Auth-Token&lt;/code&gt;, a Bearer token, or any non-standard header, use the &lt;code&gt;headers&lt;/code&gt; parameter. This also works for API documentation portals that require authentication headers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Basic Auth&lt;/strong&gt; — for staging environments and htpasswd-protected sites. If the browser shows a native username/password dialog (not an HTML form), the site uses HTTP Basic Authentication. Pass the credentials as a Base64-encoded &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;Some setups combine multiple methods. A staging server might have Basic Auth at the Nginx level and cookie-based sessions at the application level. In that case, pass both — the &lt;code&gt;headers&lt;/code&gt; parameter with the Basic Auth header and the &lt;code&gt;cookies&lt;/code&gt; parameter with the session cookie. The screenshotrun API sends everything together with the request.&lt;/p&gt;
&lt;h2&gt;Security notes&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Don't hardcode credentials.&lt;/strong&gt; Use environment variables or a secrets manager. If you're storing session cookies or passwords in your codebase, you're one accidental commit away from leaking them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use dedicated accounts.&lt;/strong&gt; If you're automating dashboard screenshots, create a read-only user account specifically for this purpose. Don't use your admin credentials in scripts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Be careful with screenshot storage.&lt;/strong&gt; Authenticated dashboards might contain sensitive data: revenue numbers, user emails, internal metrics. If you're using screenshot &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/blog/how-to-cache-screenshots"&gt;caching&lt;/a&gt; or storing images long-term, think about who has access to those files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Session cookies are temporary credentials.&lt;/strong&gt; Treat them like passwords. Don't log them, don't put them in URLs, don't send them over unencrypted connections.&lt;/p&gt;
&lt;h2&gt;What I ended up doing with fixheaders&lt;/h2&gt;
&lt;p&gt;Back to the original problem. I needed to periodically check fixheaders.com scan stats without logging in manually every time.&lt;/p&gt;
&lt;p&gt;Here's what I built: a scheduled task in Laravel (&lt;code&gt;schedule:run&lt;/code&gt;) that fires every hour. It logs into fixheaders using the automated approach above, takes a screenshot of the dashboard, and saves it. I can open the latest screenshot any time and see the numbers without any extra steps.&lt;/p&gt;
&lt;p&gt;Could I have built a proper API integration instead? Sure. But that would have taken way longer than the 20 minutes this approach took. Sometimes a screenshot is the fastest way to pull data out of a system that doesn't have an API for what you need.&lt;/p&gt;
&lt;p&gt;If you want to try screenshotrun for something like this, the &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/register"&gt;free plan&lt;/a&gt; gives you 200 screenshots per month, more than enough for hourly dashboard captures. The &lt;code&gt;cookies&lt;/code&gt; and &lt;code&gt;headers&lt;/code&gt; parameters are available on the &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/pricing"&gt;Pro plan&lt;/a&gt; and above.&lt;/p&gt;
&lt;p&gt;I hope this saves you some time next time you need to screenshot a page behind a login. If you hit edge cases with specific frameworks (NextAuth, Passport, Sanctum — they all have their quirks), feel free to &lt;a rel="noopener noreferrer nofollow" href="https://screenshotrun.com/contact-us"&gt;reach out&lt;/a&gt; and I'll try to help.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>node</category>
      <category>playwright</category>
    </item>
  </channel>
</rss>
