<?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>How do you test a screenshot API when every failure returns 200 OK?</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/how-do-you-test-a-screenshot-api-when-every-failure-returns-200-ok-hd5</link>
      <guid>https://dev.to/mamubeenkhan/how-do-you-test-a-screenshot-api-when-every-failure-returns-200-ok-hd5</guid>
      <description>&lt;p&gt;Most test suites answer the question "did it work?". A screenshot renderer makes that question almost useless, because the interesting failures all return &lt;code&gt;200 OK&lt;/code&gt; with an image attached.&lt;/p&gt;

&lt;p&gt;A cookie wall is a valid PNG. So is a page captured mid-paint, a blank white frame from a bot wall, and a captcha. Every one of those passes a status-code check, passes a "is the response an image?" check, and passes a "is the file bigger than zero bytes?" check. Then it sits in someone's pipeline for a month.&lt;/p&gt;

&lt;p&gt;So the benchmark behind &lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt;is built around a different question: &lt;strong&gt;which pages surprised me this time?&lt;/strong&gt; This post is how that works, because the shape of it applies to anything that scrapes, renders or crawls the real web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every page carries a claim, written before the run
&lt;/h2&gt;

&lt;p&gt;The suite is 202 real sites across 15 categories: news, shops, SaaS, docs, government, education, finance, WebGL and visualisation, TLS edge cases, HTTP semantics, and 13 languages in 10 scripts. Each entry looks like this:&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nyt&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;https://www.nytimes.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;Fides consent banner injected after networkidle&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;and each page is tagged in advance with what I claim my own product should do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/strong&gt;: this must work. A failure here is a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;known-hard&lt;/code&gt;&lt;/strong&gt;: I expect a bot wall or a block. A failure here is information, not a regression.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That tag converts a run from a score into a set of predictions that can be wrong, which is the version that teaches you something. The runner prints the two kinds of broken prediction under a heading I actually read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  BUGS - expected to work, failed (0):
  Expected hard, but worked (14) - verify the image is real content, not a bot wall

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A page that was supposed to work and didn't is a bug. A page that was supposed to be hard and passed means something changed. Maybe my renderer got better, maybe the site dropped a defence, maybe my own test URL has rotted.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pass on a hard page means nothing until you look
&lt;/h2&gt;

&lt;p&gt;This is the rule that cost me the most to learn. There are three completely different things behind a &lt;code&gt;200&lt;/code&gt; on a page I marked &lt;code&gt;known-hard&lt;/code&gt;, and only one is a success:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A login wall&lt;/strong&gt; is what any logged-out visitor sees. Capturing it is &lt;em&gt;correct&lt;/em&gt;. facebook, instagram, quora.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A captcha&lt;/strong&gt; is a challenge shown only to suspected bots. Capturing it is a &lt;strong&gt;failure dressed as a pass&lt;/strong&gt;. walmart, wsj.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 404&lt;/strong&gt; means the URL in my own suite died. amazon, where an ASIN went away and I was quietly benchmarking a "product not found" page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No automatic check tells those apart. I tried. A text heuristic scored wsj as "looks real" on &lt;strong&gt;zero extracted characters&lt;/strong&gt;, because the page around the challenge was full of navigation and footer text.&lt;/p&gt;

&lt;p&gt;So when the runner says 14 known-hard pages returned something, I open all 14. The last time: 11 were genuinely the real page, 2 were captchas, and 1 was that dead URL. That is a result I can publish. "14 of 20 hard pages passed" is not.&lt;/p&gt;

&lt;p&gt;It is also why I will never quote a single headline percentage that mixes the two groups together. The honest version has two numbers: &lt;strong&gt;of the 182 pages marked &lt;code&gt;ok&lt;/code&gt;, all 182 capture correctly&lt;/strong&gt;; of the 20 marked known-hard, 14 returned something and each one was checked by eye.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blank frames fail the run
&lt;/h2&gt;

&lt;p&gt;The suite's sharpest rule is about blank captures, because that is the worst thing this product can do: return a 200 that a customer's pipeline files as a success while the image shows nothing.&lt;/p&gt;

&lt;p&gt;Some pages really are blank for everyone: a bot wall that serves an empty document, or an IP block. Those are marked &lt;code&gt;blankOk&lt;/code&gt; in the suite file, one page at a time, after I have looked. A blank frame on &lt;strong&gt;any other page&lt;/strong&gt; fails the whole run:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unexpectedBlank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;` FAILED: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unexpectedBlank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; unexpected blank capture(s).`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&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="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-zero exit is deliberate. Without it, a script or a CI job that only checks the status code would let a silent blank regression through, which is precisely the class of failure the suite exists to catch.&lt;/p&gt;

&lt;p&gt;The report also separates "blank, and known to be" from "blank, and new". The first list is context. The second is a stop-everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  File size is not quality, in either direction
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is to compare byte counts: bigger image, better capture. It is wrong both ways, and I have been fooled in both directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing a consent modal makes a capture smaller.&lt;/strong&gt; A full-screen dark overlay compresses into a lot of bytes. The better picture is the lighter one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collapsing blocked ad slots makes it bigger.&lt;/strong&gt; On france24.com, collapsing the empty slots took a capture from 122 KB to 234 KB of actual content, because real content moved up into the frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A larger competitor capture can still be worse.&lt;/strong&gt; One competitor's france24 image was bigger than mine and blurry, because it was taken mid-paint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And some pages simply swing.&lt;/strong&gt; nytimes.com moves between 80 KB and 650 KB depending on how much of its skeleton has hydrated. That is page timing, not a renderer bug, and &lt;code&gt;delay&lt;/code&gt; is the parameter for it.&lt;/p&gt;

&lt;p&gt;Which leaves the same conclusion as everywhere else in this project: open the image.&lt;/p&gt;

&lt;p&gt;To make that bearable across 202 pages and several providers, the runner writes every capture to disk and a second script builds one HTML page with every provider's version of every URL side by side. Ten minutes of scrolling tells you more than any metric I have found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the suite is not
&lt;/h2&gt;

&lt;p&gt;It is &lt;strong&gt;not a scoreboard&lt;/strong&gt;, and the pass rate is the least useful line it prints. Any suite can be made to look perfect by lowering its expectations, and a benchmark whose numbers only ever improve is measuring the author's optimism.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not stable across machines&lt;/strong&gt;. Several of these sites block datacenter IP ranges, so a page that passes from my laptop may fail from a cloud host. I'd rather know that before a customer discovers it: it is why the hosted API's p50 (about 7.1s, single region) is measured separately from the benchmark machine's 5.7s rather than reusing the nicer number.&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;not a competitor takedown&lt;/strong&gt;. I run the same 202 URLs against other providers because side-by-side images are how I find the pages where I am worse. The honest result of the last head-to-head, on 18 news front pages: we captured 18/18, ScreenshotOne 17/18, and their median was faster than ours, 5.6s against 6.0s. Publishing the part that flatters you and quietly dropping the rest is how benchmarks became a genre people don't trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you are building something similar
&lt;/h2&gt;

&lt;p&gt;The parts worth stealing, in order of how much they saved me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the expectation next to the URL, before you run anything.&lt;/strong&gt; A test that can only pass or fail tells you less than one that can surprise you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the silent failure loud.&lt;/strong&gt; Decide what "wrong but successful" looks like in your domain, detect it, and let it fail the run with a non-zero exit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a category that you expect to fail.&lt;/strong&gt; It stops you from tuning the suite until it is green and keeps the hard cases in view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the output with your own eyes, on a schedule.&lt;/strong&gt; Not because automation is bad, but because the failures that matter are the ones your checks were not built to see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The suite lives in &lt;a href="https://github.com/MAMubeenKhan/screenshotline/tree/main/bench" rel="noopener noreferrer"&gt;&lt;code&gt;bench/&lt;/code&gt;&lt;/a&gt; in the repo: the runner, the 202 URLs with their tags and notes, and the comparison page builder. It runs against a local instance, so if you &lt;a href="https://screenshotline.com/blog/self-hosted-screenshot-api" rel="noopener noreferrer"&gt;self-host&lt;/a&gt; you can point it at your own and see what your hardware does with the same pages.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>node</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Cryptid Field Office: an AI triages sightings, a person signs off</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Fri, 25 Sep 2026 11:07:12 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/the-cryptid-field-office-an-ai-triages-sightings-a-person-signs-off-57go</link>
      <guid>https://dev.to/mamubeenkhan/the-cryptid-field-office-an-ai-triages-sightings-a-person-signs-off-57go</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Cryptid Field Office&lt;/strong&gt;: a deadpan government bureau that takes every unexplained sighting seriously. Cryptids, lights over the desert, a figure on the stairs, a jinn in the storeroom, a lodge that is on no property register. You file a report. An AI &lt;strong&gt;Field Investigator&lt;/strong&gt; reads it, scores it and decides where it goes. A person signs off on anything unclear. Every case ends in a stamp: CLASSIFIED, DEBUNKED or INCONCLUSIVE.&lt;/p&gt;

