<?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>I replaced Selenium screenshots with a 3-line API call</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Fri, 16 Oct 2026 14:01:41 +0000</pubDate>
      <link>https://dev.to/webmox/i-replaced-selenium-screenshots-with-a-3-line-api-call-53fm</link>
      <guid>https://dev.to/webmox/i-replaced-selenium-screenshots-with-a-3-line-api-call-53fm</guid>
      <description>&lt;p&gt;Had a Selenium setup for taking screenshots in our CI pipeline. It worked, but maintaining it was a whole thing.&lt;/p&gt;
&lt;p&gt;The Docker image was 800MB because it bundled Chromium. Build times were 4 minutes just for the screenshot step. And every few weeks something broke -- ChromeDriver version mismatch, a new Chrome flag that changed default behavior, font rendering differences between CI runners.&lt;/p&gt;
&lt;p&gt;Spent more time babysitting the screenshot infrastructure than actually using the screenshots.&lt;/p&gt;
&lt;h2&gt;The switch&lt;/h2&gt;
&lt;p&gt;Ripped out the entire Selenium setup and replaced it with an HTTP call:&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;resp&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?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;targetUrl&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;width=1440&amp;amp;format=png`&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="s2"&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;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="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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;screenshot.png&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;from&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thats it. No browser binary, no driver compatibility matrix, no 800MB Docker layer.&lt;/p&gt;
&lt;h2&gt;What changed&lt;/h2&gt;
&lt;p&gt;Build times dropped from 4 minutes to about 12 seconds for the screenshot step. The Docker image went from 800MB to 120MB. Zero maintenance on the rendering side -- thats someone elses problem now.&lt;/p&gt;
&lt;p&gt;The screenshots are also more consistent. Same rendering environment every time, regardless of which CI runner picks up the job. No more "works on runner A, looks different on runner B" debugging sessions.&lt;/p&gt;
&lt;h2&gt;Tradeoffs&lt;/h2&gt;
&lt;p&gt;Costs money, obviously. For our volume (maybe 200 captures per day across staging and PR previews) its a few bucks a month. Way less than the engineer-hours I was spending on Selenium maintenance.&lt;/p&gt;
&lt;p&gt;You also depend on an external service. If their API is down during your CI run, your build fails. Added a simple retry with 3 attempts and a 5-second backoff. Hasnt been an issue in practice but its worth knowing about.&lt;/p&gt;
&lt;p&gt;If youre doing fewer than 50 screenshots a day and your Selenium setup is stable, probably not worth switching. But if youre fighting Chrome version mismatches every sprint like I was -- yeah, just use an API.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>I Caught a Production CSS Bug Before Users Did</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:20:34 +0000</pubDate>
      <link>https://dev.to/webmox/i-caught-a-production-css-bug-before-users-did-3lb4</link>
      <guid>https://dev.to/webmox/i-caught-a-production-css-bug-before-users-did-3lb4</guid>
      <description>


&lt;h2&gt;title: I Caught a Production CSS Bug Before Users Did — Here's the Visual Regression Setup
published: true
tags: testing, webdev, css, devops&lt;/h2&gt;

&lt;p&gt;We shipped a "minor" font-size change last month. Updated the base size from 16px to 15px in our design tokens. Seemed harmless — the designer wanted tighter spacing.&lt;/p&gt;

&lt;p&gt;It broke the checkout flow.&lt;/p&gt;

&lt;p&gt;The payment form had a hardcoded min-height based on content size. With the smaller font, the content shifted up, and the submit button overlapped the terms checkbox on mobile viewports. Users on iPhone SE literally couldn't complete a purchase.&lt;/p&gt;

&lt;p&gt;We didn't catch it in code review because the CSS diff looked fine. We didn't catch it in unit tests because we don't render real layouts in Jest. We caught it 4 hours after deploy when support tickets started coming in.&lt;/p&gt;

&lt;h2&gt;What visual regression testing actually looks like&lt;/h2&gt;

&lt;p&gt;The concept: screenshot key pages before deploy, screenshot them after, diff the images. If pixels changed where they shouldn't have, block the deploy.&lt;/p&gt;

&lt;p&gt;In practice it's messier than that.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;# pseudo pipeline
1. Take "baseline" screenshots of production
2. Deploy to staging
3. Take "candidate" screenshots of staging
4. Compare each pair, flag differences above threshold
5. Human reviews flagged diffs
6. Approve or reject deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Step 4 is where most tools fall apart. Pixel-perfect comparison produces way too many false positives — browser antialiasing, font rendering differences, dynamic content like timestamps, ads, user avatars.&lt;/p&gt;

&lt;h2&gt;What I actually use&lt;/h2&gt;

&lt;p&gt;After trying a few setups (BackstopJS, Percy, custom Puppeteer scripts), I landed on a simpler approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Monitor key pages periodically with &lt;a href="https://snapshotarchive.com" rel="noopener noreferrer"&gt;SnapshotArchive&lt;/a&gt; — it takes scheduled screenshots and stores the history&lt;/li&gt;
&lt;li&gt;When we deploy, I compare the latest pre-deploy screenshot against the staging version&lt;/li&gt;
&lt;li&gt;Significant visual changes get flagged in Slack&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The advantage over CI-integrated tools: I get visual monitoring even when there's NO deploy. So if a third-party script changes, or a CDN serves different assets, or a CMS editor modifies content — I still see it.&lt;/p&gt;

&lt;h2&gt;The false positive problem&lt;/h2&gt;

&lt;p&gt;Every visual testing tool struggles with this. Here's what reduced noise for me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mask dynamic regions&lt;/strong&gt; — hide elements that change on every load (timestamps, personalized content, ads). Most tools support CSS selectors for this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Perceptual diffing over pixel diffing&lt;/strong&gt; — a 1px antialiasing difference isn't a real change. Tools that use perceptual comparison (SSIM or similar) produce way fewer false alarms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate critical from cosmetic&lt;/strong&gt; — the checkout page gets a 0.1% threshold. The blog page gets 5%. Not everything needs the same sensitivity.&lt;/p&gt;

&lt;h2&gt;My current setup in numbers&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;12 pages monitored&lt;/strong&gt; (homepage, pricing, checkout flow, key landing pages)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots every 6 hours&lt;/strong&gt; outside of deploys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~2 real alerts per week&lt;/strong&gt; after tuning thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 CSS incidents reaching production&lt;/strong&gt; since setting this up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checkout font-size bug would've shown up as a visual diff on staging before we merged. Instead it cost us 4 hours of lost conversions and a hotfix.&lt;/p&gt;

&lt;p&gt;The irony: the monitoring setup took an afternoon. The incident cost way more.&lt;/p&gt;

&lt;p&gt;What's your visual testing setup look like? Or are you still yolo-deploying CSS changes like we used to?&lt;/p&gt;

</description>
      <category>websitemonitoring</category>
      <category>screenshot</category>
      <category>archive</category>
      <category>changedetection</category>
    </item>
    <item>
      <title>I Built a Screenshot Pipeline That Handles 10K URLs Daily</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:20:24 +0000</pubDate>
      <link>https://dev.to/webmox/i-built-a-screenshot-pipeline-that-handles-10k-urls-daily-5cbd</link>
      <guid>https://dev.to/webmox/i-built-a-screenshot-pipeline-that-handles-10k-urls-daily-5cbd</guid>
      <description>


&lt;h2&gt;title: I Built a Screenshot Pipeline That Handles 10K URLs Daily (Here's What Broke First)
published: true
tags: webdev, api, automation, javascript&lt;/h2&gt;

&lt;p&gt;Last quarter I needed to generate preview thumbnails for a link aggregation tool. The requirement seemed simple: given a URL, produce a 1200x630 screenshot. At first it was maybe 50 URLs a day. Then the product grew.&lt;/p&gt;

&lt;p&gt;At around 2,000 URLs per day things started falling apart.&lt;/p&gt;

&lt;h2&gt;The first bottleneck: memory&lt;/h2&gt;

&lt;p&gt;Each Puppeteer page instance uses somewhere between 50-150MB of RAM depending on the page complexity. Running 10 concurrent pages meant 1.5GB just for Chrome, plus Node overhead. Our 4GB server started swapping.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// the naive approach that doesn't scale
const pages = urls.map(async (url) =&amp;gt; {
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'networkidle0' });
  await page.screenshot({ path: `${hash(url)}.png` });
  await page.close();
});
await Promise.all(pages); // boom, 10K pages at once
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The fix was a simple semaphore — limit to 4 concurrent pages and queue the rest. But even with that, Chrome would occasionally leak memory and need a restart every ~500 screenshots.&lt;/p&gt;

&lt;h2&gt;The second bottleneck: timeouts&lt;/h2&gt;

&lt;p&gt;Some URLs just don't load. Or they load but never reach &lt;code&gt;networkidle0&lt;/code&gt; because of persistent WebSocket connections or infinite polling. I was losing 15-20% of my daily runs to timeout-related failures.&lt;/p&gt;

&lt;p&gt;What worked:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;await page.goto(url, {
  waitUntil: 'domcontentloaded', // not networkidle0
  timeout: 15000
});
// then wait for specific readiness signal
await page.waitForFunction(
  () =&amp;gt; document.readyState === 'complete',
  { timeout: 5000 }
).catch(() =&amp;gt; {}); // screenshot whatever we have
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Switching from &lt;code&gt;networkidle0&lt;/code&gt; to &lt;code&gt;domcontentloaded&lt;/code&gt; + a short secondary wait cut my failure rate from 18% to under 3%.&lt;/p&gt;

&lt;h2&gt;The third bottleneck: me&lt;/h2&gt;

&lt;p&gt;At some point I was spending more time maintaining the screenshot infrastructure than building the actual product. Chrome updates breaking things, memory leak debugging, managing the screenshot queue, handling retries, storage rotation...&lt;/p&gt;

&lt;p&gt;I switched the whole thing to &lt;a href="https://screenshotrun.com" rel="noopener noreferrer"&gt;ScreenshotRun&lt;/a&gt; — send a URL via API, get back an image. No browser management, no server scaling, no 3am Chrome crashes.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;// entire screenshot pipeline, after
const response = await fetch('https://api.screenshotrun.com/capture', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${API_KEY}` },
  body: JSON.stringify({ url, width: 1200, height: 630 })
});
const image = await response.buffer();
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;My screenshot server went from a dedicated 8GB instance to zero infrastructure. The API handles the browser pool, the timeouts, the retries, the scaling.&lt;/p&gt;

