<?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: Mohammed Abdul Mubeen Khan</title>
    <description>The latest articles on DEV Community by Mohammed Abdul Mubeen Khan (@mamubeenkhan).</description>
    <link>https://dev.to/mamubeenkhan</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%2F4124042%2Fdf72798a-6647-427e-804c-f508b51a2f0a.png</url>
      <title>DEV Community: Mohammed Abdul Mubeen Khan</title>
      <link>https://dev.to/mamubeenkhan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mamubeenkhan"/>
    <language>en</language>
    <item>
      <title>Six things that break when you run headless Chrome at scale</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/six-things-that-break-when-you-run-headless-chrome-at-scale-5d4o</link>
      <guid>https://dev.to/mamubeenkhan/six-things-that-break-when-you-run-headless-chrome-at-scale-5d4o</guid>
      <description>&lt;p&gt;Taking one screenshot with Puppeteer is twenty minutes of work. Taking a few million is a different job entirely.&lt;/p&gt;

&lt;p&gt;I found that out building &lt;a href="https://screenshotline.com" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt;, a screenshot API. What surprised me was not how much broke. It was how little of it looked broken. Almost nothing crashed. The failures were a &lt;code&gt;200 OK&lt;/code&gt; with the wrong picture in it — a cookie wall, a half-loaded page, a grey hole, a blank white frame — which a pipeline records as a success and nobody notices for a month.&lt;/p&gt;

&lt;p&gt;So I built a benchmark of 202 real websites — news sites, shops, SaaS sites, docs, government pages, sites in other languages and scripts — and ran every change against it. The failures were consistent enough to be worth writing down. Here are the six that cost the most, each with the fix and the actual code, and then the bug that was worse than all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Consent banners arrive after the page settles
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; Your screenshot is a cookie wall, even though you remove cookie banners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; The obvious approach is a sweep at capture time: find the consent dialog, remove it, take the picture. But many sites inject the banner &lt;em&gt;late&lt;/em&gt; — after the page has loaded, sometimes after the network has gone quiet. nytimes.com is one. A sweep that runs at capture time is simply too early, and it loses that race every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Don't sweep once. Install a watcher before any of the page's own scripts run, and keep sweeping every time the DOM changes:&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="nx"&gt;COOKIE_OBSERVER_SCRIPT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where the script is, in part:&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;schedule&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scheduled&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;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&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;start&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="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="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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&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;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;observe&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;childList&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="na"&gt;subtree&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="nf"&gt;run&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 50ms debounce matters: a busy page mutates its DOM thousands of times, and sweeping on every mutation would be pure waste.&lt;/p&gt;

&lt;p&gt;One more thing that took a while to find: some consent managers render inside a &lt;strong&gt;shadow root&lt;/strong&gt;, and &lt;code&gt;querySelectorAll&lt;/code&gt; does not cross a shadow boundary. On dw.com the sweep was looking straight past a full-page modal. The sweep now walks open shadow roots too.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pages that never go network-idle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; Timeouts on pages that load perfectly well in your browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; &lt;code&gt;networkidle2&lt;/code&gt; — wait until there are no more than two network connections for half a second — is the obvious default, and it is wrong for the real web. Plenty of sites hold a connection open forever for analytics beacons, long-polling or ad refresh. They painted seconds ago; they will never be "idle". Across the&lt;br&gt;
benchmark, waiting for it caused &lt;strong&gt;18 hard timeouts&lt;/strong&gt;. Waiting for &lt;code&gt;load&lt;/code&gt; instead gave a median of 23 seconds, because 16 of the 20 SaaS pages in the su&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Navigate to &lt;code&gt;domcontentloaded&lt;/code&gt;, then spend one bounded settle budget on the things that actually affect the picture — load if it comes soonages and video in view decoding — and capture when the budget runs out:&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="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;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;options&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="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitUntil&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;6000&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 second line of that &lt;code&gt;Math.min&lt;/code&gt; is a lesson on its own: the settle budget has to leave room for the capture inside the overall timeout, or a page that takes 24 seconds to navigate settles its way straight past a 30-second ceiling.&lt;/p&gt;