&lt;p&gt;It is silly on the surface and serious underneath. Take away the Bigfoot and it is a &lt;strong&gt;report-triage pipeline&lt;/strong&gt;: public intake, AI screening, linking related reports, a human decision, and an audit trail. Swap the creatures for potholes and you have a city 311 system. The same pattern runs bug trackers, insurance claims and fraud review. That was the point: something strange, built on something real.&lt;/p&gt;

&lt;p&gt;It has three surfaces on one Sanity content lake:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Who uses it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Public site&lt;/strong&gt; (Next.js)&lt;/td&gt;
&lt;td&gt;Case map and files, a four-step report form, a live "your report is being triaged" page, and a public &lt;strong&gt;Director's Desk&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Anyone. No login.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Case Board&lt;/strong&gt; (Sanity &lt;strong&gt;App SDK&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;A live corkboard: drag case photos around, pull a red pin from one card onto another to draw a string, approve or reject the AI's suggested links, fire workflow actions&lt;/td&gt;
&lt;td&gt;Bureau staff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Studio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom desk, status badges, a map location picker, and the official &lt;strong&gt;Workflows&lt;/strong&gt; plugin&lt;/td&gt;
&lt;td&gt;Editors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The part I am proudest of: &lt;strong&gt;the agent and a person move a case through exactly the same workflow actions.&lt;/strong&gt; The AI triages a report, a visitor on the Director's Desk opens an investigation, and a staff member closes it from the Case Board, and all three are the same &lt;code&gt;fireAction&lt;/code&gt; on the same instance, recorded in one audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live site (no login needed): &lt;a href="https://cryptid-field-office-mubeen9.vercel.app" rel="noopener noreferrer"&gt;https://cryptid-field-office-mubeen9.vercel.app&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Try it in about a minute:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://cryptid-field-office-mubeen9.vercel.app/report" rel="noopener noreferrer"&gt;File a report&lt;/a&gt;.&lt;/strong&gt; Describe something, drop a pin, optionally attach a photo. About 15 seconds after you submit, the Field Investigator has read it and a stamp lands on your case.&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;&lt;a href="https://cryptid-field-office-mubeen9.vercel.app/desk" rel="noopener noreferrer"&gt;Director's Desk&lt;/a&gt;&lt;/strong&gt; and decide a case that needs a person.&lt;/li&gt;
&lt;li&gt;Open the resulting case file: memo, evidence, red strings to related cases, and the timeline showing who did what ("Agent", "Visitor Director", "Bureau").&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Studio and Case Board need a Sanity login, so the video and screenshots below show them. The Director's Desk gives you the same workflow power without one.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/xEti2uTE5eA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The video shows the whole loop, from a public report to a stamp, and it is the only place to see the Case Board and Studio without a Sanity login.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F464j09dlkh06jhuwxkm5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F464j09dlkh06jhuwxkm5.png" alt="The home page" width="800" height="2077"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62ewyc7as7vnkqdpdss4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62ewyc7as7vnkqdpdss4.png" alt="A report, triaged by the Field Investigator" width="800" height="811"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zw3770ufolvprelog0o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zw3770ufolvprelog0o.png" alt="A case file" width="800" height="1587"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl47zi92qh0q568rijrs8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl47zi92qh0q568rijrs8.png" alt="The map and case list" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fob9sv250dz4hgyalbach.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fob9sv250dz4hgyalbach.png" alt="The Director's Desk" width="800" height="1357"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tkz7ksyso7ac0mc1o6v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8tkz7ksyso7ac0mc1o6v.png" alt="Dark mode" width="800" height="2077"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FMAMubeenKhan%2Fcfo%2Fmain%2Fsubmission%2Fscreenshot-7-phone.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2FMAMubeenKhan%2Fcfo%2Fmain%2Fsubmission%2Fscreenshot-7-phone.png" alt="On a phone" width="390" height="7122"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/MAMubeenKhan/cfo" rel="noopener noreferrer"&gt;https://github.com/MAMubeenKhan/cfo&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The repo includes &lt;code&gt;PLAN.md&lt;/code&gt; (the full runbook I had Claude write &lt;em&gt;before&lt;/em&gt; any code) and &lt;code&gt;BUILDLOG.md&lt;/code&gt; (an honest day-by-day log, including everything that broke).&lt;/p&gt;
&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Claude Code (Opus 5.5 for planning, Sonnet 5 for most building), the official &lt;code&gt;sanity-best-practices&lt;/code&gt; agent skill, Playwright, axe-core and Lighthouse for testing. I am non-technical; I directed, Claude built.&lt;/p&gt;
&lt;h3&gt;
  
  
  The idea (and the prompt that mattered most)
&lt;/h3&gt;

&lt;p&gt;My first prompt was the challenge page pasted in, plus: &lt;em&gt;"give me options on what we should make and tell me what is best and why."&lt;/em&gt; Claude scored four ideas against the four judging criteria and both bonuses and recommended the cryptid bureau, because a case moving from triage to a verdict is exactly the "agent moves it forward, a person approves it" pattern, and a red-string board is a natural App SDK app.&lt;/p&gt;

&lt;p&gt;My honest worry was: &lt;em&gt;"won't it be weird and silly and make me a joke?"&lt;/em&gt; Claude's answer became the design rule for the whole project: &lt;strong&gt;play it deadpan.&lt;/strong&gt; The humour lives in the bureaucracy (case numbers, stamps, memos), never in the subject. A silly idea with a real workflow reads as clever; a silly idea with three screens reads as a joke.&lt;/p&gt;

&lt;p&gt;Then I kept pushing the scope: &lt;em&gt;"include UFOs, aliens, Area 51"&lt;/em&gt;, then &lt;em&gt;"ghosts, jinn, spirits and occult"&lt;/em&gt;, then &lt;em&gt;"secret cults and societies."&lt;/em&gt; That created a real design question I want to be upfront about, because it shaped the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jinn are part of real religious belief.&lt;/strong&gt; So the bureau's humour must never touch them. Rules that ended up in code, not just in copy: faith-related reports are &lt;strong&gt;never auto-closed&lt;/strong&gt;; the AI prompt forbids ruling on belief or calling a witness mistaken; the final filing step &lt;strong&gt;refuses an agent "debunked" stamp&lt;/strong&gt; on a faith-sensitive case even if everything upstream failed; and a human closing one must tick an extra confirmation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every secret society is invented.&lt;/strong&gt; No real group, religion or person appears anywhere.&lt;/li&gt;
&lt;li&gt;Reports suggesting distress or coercion force human review and show a calm banner.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The plan-first prompt
&lt;/h3&gt;

&lt;p&gt;Before writing code I asked for &lt;em&gt;"an end to end plan so a new session doesn't have to do any planning, including edge cases, no tests until completion, only one comprehensive test at the end."&lt;/em&gt; Claude read the Workflows and App SDK docs, checked real package versions, and wrote a ~500-line runbook. Three mid-flight prompts shaped it: &lt;em&gt;"it should be attractive with good UX and highly polished"&lt;/em&gt;, &lt;em&gt;"as per current standards"&lt;/em&gt; (this became a WCAG 2.2 AA, Core Web Vitals, dark-mode, reduced-motion spec), and my memory file (a &lt;code&gt;memory.md&lt;/code&gt; Claude reads at every session start and appends lessons to).&lt;/p&gt;
&lt;h3&gt;
  
  
  Sanity features I used
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content model:&lt;/strong&gt; 8 document types (case, subject, region, witness, connection, board pin, settings, counter) and 5 object types. Typed evidence union (photo, footprint, sound, testimony), geopoints, references, and a first-class &lt;code&gt;connection&lt;/code&gt; document with provenance and confidence: the red strings. Witnesses are anonymous codenames with a credibility score computed across their cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio customisation:&lt;/strong&gt; status-queue desk structure, category and restricted-site lists, badges (status, plausibility P0-100, hidden), delete removed for cases (it would orphan their workflow instance), and a &lt;strong&gt;MapLibre location picker&lt;/strong&gt; replacing the default geopoint input (which needs a Google Maps key).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflows (0.35, early access):&lt;/strong&gt; the &lt;code&gt;case-lifecycle&lt;/code&gt; definition: five stages, five automated effects, human actions for the Director and Investigator. It passed &lt;code&gt;sanity-workflows deploy --check&lt;/code&gt; on the first attempt. I then ran all 30 seeded cases through the &lt;em&gt;real engine&lt;/em&gt; using the same actions a person fires, and asserted the stage histogram matched the plan exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functions:&lt;/strong&gt; a document Function fires when a workflow instance gains unclaimed effects and runs the AI handlers in Sanity's cloud, and a daily Scheduled Function sweeps stale claims and re-evaluates open cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent Actions:&lt;/strong&gt; the Field Investigator and the Cross-Referencer both call &lt;code&gt;client.agent.action.prompt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App SDK:&lt;/strong&gt; the Case Board (&lt;code&gt;useQuery&lt;/code&gt; streams, &lt;code&gt;useWorkflowEngine&lt;/code&gt;, &lt;code&gt;useWorkflowSession&lt;/code&gt;, &lt;code&gt;@sanity/workflow-diagram&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Where it got stuck, and how we corrected course
&lt;/h3&gt;