&lt;h2&gt;What I'd do differently&lt;/h2&gt;

&lt;p&gt;If I was starting over and knew I'd hit scale:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't self-host screenshots past ~500/day&lt;/strong&gt; — the operational overhead isn't worth it unless you need very specific browser customization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always use &lt;code&gt;domcontentloaded&lt;/code&gt; over &lt;code&gt;networkidle0&lt;/code&gt;&lt;/strong&gt; — the few pages that need network idle aren't worth the timeout penalty on everything else&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store WebP, not PNG&lt;/strong&gt; — 60-70% smaller files, virtually identical quality for thumbnails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the retry logic from day one&lt;/strong&gt; — transient failures are guaranteed at scale, not edge cases&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Has anyone else hit similar scaling issues with browser-based tools? Curious what concurrency limits others settled on.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Capture Full-Page Screenshots of Infinite Scroll Pages</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Wed, 23 Sep 2026 09:04:07 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-capture-full-page-screenshots-of-infinite-scroll-pages-33gk</link>
      <guid>https://dev.to/webmox/how-to-capture-full-page-screenshots-of-infinite-scroll-pages-33gk</guid>
      <description>


&lt;h2&gt;title: How to Capture Full-Page Screenshots of Infinite Scroll Pages
tags: webdev, javascript, puppeteer, testing&lt;/h2&gt;