&lt;p&gt;A fixed budget still can't serve both a static page and a heavy news homepage. timesofindia.indiatimes.com returned &lt;strong&gt;446 characters&lt;/strong&gt; of text with a 5-second settle and &lt;strong&gt;48,755&lt;/strong&gt; with 15 seconds — and the short version came back as a clean 200. So the settle has an adaptive tail: it keeps waiting while the page's text is still&lt;br&gt;
growing, and stops early once there is plainly enough of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Pages that wedge their own main thread
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; A single page times out, and takes a browser down with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; When a page blocks its main thread, everything you ask of it hangs: &lt;code&gt;page.evaluate&lt;/code&gt;, &lt;code&gt;page.screenshot&lt;/code&gt;, and even &lt;code&gt;page.close()&lt;/code&gt;. Nons own. vercel.com did this in the benchmark: the document was complete at 3.6 seconds, then four JavaScript chunks stayed in flight forever and the pagestopped answering anything. One stuck page burns the whole render budget.&lt;/p&gt;

&lt;p&gt;I blamed this on concurrency for longer than I'd like to admit. Then I ran the page on its own, with nothing else happening, and watched it hang there too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Every wait gets its own ceiling. Anything you ask the page for is optional — a measurement, a sweep — so time it out and carry on with what is already on screen:&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;atMost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;promise&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="kc"&gt;null&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;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="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&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="nx"&gt;ms&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;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateAtMost&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;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;atMost&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="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&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;And a page that misses one deadline has almost always stopped answering for good, so after the first miss, stop asking — every further timed-out call ishole has a wall-clock ceiling, and when it fires the browser process is killed, not politely closed: &lt;code&gt;close()&lt;/code&gt; waits on the very thing that hung.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Blocked ads leave a hole
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; You block ads, and the screenshot has a big grey rectangle pushing the real content below the fold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Blocking the ad request stops the ad. It does not give back the space: the page reserved that slot's height before the ad arrived, a&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; After the page has settled, find ad containers that are still empty and collapse them. The part that matters is not collapsing anything rea— whole class-name tokens, never substrings, because a substring match on "ad" eats &lt;code&gt;header&lt;/code&gt;, &lt;code&gt;shadow&lt;/code&gt;, &lt;code&gt;download&lt;/code&gt; and &lt;code&gt;gradient&lt;/code&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;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;token&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&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;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;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;AD_TOKENS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&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;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;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;AD_PREFIXES&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;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a slot is only collapsed if it has no text, nothing painted inside it, and is not large enough to be the page itself. It runs &lt;em&gt;after&lt;/em&gt; the settle, sohouse ad is left alone. On france24.com this took the capture from 122KB to 234KB of actual content.&lt;/p&gt;

&lt;p&gt;(A caution about that number: file size is a bad proxy for quality in both directions. Removing a consent modal makes a capture &lt;em&gt;smaller&lt;/em&gt;, and one compere was blurry because it was taken mid-paint. Open the image. Always open the image.)&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The blank capture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; None, which is the problem. A bot wall or a page that needs JavaScript you blocked renders a blank white frame, the API returns 200, an success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Detect it, and say so in a response header. Neither signal is trustworthy alone. An empty DOM flags legitimately sparse pages (badssl.com iackground), and low bytes-per-pixel flags anything simple. So both have to agree:&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;domBlank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textLength&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&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;visuallyBlank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calibrated&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;bytesPerPixel&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.008&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textLength&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blankSuspected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;detectorApplies&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visuallyBlank&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;domBlank&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;calibrated&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;bytesPerPixel&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there each came from a real mistake. &lt;code&gt;detectorApplies&lt;/code&gt; is false for a successful response that isn't HTML: Chrome's own JSON or image viewer is legitimately sparse, and a JSON endpoint was being flagged as a blank capture. And &lt;code&gt;calibrated&lt;/code&gt; restricts the pixel signal to the configuration its thresholds were measured on&lt;br&gt;
— a PNG of a normal viewport — because bytes-per-pixel measures the &lt;em&gt;encoding&lt;/em&gt; as much as the page. A 3840×4320 capture of a small page is mostly white wrong, and a quality-1 JPEG is tiny whatever it shows.&lt;/p&gt;

&lt;p&gt;Honest reporting also meant going through every "hard" page that &lt;em&gt;passed&lt;/em&gt; by hand. Of the 20 pages the suite marks as known-hard, 14 returned something.uinely the real page, 2 were captchas that a naive count would have scored as wins, and 1 was a dead URL in my own test suite.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Zombie Chrome
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The symptom.&lt;/strong&gt; Throughput slowly collapses and memory climbs, with no obvious cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; When Chrome crashes mid-render, its child processes are orphaned. Inside a container, whatever is PID 1 is supposed to reap orphans rocess, nothing does. They sit as &lt;code&gt;&amp;lt;defunct&amp;gt;&lt;/code&gt; entries forever, while your pool believes those slots are free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is one line, and it cost me an afternoon to find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/usr/bin/tini", "--"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;docker run --init&lt;/code&gt;, or &lt;code&gt;init: true&lt;/code&gt; in Compose, does the same.) Pair it with recycling each browser after a fixed number of renders, because Chrome dos navigations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that had no symptom
&lt;/h2&gt;