&lt;p&gt;I would rather show the dead ends than pretend there were none.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A big Bash command silently applied nothing&lt;/strong&gt; (twice): long heredocs containing an apostrophe failed with "unexpected EOF". The lesson, saved to memory: write files with the file tool, one call per file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-installed packages:&lt;/strong&gt; an interrupted &lt;code&gt;npm install&lt;/code&gt; left a half-extracted &lt;code&gt;zod&lt;/code&gt;, which made every Sanity CLI command fail with &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt;, then made a second install fail with &lt;code&gt;Invalid Version:&lt;/code&gt;. Fix: delete &lt;code&gt;node_modules&lt;/code&gt; and the lockfile, install fresh, in the background. On this network installs took 10 to 25 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The skill corrected the plan.&lt;/strong&gt; I installed Sanity's &lt;code&gt;sanity-best-practices&lt;/code&gt; skill and it caught that &lt;code&gt;@sanity/icons&lt;/code&gt; v5 has &lt;em&gt;no root exports&lt;/em&gt; (every icon imports from its own path). My draft compiled and would have failed at bundle time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions:&lt;/strong&gt; my API token could deploy the Studio and Functions but not an App SDK app (missing an organisation-level grant) and could not create the organisation stack that Scheduled Functions need. I did not work around it: I shipped without the daily backup job and wrote a repair command instead, deployed the Case Board from my own login, and later, once I was logged in, created the organisation stack, moved both Functions onto it and retired the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel served 404 for everything:&lt;/strong&gt; the CLI created the project with no framework preset, so the build passed but the edge served nothing. One API call to set &lt;code&gt;framework: nextjs&lt;/code&gt; and a redeploy fixed it. Deployment Protection was also putting a login in front of the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI was too harsh.&lt;/strong&gt; In the live test, a credible sighting &lt;em&gt;with a photo and a footprint&lt;/em&gt; scored 68, just under the 70 "investigate" bar. I added a scoring guide to the prompt ("a submitted photo or footprint counts as physical evidence"; "vague is not false: when in doubt, score 20-49 so a person can look") and redeployed. The same report then opened an investigation, and the Cross-Referencer proposed 3 real related files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Things the live test found&lt;/strong&gt; that no type-checker would have: a missing case returned HTTP 200 (a soft 404) because of a page-wide loading skeleton; the map threw "Worker failed to load" because MapLibre 6's worker files can't be bundled by Next; the map opened on Scotland instead of framing all cases; my evidence photos were bad at first sight (a footprint that looked like a snowman, a triangle invisible on a dark sky), which I only caught by &lt;em&gt;looking&lt;/em&gt; at them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My first rate limiter was useless, and only a live test showed it.&lt;/strong&gt; I had built it in server memory and even documented it as a known limit. Then I filed eight reports at once from one connection with the limit set to five: all eight went through, because serverless hosting spreads requests over many servers. I rebuilt it as counters stored in Sanity as private documents (an id with a dot in it is unreadable to the public). The same test then accepted five and refused three, and an anonymous query for those counters returns nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel blocked a deployment&lt;/strong&gt; once I started committing to git: the CLI attached my commit author, which was not on the Vercel team. Deploying from a copy of the folder without git history fixes it (&lt;code&gt;scripts/deploy-web.sh&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recording the demo video found three more bugs.&lt;/strong&gt; Watching my own screen recording, the red strings on the Case Board were grey and hair-thin: React Flow's stylesheet loads later and beat mine, so I raised the selector's specificity. The Studio's map input was blank: MapLibre 6 needs a worker URL in every host, not only the Next site, so I serve the worker from the Studio's &lt;code&gt;static&lt;/code&gt; folder. And demo cases I had hidden were still appearing as "related files" on public pages, so the query now drops a connection if either end is hidden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; the case page scored 57 on a throttled phone because a 290 KB map library loaded below the fold. Mounting the map only when it scrolls into view took it to 93. Accessibility is 100 on every page; the search-engine score stays near 66 only because Vercel adds a &lt;code&gt;noindex&lt;/code&gt; header to its free addresses.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The App SDK and Workflows, honestly
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Workflows&lt;/strong&gt; is a library, not a service: it acts only when your code calls it. That felt unfamiliar for a day, then clicked. Defining the process as data next to the content, with conditions in GROQ, made "the agent and a person use the same transitions" almost free. The cookbook matched the installed types closely, which is rare for a 0.x release. A deterministic instance ID (&lt;code&gt;prod.wf-instance.&amp;lt;caseId&amp;gt;&lt;/code&gt;) made starting a case idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App SDK&lt;/strong&gt; hooks stayed live without any polling code of my own, and the workflow session gave me the action list, the definition and the history for the diagram in one object. The one rule I obeyed carefully: never edit a workflow instance document directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What is honestly not there
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;No CAPTCHA. Abuse is limited by a durable rate limit (5 reports an hour per visitor, 40 overall), a honeypot, a minimum fill time, and a daily AI budget with a kill switch in &lt;code&gt;Bureau settings&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Case Board is desktop-only, by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not tested:&lt;/strong&gt; two people using the Case Board at the same moment (the live sync is Sanity's, and I only tried it alone); a real screen reader (I ran axe-core, which scored 100, and did a keyboard-only pass, but I did not listen to it); and the daily sweeper's first scheduled run, which was due after I wrote this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI can be wrong.&lt;/strong&gt; Code, not the model, picks the route, but a very low score (under 20) still auto-debunks an ordinary case. The safety net is narrower than "a person sees everything": faith, distress and coercion flags always force human review, and every automated step is written to the case's audit log so a person can find and reverse it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;cyh4xyo1&lt;/code&gt; · &lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;Sample query: &lt;code&gt;https://cyh4xyo1.api.sanity.io/v2026-09-01/data/query/production?query=*[_type=="case"][0...5]{caseNumber,title,category,status}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio:&lt;/strong&gt; &lt;a href="https://cryptid-field-office.sanity.studio/" rel="noopener noreferrer"&gt;https://cryptid-field-office.sanity.studio/&lt;/a&gt; (needs a Sanity login)&lt;/li&gt;
&lt;li&gt;Schema highlights: &lt;code&gt;case&lt;/code&gt; (workflow subject with a status mirror), &lt;code&gt;connection&lt;/code&gt; (red strings with provenance), &lt;code&gt;witness&lt;/code&gt; (anonymous, credibility computed across cases), &lt;code&gt;subject&lt;/code&gt; (six categories), &lt;code&gt;region&lt;/code&gt; (a &lt;code&gt;restricted&lt;/code&gt; flag drives the Area 51 banner and redactions).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;


&lt;div class="ltag-agent-session"&gt;
  &lt;div class="agent-session-header"&gt;
    
    &lt;span class="agent-session-tool-icon-badge" title="Claude Code"&gt;
&lt;/span&gt;
    &lt;span class="agent-session-title"&gt;Vibe-coding a cryptid bureau on Sanity: the key moments&lt;/span&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-scroll"&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;&lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;https://dev.to/challenges/sanity-2026-09-16&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;&amp;lt;pasted_content id="a237"&amp;gt;&lt;br&gt;
DEV Community&lt;br&gt;
Create Post&lt;br&gt;
Edit&lt;br&gt;
Preview&lt;/p&gt;

&lt;p&gt;Upload Cover ImageNo file chosenUse a ratio of 1000:420 for best results.&lt;br&gt;
🍌 Generate Image&lt;br&gt;
Cover Video Link&lt;br&gt;
New post title here...&lt;/p&gt;

&lt;p&gt;Add up to 4 tagsMaximum 4 selections&lt;br&gt;
Selected items:&lt;/p&gt;

&lt;h1&gt;devchallenge&lt;/h1&gt;

&lt;h1&gt;sanitychallenge&lt;/h1&gt;

&lt;h1&gt;sanity&lt;/h1&gt;

&lt;h1&gt;ai&lt;/h1&gt;

&lt;p&gt;Bold CTRL + B&lt;br&gt;
Italic CTRL + I&lt;br&gt;
Link CTRL + K&lt;br&gt;
Ordered list&lt;br&gt;
Unordered list&lt;br&gt;
Heading&lt;br&gt;
Quote&lt;br&gt;
Code&lt;br&gt;
Code block&lt;br&gt;
Embed CTRL + SHIFT + K&lt;br&gt;
No file chosenUpload image&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;What I Built&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- Tell us about the app you prompted into existence. What does it do and who is it for? --&amp;gt;&lt;/p&gt;

&lt;h2&gt;Demo&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- Share a link to your deployed project and include a video walkthrough or screenshots. --&amp;gt;&lt;/p&gt;

&lt;h2&gt;Code&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- Embed or share a link to your repository. --&amp;gt;&lt;/p&gt;

&lt;h2&gt;My Build Process&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- This is the heart of your submission. Which AI-native IDE did you use? Share the prompts that worked, the ones that didn't, where the model got stuck, and how you course-corrected. If you reached past the Studio into the App SDK or Workflows, tell us how that went. --&amp;gt;&lt;/p&gt;

&lt;h2&gt;Sanity Project Details&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- Required: share your Sanity project ID or a link to a public dataset URL so the Sanity team can see how you modeled and used your structured content. --&amp;gt;&lt;/p&gt;

&lt;h2&gt;Agent Session&lt;/h2&gt;

&lt;p&gt;&amp;lt;!-- Optional but encouraged: upload your transcript at &lt;a href="https://dev.to/agent_sessions/new"&gt;https://dev.to/agent_sessions/new&lt;/a&gt;, curate or slice the parts worth showing, and embed it right here. Supported tools include Claude Code, Gemini CLI, Codex, GitHub Copilot CLI, and Pi. --&amp;gt;&lt;br&gt;
&amp;lt;!-- Sessions are unlisted by default, so hit Make Public before you publish or judges won't be able to open it. Check your transcript for keys and sensitive data first. --&amp;gt;&lt;/p&gt;

&lt;p&gt;&amp;lt;!-- Don't forget to add a cover image if you want! --&amp;gt;&lt;/p&gt;

&lt;p&gt;&amp;lt;!-- Team Submissions: Please pick one member to publish the submission and credit teammates by listing their DEV usernames directly in the body of the post. --&amp;gt;&lt;/p&gt;

&lt;p&gt;&amp;lt;!-- Thanks for participating! --&amp;gt;&lt;br&gt;
Publishing Tips&lt;br&gt;
Ensure your post has a cover image set to make the most of the home feed and social media platforms.&lt;br&gt;
Share your post on social media platforms or with your co-workers or local communities.&lt;br&gt;
Ask people to leave questions for you in the comments. It's a great way to spark additional discussion describing personally why you wrote it or why people might find it helpful.&lt;br&gt;
Publish&lt;br&gt;
Save Draft&lt;br&gt;
AI Disclosure&lt;br&gt;
Advanced Options&lt;br&gt;
&amp;lt;/pasted_content id="a237"&amp;gt;&lt;/p&gt;

&lt;p&gt;&amp;lt;pasted_content id="a237"&amp;gt;&lt;br&gt;
Path Two: Vibe-Code Something Strange&lt;br&gt;
Prompt your way to a working app. Any AI-native IDE, Next.js or Astro on the front, Sanity behind it.&lt;/p&gt;

&lt;p&gt;This one is judged on the build as much as the result. How deep did you get into Sanity's features? Did you customize the interface? Build a new component to turn videos into gifs? Create a workflow that kicks off an external API call? A rough app with an honest writeup beats a [... 4497 characters omitted for upload size ...]  the entry that received the highest number of positive reactions on their DEV post to determine the winner.&lt;/p&gt;

&lt;p&gt;How will I know if I won?&lt;br&gt;
Winners will be announced in a DEV post on the winner announcement date noted in our key dates section.&lt;/p&gt;

&lt;p&gt;When will I receive my DEV badge?&lt;br&gt;
Both participation and winner badges will be awarded, in most cases, the same day as the winner announcement.&lt;/p&gt;

&lt;p&gt;When will I receive my prizes?&lt;br&gt;
The DEV Team will contact you via the email associated with your DEV profile within, at most, 10 business days of the announcement date to share the details of claiming your prizes.&lt;/p&gt;

&lt;p&gt;What steps do I need to take to receive my cash prize?&lt;br&gt;
The winner (including each member of a team) may be required to sign and return an affidavit of eligibility and publicity/liability release, and provide any additional tax filing information (such as a W-9, social security number or Federal tax ID number) within seven (7) business days following the date of your first email notification.&lt;/p&gt;

&lt;p&gt;Sanity Challenge Rules&lt;br&gt;
NO PURCHASE NECESSARY. Open only to 18+. Contest entry period ends October 4, 2026 at 11:59 PM PDT. Contest is void where prohibited or restricted by law or regulation. All entries must be submitted during the contest period. For Official Rules, see Sanity Challenge Contest Rules and General Contest Official Rules.&lt;br&gt;
&amp;lt;/pasted_content id="a237"&amp;gt;&lt;/p&gt;

&lt;p&gt;. So, let's make something and win this challenge. You give me options on what we should make and tell me what is best for this and why&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Checked the challenge page plus Sanity's Workflows and App SDK docs. Now I'm writing up the options and a recommended build plan.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;I haven't built anything yet. Here's what I've got so far, then tell me what you'd like to clarify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What wins this path:&lt;/strong&gt; judges score four things: an honest build write-up, a working app, a well-designed schema, and creativity. The bonus is for going past the Studio: an &lt;strong&gt;App SDK&lt;/strong&gt; app (a custom real-time interface) or &lt;strong&gt;Workflows&lt;/strong&gt;, where a draft moves through set stages that both an agent and a person can advance. Sanity only launched Workflows on Sep 14 and it's early access, so few entrants will use it well. That's our best edge. The deadline is Oct 4, 10 days from now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Options:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Idea&lt;/th&gt;
&lt;th&gt;Workflow fit&lt;/th&gt;
&lt;th&gt;App SDK fit&lt;/th&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Strangeness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A. Cryptid Field Office&lt;/strong&gt;: a bureau processing Bigfoot/Mothman sighting reports&lt;/td&gt;
&lt;td&gt;Natural&lt;/td&gt;
&lt;td&gt;Natural&lt;/td&gt;
&lt;td&gt;Rich&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. Exquisite Corpse Gazette: surreal newspaper written in turns&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C. Dream Patent Office: dreams filed as patents&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D. Fridge Court: leftovers put on trial&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Weak&lt;/td&gt;
&lt;td&gt;Thin&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My pick is A, the Cryptid Field Office.&lt;/strong&gt; It's the only idea where both bonuses come straight out of the premise:&lt;br&gt;
- &lt;strong&gt;Workflows:&lt;/strong&gt; a sighting report moves Reported → an AI "Field Investigator" triages it → human review if flagged → verdict. The agent and a person press the same buttons, which is what the judges asked for.&lt;br&gt;
- &lt;strong&gt;App SDK:&lt;/strong&gt; a live red-string conspiracy board. Dragging a string between two sightings creates a real link between those records, and you can approve a case from the board.&lt;br&gt;
- &lt;strong&gt;Schema:&lt;/strong&gt; sightings, creatures, witnesses (with a credibility score across cases), typed evidence, map locations and connections. That gives us plenty to say about why the data had to be structured.&lt;br&gt;
- &lt;strong&gt;Demo:&lt;/strong&gt; a public Next.js map of sightings that judges can use without logging in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One risk:&lt;/strong&gt; Workflows needs early-access sign-up. If we don't have access by day 2, we model the workflow ourselves as Sanity documents, and the write-up says so honestly.&lt;/p&gt;

&lt;p&gt;What would you like to clarify? It could be the ideas, the stack, what accounts you have (Sanity, Vercel, an Anthropic API key), or something else.&lt;/p&gt;

&lt;p&gt;Sources:&lt;br&gt;
- &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;DEV Sanity Challenge&lt;/a&gt;&lt;br&gt;
- &lt;a href="https://www.sanity.io/docs/workflows/cookbook" rel="noopener noreferrer"&gt;Sanity Workflows cookbook&lt;/a&gt;&lt;br&gt;
- &lt;a href="https://www.sanity.io/docs/workflows/cookbook-ai-content-pipeline" rel="noopener noreferrer"&gt;AI content pipeline recipe&lt;/a&gt;&lt;br&gt;
- &lt;a href="https://www.sanity.io/docs/app-sdk/sdk-introduction" rel="noopener noreferrer"&gt;App SDK intro&lt;/a&gt;&lt;br&gt;
- &lt;a href="https://www.sanity.io/everything-2026-recap" rel="noopener noreferrer"&gt;Everything NYC 2026 recap&lt;/a&gt;&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;if i chose cryptid field office, won't it be weird and silly and will it be acceptable and won't it make me a joke in there?&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;It's a fair worry, but I don't think it will make you look like a joke. Here's why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The brief asks for strange.&lt;/strong&gt; The path is literally called "Vibe-Code &lt;em&gt;Something Strange&lt;/em&gt;", and creativity is one of the four judging criteria. The judges will read dozens of blog templates and "AI content helper" apps. A playful premise done well stands out. A safe idea done well blends in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. People laugh at weak engineering, not at a funny theme.&lt;/strong&gt; A silly idea with three screens and a thin write-up looks like a joke. A silly idea with a real workflow, an agent handing cases to a human, a live App SDK board and a carefully designed schema looks like someone who's good enough to have fun. The theme is the costume. The engineering is what gets judged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It's a serious system in disguise.&lt;/strong&gt; Take out the cryptids and what's left is a &lt;strong&gt;report-triage pipeline&lt;/strong&gt;: the public files reports, an AI screens them, related reports get linked, a human makes the final call, and there's an audit trail. That's how pothole reports, bug trackers, insurance claims and fraud flags work. The write-up can say so outright: &lt;em&gt;"Swap Bigfoot for potholes and this is a city 311 system."&lt;/em&gt; Judges see the fun and the real-world pattern, and that's a strong combination.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Tone is our choice.&lt;/strong&gt; It doesn't have to be goofy. I'd play it &lt;strong&gt;deadpan&lt;/strong&gt;: a dry government-bureau look with case numbers, "CLASSIFIED" stamps and formal language, taken completely seriously. It's in the spirit of the SCP Foundation or &lt;em&gt;The X-Files&lt;/em&gt;. Humour that plays it straight comes across as clever, not silly.&lt;/p&gt;

&lt;p&gt;If you'd still feel uncomfortable putting your name on it, that matters. You'll be the one posting it and answering comments. The same engineering can wear a more serious costume.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;first, write end to end plan on how to implement this so if i start a new session and give the file to claude, it should not have to do planning of any kind, you get how planned I want this to be? including edge cases etc as we are short on time, also mention in the plan, no tests during implementation until completion. only one comprehensive test after completion. also, make a memory.md file and read it before starting any task, It should hold my preferences and lessons learnt in current and past sessions. After each task, if I corrected you or you learned something worth keeping, add one short dated and timed line to memory.md. Do not rewrite old lines.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;h2&gt;Where the plan is&lt;/h2&gt;

&lt;p&gt;It's saved in one file:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&lt;span class="agent-session-redacted"&gt;[REDACTED]&lt;/span&gt;\.claude\plans\https-dev-to-challenges-sanity-2026-09-1-optimized-plum.md&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once you approve it, the first thing the build does is copy it into your project folder as &lt;strong&gt;&lt;code&gt;&lt;span class="agent-session-redacted"&gt;[REDACTED]&lt;/span&gt;\sanity\PLAN.md&lt;/code&gt;&lt;/strong&gt;. Any new Claude session can be given that file and start building straight away, with no re-planning. It covers every step, the exact commands, the problems that could come up and what to do about each one, the design rules, the rule of one full test at the very end, and exactly which small steps you'll need to do yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About memory.md:&lt;/strong&gt; you already have one at &lt;code&gt;&lt;span class="agent-session-redacted"&gt;[REDACTED]&lt;/span&gt;\.claude\memory.md&lt;/code&gt;, and it's loaded automatically at the start of every session. So I won't create a second one. I'm not allowed to change files until you approve the plan, so it's the plan's first step to add today's new preferences to that file: fully detailed plans, a polished UI, and simple explanations for you.&lt;/p&gt;

&lt;h2&gt;What we're building, in plain words&lt;/h2&gt;

&lt;p&gt;We're building a &lt;strong&gt;website that pretends to be a serious government office for Bigfoot sightings.&lt;/strong&gt; It's called the &lt;strong&gt;Cryptid Field Office&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a visitor can do:&lt;/strong&gt;&lt;br&gt;
1. &lt;strong&gt;Report a sighting.&lt;/strong&gt; They fill in a short form: what they saw, where (by pinning it on a map), when, and an optional photo.&lt;br&gt;
2. &lt;strong&gt;Watch an AI "investigator" read it&lt;/strong&gt; in about 30 seconds. It gives the report a believability score out of 100 and writes a short, dry office memo about it.&lt;br&gt;
3. &lt;strong&gt;See the report sorted automatically:&lt;/strong&gt;&lt;br&gt;
   - Obviously fake → &lt;strong&gt;stamped "DEBUNKED"&lt;/strong&gt; right away.&lt;br&gt;
   - Unclear → sent to a human "Director" to decide.&lt;br&gt;
   - Believable → opened as an investigation. A second AI then looks for similar sightings nearby and links them with red strings, like a detective's wall.&lt;br&gt;
4. &lt;strong&gt;Browse every case&lt;/strong&gt; on a map, and open any case to read its full file.&lt;br&gt;
5. &lt;strong&gt;Play the Director.&lt;/strong&gt; A visitor page lets anyone approve or reject waiting cases, so the judges can try the whole thing without logging in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you (as staff) get:&lt;/strong&gt; a private &lt;strong&gt;"Case Board"&lt;/strong&gt;. It's a digital corkboard where case cards are pinned and red strings connect related sightings. Your team can drag cards, draw strings and make decisions, and everyone sees changes instantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a joke idea is a smart entry:&lt;/strong&gt; under the costume, this is a real system. It's the same one cities use for pothole complaints and companies use for customer claims: people report, AI screens, a human decides, and everything is recorded. The judges asked for exactly this ("an agent moves it forward, a person approves it"), and we use both of Sanity's newest features that they said they especially want to see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it will look:&lt;/strong&gt; it'll be polished and modern, styled like old government files: paper colours, typewriter-style case numbers, ink stamps that "thump" onto the page, and a dark mode. It'll work well on phones and follow current accessibility standards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll need to do yourself&lt;/strong&gt; (I'll walk you through each with exact clicks): log in to Sanity once, create a Vercel account, paste one secret key into a file, click through the Vercel setup, record a 2–3 minute screen video, and publish the post on DEV. I'll do everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt; building Sep 25 – Oct 1, the one full test on Oct 2, and writing and publishing the post on Oct 3. That leaves Oct 4 as a spare day before the deadline.&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;carryon but i think we should also include ufos and aliens, area 51 etc&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
        &lt;div class="agent-session-model-change"&gt;
          &lt;span class="agent-session-gap-line"&gt;&lt;/span&gt;
          &lt;span class="agent-session-model-change-label"&gt;Switched to claude-sonnet-5&lt;/span&gt;
          &lt;span class="agent-session-gap-line"&gt;&lt;/span&gt;
        &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Good call. UFOs, aliens and Area 51 fit the "Department of Unexplained Sightings" idea, and they open up better material. UFO reports can be explained by Venus, satellites or aircraft, which suits an AI investigator, and Area 51 gives us a redacted-document look. Nothing is seeded yet, so changing the schema now costs almost nothing.&lt;/p&gt;