&lt;p&gt;Infinite scroll pages don't have a "bottom." That makes full-page screenshots tricky — the browser doesn't know how tall the page actually is until you've scrolled through everything.&lt;/p&gt;

&lt;p&gt;Here's how I handle it, and where the common approaches break.&lt;/p&gt;

&lt;h2&gt;The Naive Approach (and Why It Fails)&lt;/h2&gt;

&lt;p&gt;Puppeteer's &lt;code&gt;fullPage: true&lt;/code&gt; option works by measuring &lt;code&gt;document.body.scrollHeight&lt;/code&gt; and setting the viewport to that height before capturing. On a normal page, this is fine.&lt;/p&gt;

&lt;p&gt;On an infinite scroll page, &lt;code&gt;scrollHeight&lt;/code&gt; only reflects content that's already loaded. If the page lazy-loads 50 more items when you reach the bottom... those items aren't in the DOM yet when the screenshot fires.&lt;/p&gt;

&lt;p&gt;You get a screenshot of the first batch of content and nothing else.&lt;/p&gt;

&lt;h2&gt;Scroll, Wait, Repeat&lt;/h2&gt;

&lt;p&gt;The workaround is manual scrolling:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;async function scrollToBottom(page) {
  let previousHeight = 0;
  let currentHeight = await page.evaluate(() =&amp;gt; document.body.scrollHeight);
  
  while (previousHeight !== currentHeight) {
    previousHeight = currentHeight;
    await page.evaluate(() =&amp;gt; window.scrollTo(0, document.body.scrollHeight));
    await page.waitForTimeout(1500);
    currentHeight = await page.evaluate(() =&amp;gt; document.body.scrollHeight);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Scroll to bottom, wait for new content, check if height changed. When it stops changing, you've hit the end. Then take your &lt;code&gt;fullPage&lt;/code&gt; screenshot.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;waitForTimeout(1500)&lt;/code&gt; is a guess. Some pages load fast, some make API calls that take 3+ seconds. I've seen people use &lt;code&gt;waitForNetworkIdle&lt;/code&gt; here, but that can hang forever on pages with analytics pings and websocket connections.&lt;/p&gt;

&lt;p&gt;What I do: wait for timeout, then also check if new DOM elements appeared:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const itemCount = await page.evaluate(
  () =&amp;gt; document.querySelectorAll('.feed-item').length
);
// scroll...
const newCount = await page.evaluate(
  () =&amp;gt; document.querySelectorAll('.feed-item').length
);
if (newCount === itemCount) break;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;More reliable than height checks for some layouts.&lt;/p&gt;

&lt;h2&gt;The Memory Problem&lt;/h2&gt;

&lt;p&gt;A page with 500 loaded items is big. Setting the viewport to 1920x50000 and capturing as PNG creates a massive image. Chrome might crash, or your server runs out of memory.&lt;/p&gt;

&lt;p&gt;Two options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap the scroll depth.&lt;/strong&gt; Don't try to capture everything. Scroll N times and stop:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const MAX_SCROLLS = 20;
for (let i = 0; i &amp;lt; MAX_SCROLLS; i++) {
  await page.evaluate(() =&amp;gt; window.scrollTo(0, document.body.scrollHeight));
  await page.waitForTimeout(1000);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;Use JPEG instead of PNG.&lt;/strong&gt; For long screenshots, JPEG at quality 80 is usually 5-10x smaller:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;await page.screenshot({ 
  fullPage: true, 
  type: 'jpeg', 
  quality: 80 
});
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Intersection Observer Gotchas&lt;/h2&gt;

&lt;p&gt;Modern sites use IntersectionObserver for lazy loading. The observer fires when elements enter the viewport. In headless Chrome, scrolling programmatically does trigger these observers — but only if you actually scroll through the viewport, not just jump to the bottom.&lt;/p&gt;

&lt;p&gt;If you &lt;code&gt;window.scrollTo(0, 99999)&lt;/code&gt; in one jump, observers for elements in the middle of the page might not fire. Scroll incrementally:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const scrollStep = 500;
let scrollPos = 0;
const maxHeight = await page.evaluate(() =&amp;gt; document.body.scrollHeight);

while (scrollPos &amp;lt; maxHeight) {
  scrollPos += scrollStep;
  await page.evaluate((y) =&amp;gt; window.scrollTo(0, y), scrollPos);
  await page.waitForTimeout(200);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Slower but catches all the lazy-loaded content.&lt;/p&gt;

&lt;h2&gt;When to Give Up&lt;/h2&gt;

&lt;p&gt;Some pages are genuinely infinite — social media feeds, search results that go on forever. Set a reasonable limit — either by scroll count, total height, or time spent — and document it. "We capture the first 50 items" is a perfectly valid approach.&lt;/p&gt;

</description>
      <category>screenshotapi</category>
      <category>webcapture</category>
      <category>developertools</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Capture Dark Mode Screenshots Programmatically</title>
      <dc:creator>Vitalii Holben</dc:creator>
      <pubDate>Wed, 16 Sep 2026 12:23:17 +0000</pubDate>
      <link>https://dev.to/webmox/how-to-capture-dark-mode-screenshots-programmatically-2d02</link>
      <guid>https://dev.to/webmox/how-to-capture-dark-mode-screenshots-programmatically-2d02</guid>
      <description>&lt;p&gt;Needed to generate both light and dark mode screenshots of a marketing site for a client's brand guidelines doc. Turns out forcing dark mode in a headless browser isn't as straightforward as setting a CSS class.&lt;/p&gt;

&lt;p&gt;Here's how I got it working with Playwright (same approach works with Puppeteer).&lt;/p&gt;

&lt;h2&gt;
  
  
  The prefers-color-scheme approach
&lt;/h2&gt;

&lt;p&gt;Most modern sites use the &lt;code&gt;prefers-color-scheme&lt;/code&gt; media query for dark mode. You can emulate this in 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="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;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="c1"&gt;// Light mode capture&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lightCtx&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;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&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;1280&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;800&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;lightPage&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;lightCtx&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;lightPage&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="s1"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lightPage&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light.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;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="c1"&gt;// Dark mode capture&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;darkCtx&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;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&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;1280&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;800&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;darkPage&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;darkCtx&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;darkPage&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="s1"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;darkPage&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark.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;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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is &lt;code&gt;colorScheme: 'dark'&lt;/code&gt; in the browser context. This sets &lt;code&gt;prefers-color-scheme: dark&lt;/code&gt; at the OS level, so the page responds exactly as it would on a user's machine with dark mode enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the site uses a toggle instead
&lt;/h2&gt;

&lt;p&gt;Some sites don't rely on the media query. They use a JavaScript toggle that sets a class on &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; — something like &lt;code&gt;class="dark"&lt;/code&gt; or &lt;code&gt;data-theme="dark"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For these, you need to interact with the page:&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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="c1"&gt;// Take light mode screenshot first&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;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;light.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Click the theme toggle&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;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="theme-toggle"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Wait for transition animations to finish&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;500&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;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;dark.png&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;If you don't know the toggle's selector, you can force it by injecting the class directly:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// or&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;documentElement&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="s1"&gt;data-theme&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;dark&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;// Give CSS transitions time to complete&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;300&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;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;dark-forced.png&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;This is hacky but reliable when you can't find the toggle button.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling both approaches at once
&lt;/h2&gt;

&lt;p&gt;Real-world sites sometimes use a combination — media query as default, with a manual override stored in localStorage. Here's what I ended up with for a batch capture script:&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;captureWithTheme&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;theme&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;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;colorScheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;theme&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;1280&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;800&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;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;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;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;networkidle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Also set common localStorage/cookie theme preferences&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="nx"&gt;t&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;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-mode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&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;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`theme=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; path=/`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Reload to pick up localStorage-based themes&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;reload&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="c1"&gt;// Force the class as a fallback&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="nx"&gt;t&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;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&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;dark&lt;/span&gt;&lt;span class="dl"&gt;'&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;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;documentElement&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="s1"&gt;data-theme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;theme&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;500&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;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;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="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;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;captureWithTheme&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&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;dark&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;dark.png&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;captureWithTheme&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&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;light&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;light.png&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;Bit aggressive with the overrides, but it catches most theme implementations I've encountered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Puppeteer equivalent
&lt;/h2&gt;

&lt;p&gt;If you're using Puppeteer instead of Playwright, the media query emulation looks different:&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;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="c1"&gt;// Emulate dark mode&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;emulateMediaFeatures&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;prefers-color-scheme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&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;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="s1"&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="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;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;dark.png&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;emulateMediaFeatures&lt;/code&gt; works well but you need to call it before navigating to the page. If you call it after, some components might not re-render until you force a repaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I ran into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CSS transitions mess up screenshots.&lt;/strong&gt; If the site has a smooth dark-to-light transition (0.3s fade), your screenshot might catch it mid-transition. Either wait for the animation to complete or disable transitions entirely:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*, *::before, *::after { transition: none !important; animation: none !important; }&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;strong&gt;Images don't always switch.&lt;/strong&gt; Some sites serve different image assets for light/dark mode using the &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element with &lt;code&gt;prefers-color-scheme&lt;/code&gt; in the &lt;code&gt;&amp;lt;source&amp;gt;&lt;/code&gt; media attribute. The &lt;code&gt;colorScheme&lt;/code&gt; context option handles this correctly. The JavaScript class-forcing approach does not — you'll get light mode images with a dark background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third-party widgets ignore your theme.&lt;/strong&gt; Embedded chat widgets, analytics consent banners, and social embeds use their own theme logic. Your dark mode screenshot might have a blinding white cookie popup in the corner. Remove these elements before capturing:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selectors&lt;/span&gt; &lt;span class="o"&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;[class*="cookie"]&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;[class*="consent"]&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;[class*="chat-widget"]&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;iframe[src*="chatbot"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;selectors&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;sel&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;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="nx"&gt;sel&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="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&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;System dark mode affects more than colors.&lt;/strong&gt; On macOS, dark mode also changes scrollbar appearance, form control styling, and the default background color of transparent elements. If pixel-perfect consistency between light and dark captures matters, set a fixed background on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; before capturing.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use this
&lt;/h2&gt;

&lt;p&gt;Visual regression testing is the obvious case — you want to verify that both themes render correctly after every deploy. But I've also used it for generating marketing assets (light mode for print, dark mode for social media previews) and documentation screenshots where you want to show both variants side by side.&lt;/p&gt;

&lt;p&gt;The Playwright approach with &lt;code&gt;colorScheme&lt;/code&gt; in the browser context is the cleanest solution for media-query-based themes. For everything else, the JavaScript override approach gets the job done even if it feels a bit brute-force.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>testing</category>
      <category>tutorial</category>
    </item>
    <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>
  </channel>
</rss>