&lt;p&gt;Two things I got wrong are worth more than the list above.&lt;/p&gt;

&lt;p&gt;The consent-banner heuristic ate apnews.com's entire site header one day. Its trending strip carried the headline "Girl Scout cookies". The keyword testn-word test — looking for buttons like "OK" and "Accept" — matched &lt;strong&gt;ok&lt;/strong&gt; inside c-o-&lt;strong&gt;ok&lt;/strong&gt;-i-e-s, because I had not put word boundaries in the regex. One word in a news headline passed both tests, and the sweep removed a header containing 1,022 links and two &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; elements. The fix that actually holds is structural rather than lexical: a consent banner is never the site's navigation.&lt;/p&gt;

&lt;p&gt;Then, fixing that, I introduced something worse.&lt;/p&gt;

&lt;p&gt;The scripts that run inside the page are built as JavaScript template literals — backtick strings — so they can be injected with &lt;code&gt;evaluateOnNewDocument&lt;/code&gt;. Inside a template literal, a lone &lt;code&gt;\b&lt;/code&gt; is not a word boundary. It is an escape sequence, and it collapses into a single &lt;strong&gt;backspace character&lt;/strong&gt;, U+0008. So my new, correct-looking&lt;br&gt;
regex:&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;SWEEP_BODY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  const ACTIONS = /\b(accept|agree|reject|allow|ok)\b/i;
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;compiled, inside the page, into a regex that matched a backspace, which never appears on a web page. It matched nothing. The whole consent heuristic wass. Every test passed. Every page still rendered. The apnews header "survived" my new guard only because the heuristic had stopped removing anything atall.&lt;/p&gt;

&lt;p&gt;Inside a template literal, every backslash has to be doubled:&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;ACTIONS&lt;/span&gt; &lt;span class="o"&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;b&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;accept|agree|reject|allow|manage|continue|got it|ok|okay&lt;/span&gt;&lt;span class="se"&gt;)\\&lt;/span&gt;&lt;span class="sr"&gt;b/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;\b&lt;/code&gt; is not the only one: &lt;code&gt;\f&lt;/code&gt;, &lt;code&gt;\v&lt;/code&gt; and &lt;code&gt;\0&lt;/code&gt; collapse the same way, and &lt;code&gt;\s&lt;/code&gt; quietly becomes the letter &lt;code&gt;s&lt;/code&gt;. There is now a test that scans every script we inject for control characters — shown here with escapes, because the real test file contains the characters themselves:&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;collapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\x&lt;/span&gt;&lt;span class="sr"&gt;00&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="sr"&gt;08&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="sr"&gt;0B&lt;/span&gt;&lt;span class="se"&gt;\x&lt;/span&gt;&lt;span class="sr"&gt;0C&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&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="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="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scripts&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;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collapsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;===&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="s2"&gt;`&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; contains a control character at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;at&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test is the only reason this class of bug is visible at all. Nothing else in the system could have noticed: the code was valid, the pages rendered, and the output was merely &lt;em&gt;wrong&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting
&lt;/h2&gt;

&lt;p&gt;Screenshotline's benchmark numbers today: of the 182 pages in the suite that should work, all 182 capture correctly, with a median of 5.7 seconds on the benchmark machine. The other 20 are named as hard, and the ones that return something are checked by eye.&lt;/p&gt;

&lt;p&gt;If you need screenshots at volume, you can absolutely build this yourself — every fix above is a few dozen lines. But you will own this list, and the list grows. That is the whole case for an API.&lt;/p&gt;

&lt;p&gt;Screenshotline is &lt;a href="https://github.com/MAMubeenKhan/screenshotline" rel="noopener noreferrer"&gt;open source&lt;/a&gt; (AGPL-3.0): the renderer that runs the hosted API is the same code, with nothing held back. If you'd rather someone else's pager went off, the &lt;a href="https://screenshotline.com" rel="noopener noreferrer"&gt;hosted version&lt;/a&gt; has 500 free renders a month and no card.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>node</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