&lt;p&gt;The change: "Creature" becomes a broader &lt;strong&gt;Subject&lt;/strong&gt; (cryptid, UFO craft, extraterrestrial, folklore, misidentified). Cases get a &lt;strong&gt;category&lt;/strong&gt;, and regions can be flagged &lt;strong&gt;restricted&lt;/strong&gt; (Area 51 and similar).&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;also, ghosts, jinn, spirits and occult sightings&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;even occult secret cults and societies&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Two fixes before checking: my "restricted regions" filter query was malformed, and the map picker still needs attaching to the location field.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;I'll validate the definition offline. The &lt;code&gt;--check&lt;/code&gt; option contacts nothing and only tests whether the engine accepts my process map.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The workflow passes validation on the first try. I'll deploy it to your dataset now, so any dataset-level problem shows up early.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;The workflow, the AI handlers and the cloud automation are built and running. The Studio is redeployed at &lt;a href="https://cryptid-field-office.sanity.studio/" rel="noopener noreferrer"&gt;https://cryptid-field-office.sanity.studio/&lt;/a&gt; with the workflow plugin, so refresh it. Cases now have real stages, and the thumbnails show on the list. I haven't looked at it in a browser myself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The workflow works end to end.&lt;/strong&gt; All 30 sample cases ran through the real engine, using the same actions a person would use. They landed exactly where planned: 8 classified, 9 debunked, 4 inconclusive, 5 under investigation, 4 waiting for the Director. Each case's log, verdict and witness credibility updated correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The faith rule held.&lt;/strong&gt; None of the three spirit and jinn cases was debunked. Two safeguards enforce this in code, so the AI can't override it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cloud automation is live.&lt;/strong&gt; A Sanity Function now runs the AI investigator automatically whenever a new report arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI works on your plan.&lt;/strong&gt; I checked that Sanity's built-in AI is available, so you don't need a separate AI account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One honest cut:&lt;/strong&gt; I dropped the daily backup-repair job. Sanity only allows it at the organisation level, and the token I have doesn't permit that. I added a command that does the same repair on demand, so it costs us nothing. You can lift that limit yourself if you want it: it's a permission on your Sanity organisation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One open question:&lt;/strong&gt; I've only tested that the AI &lt;em&gt;responds&lt;/em&gt;. How good its scoring is on real reports gets tested in the final pass. If it turns out weak, I can switch to Claude, which needs an Anthropic API key from you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still owed:&lt;/strong&gt; nothing is committed or pushed to &lt;code&gt;cfo&lt;/code&gt;, and the old token is still in this chat's record, so please replace it before publishing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next is the public website:&lt;/strong&gt; the map, the case files, the "file a report" form, and the live stamp that lands when the AI finishes. This is the part judges will see first, so it gets the most design effort. Shall I go ahead?&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The build itself succeeded, but the site is being served as if it were a plain static folder. I'll check the project's framework setting.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The framework wasn't set, so Vercel didn't know this was a Next.js app. I'll set it and redeploy.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;While the website redeploys, I'll redeploy the Case Board with the corrected link, using your login.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The site is redeployed and the map worker file is being served. I'll rerun the screenshots to confirm the fixes, then run the first real report through the site.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The AI's updated instructions are live. Now the website with the skip-link fix, and then I'll re-file the credible report to see whether the calibration helped.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The calibration worked: the same credible report now goes straight to investigation. Now the Director's Desk test on two of the earlier test cases.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Picking up where we left off. My notes list two open items: the mobile speed of case pages (57 out of 100) and the search-engine score (about 66). I'll start with the exact reasons Lighthouse gave for the search-engine score.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The deploy failed. I'll look at the actual error rather than guess.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;"Not authorized" means the command-line login to Vercel isn't active in this session. I'll check.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The Vercel command line is now signed in as a different account (&lt;code&gt;mamubeenkhan-6376&lt;/code&gt;) from the one that owns the project (&lt;code&gt;mubeen9&lt;/code&gt;). I'll see which teams this login can access.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The login is fine: same account, same team. The failure may have been a hiccup, so I'll retry the deploy with the team named explicitly.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Deployed. Now I'll re-measure speed on phone and desktop.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Deployed. Now a quick check that the deferred map still appears when you scroll to it on a phone, and a re-measure of the home page.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div&gt;
                  &lt;div class="agent-session-text agent-session-text-collapse"&gt;
                    &lt;p&gt;Testing turned up two things worth fixing, and both are fixed and deployed. Most of the untested items pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Passed&lt;/strong&gt;&lt;br&gt;
- &lt;strong&gt;Keyboard only:&lt;/strong&gt; the skip link works and every one of the 44 tab stops on the home page shows a visible focus ring. I filed a complete report with the keyboard alone, choosing the category with arrow keys and the place with the search box.&lt;br&gt;
- &lt;strong&gt;Reduced motion:&lt;/strong&gt; nothing animates.&lt;br&gt;
- &lt;strong&gt;Place search down:&lt;/strong&gt; a clear message appears and you can still drop a pin and continue.&lt;br&gt;
- &lt;strong&gt;Firefox:&lt;/strong&gt; clean.&lt;br&gt;
- &lt;strong&gt;Safari's engine:&lt;/strong&gt; pages, maps and the form work. It logs one harmless message about a cancelled background request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Found and fixed: the report limiter was useless.&lt;/strong&gt;&lt;br&gt;
I filed eight reports at once from one connection, with the limit set to 5 per hour, and all eight went through. The counter lived in each server's memory, and Vercel runs many servers at once. I rebuilt it so the counts live in Sanity as hidden records that visitors can't read. It now allows 5 per hour per person and 40 per hour overall. I reran the test: &lt;strong&gt;5 accepted, 3 refused&lt;/strong&gt;, and an anonymous check finds none of the hidden records. The daily repair job now also tidies old counters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One more snag: Vercel blocked a deploy.&lt;/strong&gt; Now that the code is in git, Vercel refused it because the commit author isn't on your Vercel team. I added &lt;code&gt;scripts/deploy-web.sh&lt;/code&gt;, which deploys from a copy without git history. I also cleared the counters and hid the 20 new test reports, so &lt;strong&gt;you can film without being limited&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still can't test without you&lt;/strong&gt;&lt;br&gt;
- Two people on the Case Board at once. To try it, open the Case Board in two browser windows and drag a card in one.&lt;br&gt;
- A real screen reader.&lt;br&gt;
- The daily repair job, which first runs tomorrow at 04:00 UTC.&lt;/p&gt;

&lt;p&gt;Everything is committed and pushed to your private &lt;code&gt;cfo&lt;/code&gt; repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the video:&lt;/strong&gt; I'd suggest the hybrid plan. I record the public-website part automatically with on-screen captions, and you record only the Case Board and Studio clips (about 30 seconds) plus a voice-over. Or you record everything yourself from the shot list. Which do you prefer?&lt;/p&gt;

                  &lt;/div&gt;
                  Show more
                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Tiles load fine here (only aborted prefetches). Checking what it rendered.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The public map renders fine. The blank one is the Studio input, which likely lacks the worker setup that MapLibre 6 needs. I'll check its code.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;CORS is open on the Studio host. Deploying the Studio with the CLI login.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;Deployed and served. I'll reload the Studio tab in Chrome and check the map.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;That F5 went to VS Code instead of Chrome, because VS Code had focus. It looks harmless, but I'll stop driving your screen blind. I'll log the findings, then hand back.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-user"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-user"&gt;
          You
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;video is good, just make the strings more visible and red, they are hardly visible&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The strings on the Case Board are thin and greyish in the recording. I'll look at how they're styled.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The CSS already asks for red, but React Flow's own stylesheet (grey, 1 px) is winning over it. I'll raise the specificity, thicken the strings and make them a stronger red.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class="agent-session-message agent-session-assistant"&gt;
        &lt;div class="agent-session-role-badge agent-session-role-assistant"&gt;
          Agent
        &lt;/div&gt;
        &lt;div class="agent-session-content"&gt;
                &lt;div class="agent-session-text"&gt;
                  &lt;p&gt;The type-check passes. My stronger selector would also override the reduced-motion rule, so I'll fix that too, then deploy the Case Board.&lt;/p&gt;

                &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class="agent-session-footer"&gt;
    &lt;span class="agent-session-meta"&gt;
        38 of 38 messages
    &lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Reading a web page as Markdown, from inside the browser</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/reading-a-web-page-as-markdown-from-inside-the-browser-3pa4</link>
      <guid>https://dev.to/mamubeenkhan/reading-a-web-page-as-markdown-from-inside-the-browser-3pa4</guid>
      <description>&lt;p&gt;&lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt; takes screenshots of web pages. It also returns the same page as Markdown, from the same render, and that second endpoint gets more use than I expected when I built it.&lt;/p&gt;

&lt;p&gt;The reason is simple. A screenshot is for a person. If the thing reading the page is a script or a model, handing it a PNG is the wrong shape: it has to run OCR or a vision model to recover text that was sitting in a DOM a moment earlier. That is slower, lossier and more expensive than just giving it the text.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;/extract&lt;/code&gt; runs the identical pipeline as &lt;code&gt;/take&lt;/code&gt; and returns Markdown instead of pixels. Everything that makes the picture correct — the adaptive settle, the consent sweep, the collapsed ad slots, the scroll that triggers lazy images — makes the text correct for exactly the same reasons.&lt;/p&gt;

&lt;p&gt;This post is about the part I got wrong first: &lt;strong&gt;where&lt;/strong&gt; the extraction should happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the DOM, not a copy of it
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to pull the HTML out of the browser and run it through a good server-side library. Readability to find the article, Turndown to convert it. Both are better than anything I would write at handling edge cases.&lt;/p&gt;

&lt;p&gt;I went the other way: a dependency-free walker that runs &lt;em&gt;inside&lt;/em&gt; the page, via &lt;code&gt;page.evaluate&lt;/code&gt;, on the DOM that is already rendered. Not because the libraries are bad, but because by the time we are extracting, the browser has already done work that a serialised copy throws away.&lt;/p&gt;

&lt;p&gt;Here is the clearest example. On danluu.com, the list of posts is written like this, with no whitespace at all between the elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;d&amp;gt;&lt;/span&gt;09/26&lt;span class="nt"&gt;&amp;lt;/d&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Some post title&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;d&amp;gt;&lt;/code&gt; is not a real element, and the page styles it as a 4em flex item. Read the markup as text and you get &lt;code&gt;09/26Some post title&lt;/code&gt;. Read the &lt;em&gt;computed layout&lt;/em&gt; and you can see the browser gives &lt;code&gt;&amp;lt;d&amp;gt;&lt;/code&gt; a box, so there is a visual gap, so the text needs a space:&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;LAID_OUT&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;block|flex|grid|table|list-item|flow-root|table-cell|inline-block|inline-flex&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;laidOut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;styleOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;LAID_OUT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any unknown element the browser lays out as a box gets a space around its contents. That rule only exists because the extraction can ask the browser what it actually did.&lt;/p&gt;

&lt;p&gt;Three more things come free in the same place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden content stays out.&lt;/strong&gt; &lt;code&gt;display: none&lt;/code&gt;, &lt;code&gt;visibility: hidden&lt;/code&gt; and &lt;code&gt;opacity: 0&lt;/code&gt; are computed values. A tab panel that isn't open, or a mobile menu that only exists at narrow widths, would otherwise be indistinguishable from real content in the markup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shadow roots are reachable.&lt;/strong&gt; &lt;code&gt;innerText&lt;/code&gt; and &lt;code&gt;querySelectorAll&lt;/code&gt; do not cross a shadow boundary, so a site built out of web components extracts as an empty document. Walking into open shadow roots is a few lines:&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;childNodesOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&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;shadow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shadowRoot&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;shadow&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;shadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;childNodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;childNodes&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;childNodes&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;Closed roots are unreachable by design, and nothing can be done about those. The same blind spot once hid a full-page consent modal from the screenshot sweep on dw.com, so it is worth knowing about in both directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The consent banner is already gone.&lt;/strong&gt; By extraction time the sweep has removed it, and ad slots that were blocked have been collapsed and marked, so the walker can skip them by attribute. Nothing has to recognise a cookie wall twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The text arrives later than the page does
&lt;/h2&gt;

&lt;p&gt;This is the part people underestimate, and it has nothing to do with Markdown.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;timesofindia.indiatimes.com&lt;/code&gt; returns &lt;strong&gt;446 characters&lt;/strong&gt; of text if you extract after a 5-second settle, and &lt;strong&gt;48,755&lt;/strong&gt; if you wait 15. Both responses are a clean &lt;code&gt;200&lt;/code&gt;. Nothing in either one says "this page wasn't finished".&lt;/p&gt;

&lt;p&gt;That is why the extractor reuses the render pipeline's settle budget instead of grabbing text as soon as the DOM is ready: the budget keeps waiting while the page's text is still growing and stops early once there is plainly enough. A Markdown endpoint with a fixed 3-second wait would look fast and quietly return a header and a spinner on every heavy news site.&lt;/p&gt;

&lt;p&gt;The same logic protects the sweep. The consent heuristic refuses to remove any element carrying more than 1,500 characters of text, because on france24.com it was eating about 1,200 characters of actual article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Markdown that survives real pages
&lt;/h2&gt;

&lt;p&gt;A few structural decisions, each from a page that broke the naive version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link-wrapped headings.&lt;/strong&gt; Most news front pages are built as a card where an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; wraps an &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt;. Emitted in the obvious order, that becomes &lt;code&gt;[# Headline](url)&lt;/code&gt;, which is neither a heading nor a link. So a heading inside a link is hoisted back out and the link goes inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt; &lt;span class="o"&gt;=&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&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;{1,6})\s&lt;/span&gt;&lt;span class="sr"&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;heading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`\n\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&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;text&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;href&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)\n\n`&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;Relative URLs are resolved&lt;/strong&gt; against &lt;code&gt;document.baseURI&lt;/code&gt;, for links and for images, because a Markdown document that leaves the page behind needs absolute links. &lt;code&gt;data:&lt;/code&gt; image sources are dropped rather than inlined — a base64 blob is not useful to a reader and can be enormous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables become pipe tables&lt;/strong&gt; , with &lt;code&gt;|&lt;/code&gt; inside a cell escaped. Lists keep their nesting, and continuation lines are indented to line up under the marker, so a multi-line list item does not break out of its list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page furniture is dropped, but carefully.&lt;/strong&gt; With &lt;code&gt;strip_chrome&lt;/code&gt; on, which is the default, &lt;code&gt;nav&lt;/code&gt;, &lt;code&gt;header&lt;/code&gt;, &lt;code&gt;footer&lt;/code&gt;, &lt;code&gt;aside&lt;/code&gt;, the matching ARIA roles and &lt;code&gt;aria-hidden="true"&lt;/code&gt; are removed. Then there is a check that matters more than the rule itself:&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;let&lt;/span&gt; &lt;span class="nx"&gt;md&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;render&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripChrome&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;md&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;\s&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="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Stripping took the page with it.&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;el&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeAttribute&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-screenshotline-hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;md&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;childNodesOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&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;n&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;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;depth&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plenty of real sites put their only content inside an &lt;code&gt;&amp;lt;aside&amp;gt;&lt;/code&gt; or hang it off a &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt;. If stripping the furniture empties the document, the whole thing is rendered again with the furniture left in. Returning a stub because the page's markup was unusual is worse than returning something slightly noisy.&lt;/p&gt;

&lt;p&gt;There is a related lesson from the screenshot side. The consent sweep once ate apnews.com's entire site header — 1,022 links and two &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; elements — because a trending headline said "Girl Scout cookies" and the keyword test matched_cookies_. Structural rules ("a consent banner is never the site's navigation") hold up where keyword rules do not.&lt;/p&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;# Markdown, as text&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.screenshotline.com/extract?url=https://example.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Access-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Or JSON, with the title and character count&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.screenshotline.com/extract?url=https://example.com&amp;amp;response=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Access-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strip_chrome=false&lt;/code&gt; keeps the navigation. &lt;code&gt;max_chars=20000&lt;/code&gt; truncates and appends a &lt;code&gt;[truncated]&lt;/code&gt; marker, which is handy when the text is going into a context window. The blocking options (&lt;code&gt;block_ads&lt;/code&gt;, &lt;code&gt;block_cookie_banners&lt;/code&gt;, &lt;code&gt;block_chats&lt;/code&gt;, &lt;code&gt;block_popups&lt;/code&gt;) work the same as for a screenshot. &lt;code&gt;X-Text-Length&lt;/code&gt; on the response tells you how much text came back without parsing the body.&lt;/p&gt;

&lt;p&gt;For agents there is a hosted MCP server with the same two operations, &lt;code&gt;screenshot&lt;/code&gt; and &lt;code&gt;read_page&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http screenshotline &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.screenshotline.com/mcp &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"X-Access-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is not a readability clone. It does not try to find the one true article and throw everything else away, because a front page, a docs page and a product listing are all legitimate things to read and none of them has an article in the middle. It strips furniture, keeps structure, and leaves the judgement to whatever is reading.&lt;/p&gt;

&lt;p&gt;It does no summarising, and no cleverness beyond the DOM. There is no model in this path at all — which I mention because "web page to Markdown for LLMs" usually implies one.&lt;/p&gt;

&lt;p&gt;And it does not get past bot walls. If a page shows a captcha, you get the captcha's text, and a blank page is flagged in a response header instead of being returned as a success.&lt;/p&gt;

&lt;p&gt;The code is one file, &lt;code&gt;src/extract.js&lt;/code&gt;, about 250 lines, no dependencies: &lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/src/extract.js" rel="noopener noreferrer"&gt;read it here&lt;/a&gt;. The hosted API has 500 free renders a month and no card, and the whole thing &lt;a href="https://screenshotline.com/blog/self-hosted-screenshot-api" rel="noopener noreferrer"&gt;self-hosts with Docker&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>puppeteer</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Self-hosting a screenshot API with Docker, and what it will cost you</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/self-hosting-a-screenshot-api-with-docker-and-what-it-will-cost-you-4aj7</link>
      <guid>https://dev.to/mamubeenkhan/self-hosting-a-screenshot-api-with-docker-and-what-it-will-cost-you-4aj7</guid>
      <description>&lt;p&gt;&lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt; is a screenshot API, and it is open source. The renderer behind the hosted API is the same code that's in the &lt;a href="https://github.com/MAMubeenKhan/screenshotline" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, with nothing held back. So you can run the whole thing yourself.&lt;/p&gt;

&lt;p&gt;The repo already has a short &lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/SELF-HOSTING.md" rel="noopener noreferrer"&gt;self-hosting guide&lt;/a&gt;. This is the longer version, with the reasons. Most lines in the compose file are there because something went wrong without them, and knowing what went wrong is how you tell which ones you can change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you self-host at all?
&lt;/h2&gt;

&lt;p&gt;First, a plain answer to the question most people are really asking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-host when&lt;/strong&gt; your screenshots have to stay on your own infrastructure, when you want to read the code before you trust it, or when you need to capture pages that only exist on your internal network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't self-host just to save money at low volume.&lt;/strong&gt; The hosted free tier is 500 renders a month with no card. Above that, a small VPS costs less per month than a paid plan, but the server isn't the real cost. Your time is: keeping Chrome up to date, noticing when renders start failing, and debugging a page that wedged the browser at 2am. If you'd rather not own that, the hosted API is the same renderer run by someone else.&lt;/p&gt;

&lt;p&gt;If you're still here, let's run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/MAMubeenKhan/screenshotline.git
&lt;span class="nb"&gt;cd &lt;/span&gt;screenshotline
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

curl &lt;span class="nt"&gt;-o&lt;/span&gt; out.png &lt;span class="s2"&gt;"http://localhost:3000/take?url=https://example.com"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole setup. &lt;code&gt;/take&lt;/code&gt; returns the image bytes directly, with no JSON wrapper, so the same URL also works inside an &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then &lt;strong&gt;open &lt;code&gt;out.png&lt;/code&gt;&lt;/strong&gt;. Don't settle for checking that curl exited cleanly or that the file isn't empty. The failure this project spends most of its code on is a &lt;code&gt;200 OK&lt;/code&gt; with the wrong picture in it, and a file size can't tell you that. Only looking can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: understand the compose file
&lt;/h2&gt;

&lt;p&gt;Here are the parts that matter, and why each one is there.&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="na"&gt;init&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reaps zombie Chrome processes.&lt;/strong&gt; When Chrome crashes mid-render, its child processes are orphaned. Inside a container, PID 1 is supposed to reap orphans, and if PID 1 is your Node process, nothing does. They sit there as &lt;code&gt;&amp;lt;defunct&amp;gt;&lt;/code&gt; while the pool believes those slots are free. Throughput slowly collapses with no obvious cause. The Dockerfile already runs &lt;code&gt;tini&lt;/code&gt; as PID 1, so &lt;code&gt;init: true&lt;/code&gt; is redundant. It's harmless, and it makes the requirement explicit in the file people actually read.&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="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1gb"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Chrome uses &lt;code&gt;/dev/shm&lt;/code&gt; for shared memory, and Docker's default is 64 MB.&lt;/strong&gt; That's not enough for image-heavy pages. The renderer also passes &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;. Either fix works on its own, and having both does no harm.&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cache:/app/.cache&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;data:/app/.data&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two volumes, and only one of them matters.&lt;/strong&gt; The cache can be thrown away and refilled. &lt;code&gt;/app/.data&lt;/code&gt; holds the SQLite database with accounts, API key hashes and usage counters, and it's only used if you turn on &lt;code&gt;BILLING_ENABLED&lt;/code&gt;. If that volume is missing, a redeploy silently deletes everything. The app then comes back up healthy and empty, which is the worst way for it to fail.&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="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3g&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The renderer runs untrusted pages, so treat it that way.&lt;/strong&gt; Chrome runs as a non-root user inside the image. The memory limit means a runaway browser gets killed inside the container, not somewhere on the rest of your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: size the pool
&lt;/h2&gt;

&lt;p&gt;Everything is configured with environment variables. These four decide how the server behaves under load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;POOL_SIZE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2 # concurrent renders, one browser each&lt;/span&gt;
&lt;span class="py"&gt;MAX_RENDERS_PER_BROWSER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;50 # recycle a browser after this many renders&lt;/span&gt;
&lt;span class="py"&gt;MAX_BROWSER_AGE_MS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1800000 # ...or after 30 minutes, whichever comes first&lt;/span&gt;
&lt;span class="py"&gt;RENDER_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30000 # wall-clock ceiling; then the browser is killed&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;POOL_SIZE&lt;/code&gt;&lt;/strong&gt; is the number of Chrome instances, and each one renders a single page at a time. One CPU core handles a pool of 1 comfortably. Give it 2 cores for a pool of 2. Past that, you're mostly buying memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The recycling settings exist because Chrome doesn't give memory back across navigations.&lt;/strong&gt; A browser that has rendered a few hundred pages is much bigger than a fresh one. In an early five-minute load test with four browsers recycling every 25 renders, memory rose from 1,344 MB to a peak of 1,619 MB and ended at 1,150 MB. That was 817 renders with zero failures. The graph was a sawtooth, not a slope, and the sawtooth is what you want to see. If you raise &lt;code&gt;MAX_RENDERS_PER_BROWSER&lt;/code&gt; a long way, expect the slope instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;RENDER_TIMEOUT_MS&lt;/code&gt; is enforced by killing the browser, not by asking it to stop.&lt;/strong&gt; A page that blocks its own main thread also blocks &lt;code&gt;page.close()&lt;/code&gt;, so the polite way out hangs as well. Pages that do this are uncommon but real, and without a hard ceiling one of them takes a pool slot with it.&lt;/p&gt;

&lt;p&gt;A good starting point is 2 GB of RAM and a pool of 2. After that, change one number at a time and watch memory while you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: lock it down
&lt;/h2&gt;

&lt;p&gt;With no configuration, the API runs in &lt;strong&gt;open mode&lt;/strong&gt;: anyone who can reach port 3000 can render anything. That's fine on your laptop, and a bad idea anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set access keys.&lt;/strong&gt; They're comma-separated, and clients send one as an &lt;code&gt;X-Access-Key&lt;/code&gt; header or as &lt;code&gt;?access_key=&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;ACCESS_KEYS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;key-for-app-one,key-for-app-two&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Sign URLs that appear in public HTML.&lt;/strong&gt; The main reason to return raw image bytes is so you can write &lt;code&gt;&amp;lt;img src="https://screenshots.example.com/take?url=..."&amp;gt;&lt;/code&gt;. But a key in that URL can be read by anyone who views the page source, and then they can use your server. Signed URLs solve this: set &lt;code&gt;SIGNING_SECRET&lt;/code&gt;, and the server accepts a request carrying an HMAC-SHA256 of its own sorted query parameters. Changing any parameter, including the target URL, breaks the signature.&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;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node: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;function&lt;/span&gt; &lt;span class="nf"&gt;signedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&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;k&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="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;&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;signature&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;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;query&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="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&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;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;signature=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signature&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="nf"&gt;signedUrl&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://screenshots.example.com/take&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;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://example.com&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo also has a command-line version: &lt;code&gt;node tools/sign.js "https://example.com" full_page=true&lt;/code&gt;. Once every URL you publish is signed, set &lt;code&gt;REQUIRE_SIGNATURE=true&lt;/code&gt; so unsigned requests are refused outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leave &lt;code&gt;ALLOW_PRIVATE_HOSTS&lt;/code&gt; off, unless you have a reason and have isolated the server.&lt;/strong&gt; By default the renderer refuses private and reserved addresses: localhost, the RFC 1918 ranges, and the cloud metadata address &lt;code&gt;169.254.169.254&lt;/code&gt;. It checks again after every redirect, so a public URL that redirects inward is refused too. Without that check, a screenshot API is a very convenient way to read your cloud credentials.&lt;/p&gt;

&lt;p&gt;If internal dashboards are your whole reason for self-hosting, that flag is what you need. But turning it on lets the renderer reach everything on its network. So it must not also be reachable from the internet, and it should sit on a network segment where "everything" means very little.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A known gap: DNS rebinding.&lt;/strong&gt; The renderer resolves the hostname to check it, and then Chrome resolves it again on its own. A DNS record that changes between those two lookups can get past the check. Literal private IPs are refused for every subresource, but a hostname that resolves to one is only checked at navigation. It's documented in &lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/SECURITY.md" rel="noopener noreferrer"&gt;SECURITY.md&lt;/a&gt; rather than ignored. It's also another reason to run the renderer where there's nothing worth reaching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: HTTPS with Caddy
&lt;/h2&gt;

&lt;p&gt;Don't expose the Node process directly. Put Caddy in front of it and it gets and renews certificates on its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;screenshots.example.com&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;reverse_proxy&lt;/span&gt; &lt;span class="nf"&gt;app&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="kn"&gt;transport&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;read_timeout&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;
            &lt;span class="s"&gt;write_timeout&lt;/span&gt; &lt;span class="s"&gt;120s&lt;/span&gt;
        &lt;span class="err"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details here matter more than they look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The long timeouts.&lt;/strong&gt; A slow page can legitimately take 30 seconds to render. If the proxy gives up first, the client sees a vague &lt;code&gt;502&lt;/code&gt; instead of the app's own clear &lt;code&gt;render_timeout&lt;/code&gt; error. The proxy's timeout should always be longer than the app's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;transport&lt;/code&gt; has to be nested inside &lt;code&gt;reverse_proxy&lt;/code&gt;.&lt;/strong&gt; Written as a sibling directive, Caddy refuses to start. Under &lt;code&gt;restart: unless-stopped&lt;/code&gt;, that becomes a restart loop with nothing on screen to tell you. Run &lt;code&gt;caddy validate&lt;/code&gt; before every restart.&lt;/p&gt;

&lt;p&gt;Caddy also needs your DNS records pointing at the server &lt;em&gt;before&lt;/em&gt; it first starts, because Let's Encrypt checks domain ownership by connecting back to the machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: know when it breaks
&lt;/h2&gt;

&lt;p&gt;There are two health endpoints, and they answer different questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/healthz&lt;/code&gt;&lt;/strong&gt; says the process is answering. It stays green while Chrome is broken, so on its own it will tell you everything is fine during an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/healthz/render&lt;/code&gt;&lt;/strong&gt; actually renders a page and returns 503 if that fails. So it doesn't become a free renderer, it does at most one real render a minute, and every other call gets the last result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point your uptime monitor at &lt;code&gt;/healthz/render&lt;/code&gt;. And run the monitor somewhere other than the server itself. A status check on the same box goes down with the box, and you only find out once the box is back.&lt;/p&gt;

&lt;p&gt;Every capture also carries response headers worth logging. &lt;code&gt;X-Render-Ms&lt;/code&gt; gives the render time. &lt;code&gt;X-Upstream-Status&lt;/code&gt; is the status code the target page returned. &lt;code&gt;X-Blank-Suspected&lt;/code&gt; is set when the capture looks blank, judged from both the page's DOM and how little the encoded image weighs per pixel. Pass &lt;code&gt;fail_on_blank=true&lt;/code&gt; to turn that into an error instead of an image.&lt;/p&gt;

&lt;p&gt;The 202-site benchmark I use for every change is in the repo under &lt;code&gt;bench/&lt;/code&gt;, along with the smoke tests. Run them after upgrading, then look at the images they write. A suite that counts a captcha as a successful capture is exactly the kind of failure it exists to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you don't get by self-hosting
&lt;/h2&gt;

&lt;p&gt;To be clear about what the repo is: it's the renderer, the API, the cache, and the optional accounts and billing code. What the hosted version adds is operational: a warm pool, a shared cache, updates, and someone else watching it. It doesn't have extra rendering features.&lt;/p&gt;

&lt;p&gt;Things neither version does, on purpose: video capture, proxy rotation, getting past captchas, and async jobs with webhooks. If a site shows a bot wall, you get a picture of the bot wall. If it's blank, the header tells you.&lt;/p&gt;

&lt;p&gt;It's licensed AGPL-3.0. You can run it, modify it, and self-host it freely. If you offer a modified version to other people over a network, you have to publish your changes. Calling the API from your own application doesn't make that application AGPL.&lt;/p&gt;

&lt;p&gt;If you run it and something doesn't work on a clean machine, please &lt;a href="https://github.com/MAMubeenKhan/screenshotline/issues" rel="noopener noreferrer"&gt;open an issue&lt;/a&gt;. The compose file working the first time is a promise, and a broken promise is a bug. If you'd rather not run it at all, the &lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;hosted version&lt;/a&gt; has 500 free renders a month and doesn't need a card.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>node</category>
      <category>selfhosted</category>
    </item>
    <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>
