<?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>A free tier with no login, that isn't a free proxy for everyone else</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/a-free-tier-with-no-login-that-isnt-a-free-proxy-for-everyone-else-48gb</link>
      <guid>https://dev.to/mamubeenkhan/a-free-tier-with-no-login-that-isnt-a-free-proxy-for-everyone-else-48gb</guid>
      <description>&lt;p&gt;&lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt;'s landing page has a demo that renders a real page with no signup, no key and no card. That was a deliberate decision: if you make people create an account to find out whether your product works, most of them simply don't.&lt;/p&gt;

&lt;p&gt;It is also an invitation to burn someone else's CPU. Here is what it takes to offer that and not become a free rendering service for every scraper on the internet, including the hole I shipped to production myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing being spent is not money
&lt;/h2&gt;

&lt;p&gt;The hosted box is a fixed monthly price and renders one page at a time. Free traffic therefore does not cost dollars. It costs &lt;strong&gt;slot time that a paying customer was about to need.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That reframing changes every limit. The question stops being "how much can I afford to give away?" and becomes "how much of the queue may people who aren't paying occupy, and who waits behind whom?" The queue answers the second part: paid requests jump ahead of free and demo ones. The limits below are for the first.&lt;/p&gt;

&lt;p&gt;None of this applies if you self-host. A self-hosted deployment has no free plan and never reaches this code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: signups per IP
&lt;/h2&gt;

&lt;p&gt;The tempting first limit is a per-IP rate limit on the demo. It is also the least important one.&lt;/p&gt;

&lt;p&gt;The real hole is account creation. A free key is worth 500 renders a month, and signup needs no card and no confirmation email, both deliberate and both good for conversion. So one script minting keys in a loop is worth vastly more free compute than anyone hammering the demo, which is limited to 5 renders a minute per IP.&lt;/p&gt;

&lt;p&gt;Three signups per IP per day. That's the limit that matters most, and it's the one people think of last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: renders per target domain
&lt;/h2&gt;

&lt;p&gt;This one isn't about protecting my server at all.&lt;/p&gt;

&lt;p&gt;If the free tier can be pointed at one third-party site repeatedly, then my server becomes the thing hammering that site. The consequence lands on my paying customers: my IP goes onto the bot-wall lists, and captures of that site start coming back as challenges for everyone, including the people who pay.&lt;/p&gt;

&lt;p&gt;So free renders are capped per &lt;strong&gt;target&lt;/strong&gt; domain, per minute. And here is where I got the number wrong first.&lt;/p&gt;

&lt;p&gt;I set it to 10 a minute, which felt cautious and reasonable. Then I noticed the landing page demo box is &lt;strong&gt;pre-filled with &lt;code&gt;https://example.com&lt;/code&gt;&lt;/strong&gt;. Every visitor who clicks "Try it" without editing the field renders the same domain. On a launch-day crowd they all share one bucket, and a limit meant to stop abuse would have rate-limited my own demo against itself, on the one day traffic actually arrives.&lt;/p&gt;

&lt;p&gt;It's 30 now. The lesson generalises: before setting any shared limit, look at what the most common input actually is. A default value in a form field is a traffic pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: a budget in seconds, not requests
&lt;/h2&gt;

&lt;p&gt;Per-IP limits fall over against anyone with a proxy list. So the backstop is a global budget shared by all free and demo traffic: a share of total pool time per rolling hour, currently 0.75, with any single free caller limited to 0.2 of that.&lt;/p&gt;

&lt;p&gt;Two details make it work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's measured in slot-seconds, not renders.&lt;/strong&gt; A render is not a unit of cost here. One that runs to the timeout costs roughly ten ordinary ones, and a free key sending deliberately slow pages is never billed and never touches its quota, because failures aren't charged. Counting requests would price that attack at zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't care who is asking.&lt;/strong&gt; That's the point of a global budget: proxy rotation doesn't help you, because the budget is shared by everyone who isn't paying.&lt;/p&gt;

&lt;p&gt;It's all in-process and resets on restart, which is fine. A restart forgives at most one window, and it keeps the database as the only state that has to survive a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.75 is generous on purpose
&lt;/h2&gt;

&lt;p&gt;Three quarters of the hour available to people who aren't paying looks careless until you remember the box costs the same whether it's busy or idle. Unused capacity is not saved money. It's wasted capacity.&lt;/p&gt;

&lt;p&gt;An earlier version used 50%, which sounded prudent. Played forward against a launch-day spike, it would have switched the demo off mid-surge to protect paying customers who did not exist yet, turning off the thing that creates customers in order to protect customers.&lt;/p&gt;

&lt;p&gt;The lever still exists for when it's needed: &lt;code&gt;FREE_SLOT_SHARE=0.3&lt;/code&gt; in the environment and a restart, or &lt;code&gt;0&lt;/code&gt; to turn free rendering off entirely. Decide limits by what they cost on the day you get traffic, not by what sounds responsible in a config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bypass I shipped
&lt;/h2&gt;

&lt;p&gt;The demo route is &lt;code&gt;/demo&lt;/code&gt;, and it carries its own fences: no key needed, 5 per IP per minute, hourly budget, never metered. The main route is &lt;code&gt;/take&lt;/code&gt;, which needs a key and meters usage.&lt;/p&gt;

&lt;p&gt;Internally, both funnelled into the same handler, and that handler decided whether the request was a demo by reading the option off the parsed request options, which for &lt;code&gt;/take&lt;/code&gt; came from &lt;strong&gt;the caller's own query string&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/take?url=https://example.com&amp;amp;demo=true

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

&lt;/div&gt;



&lt;p&gt;No key. No quota check. No metering. And none of the demo's own fences either, because those live on the &lt;code&gt;/demo&lt;/code&gt; route, not in the flag. I probed it against live production: &lt;code&gt;200&lt;/code&gt;, a real PNG, no quota headers. A complete billing bypass, publicly reachable.&lt;/p&gt;

&lt;p&gt;Three things about how it was found and fixed are worth carrying away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was found by accident.&lt;/strong&gt; I was wiring up the abuse limits above and noticed the flag had two sources. No security review found it, because nobody was looking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every test suite passed.&lt;/strong&gt; The smoke suite runs with billing &lt;em&gt;off&lt;/em&gt;, which is the right way to test rendering and the exact configuration in which this bug is invisible. There is now a separate suite that boots with billing &lt;strong&gt;on&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is a shape, not a patch.&lt;/strong&gt; The demo flag is read only from the route's own forced options, never from caller input. A privileged mode that can be requested is not a mode, it's a parameter.&lt;/p&gt;

&lt;p&gt;And one habit that isn't optional: after writing the regression tests, I put the bug back and confirmed they failed. A test you have never seen fail is not evidence of anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building a free tier
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit account creation before you rate-limit usage.&lt;/strong&gt; Minting keys is worth more than any single key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap by target as well as by caller&lt;/strong&gt;, if your product touches third parties on their behalf. Your reputation is a shared resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meter cost, not requests&lt;/strong&gt;, when a slow request costs ten times a fast one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a global backstop&lt;/strong&gt; that proxy rotation cannot route around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your own defaults&lt;/strong&gt; before choosing a number. A pre-filled form field concentrates traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with billing on.&lt;/strong&gt; The most dangerous bugs live in the configuration your tests don't use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The limits are in &lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/src/limits.js" rel="noopener noreferrer"&gt;&lt;code&gt;src/limits.js&lt;/code&gt;&lt;/a&gt;, with the reasoning in comments, and the free plan is 500 renders a month if you'd rather just use it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>node</category>
      <category>security</category>
      <category>startup</category>
    </item>
    <item>
      <title>A screenshot API is a request forgery engine you rent out on purpose</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/a-screenshot-api-is-a-request-forgery-engine-you-rent-out-on-purpose-cj0</link>
      <guid>https://dev.to/mamubeenkhan/a-screenshot-api-is-a-request-forgery-engine-you-rent-out-on-purpose-cj0</guid>
      <description>&lt;p&gt;A screenshot API takes a URL from a stranger, fetches it from inside your infrastructure, and hands the result back. That is server-side request forgery as a product. The only thing separating it from a vulnerability is the checking.&lt;/p&gt;

&lt;p&gt;If you self-host &lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt;, or build anything that fetches a URL someone else supplied (a webhook tester, a link preview, an importer, an RSS reader), this is the part worth getting right. Below is what the code actually does, including the gap it doesn't close.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious attack, and why one check isn't enough
&lt;/h2&gt;

&lt;p&gt;The first thing anyone tries is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/take?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/

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

&lt;/div&gt;



&lt;p&gt;That address is the cloud metadata service. On many providers it answers to anything running on the instance, with no authentication, and it hands out credentials. A screenshot of it is a screenshot of your keys.&lt;/p&gt;

&lt;p&gt;So private and reserved ranges are refused: loopback, &lt;code&gt;10/8&lt;/code&gt;, &lt;code&gt;172.16/12&lt;/code&gt;, &lt;code&gt;192.168/16&lt;/code&gt;, link-local &lt;code&gt;169.254/16&lt;/code&gt; where metadata lives, and the IPv6 equivalents. Hostnames like &lt;code&gt;localhost&lt;/code&gt;, anything ending &lt;code&gt;.localhost&lt;/code&gt; or &lt;code&gt;.internal&lt;/code&gt;, URLs with embedded credentials, and any scheme that isn't http or https are refused too, because a page has no business fetching &lt;code&gt;file://&lt;/code&gt; through us.&lt;/p&gt;

&lt;p&gt;But a single check at submit time is not enough, for three reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirects.&lt;/strong&gt; &lt;code&gt;https://totally-fine.example/&lt;/code&gt; can answer &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;http://169.254.169.254/&lt;/code&gt;. So the check runs again on &lt;strong&gt;every redirect hop&lt;/strong&gt;, not just the submitted URL. A short-lived DNS cache keeps a redirect chain from triggering a lookup per hop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subresources.&lt;/strong&gt; The page itself can ask for anything: an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, a &lt;code&gt;fetch()&lt;/code&gt;, an iframe. So there is a second, synchronous check on the request interception path that refuses literal private addresses for every subresource a page pulls. It does no DNS on purpose, because a lookup per subresource would be a performance disaster on an image-heavy page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Names that resolve inward.&lt;/strong&gt; A hostname is resolved, and &lt;strong&gt;every&lt;/strong&gt; address it returns is checked, not just the first. A host with both a public A record and a private one isn't a pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: one address, two spellings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://[::ffff:10.0.0.1]/

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

&lt;/div&gt;



&lt;p&gt;That's an IPv4-mapped IPv6 address: a way of writing &lt;code&gt;10.0.0.1&lt;/code&gt; in IPv6 form. My private-range check for IPv6 matched that dotted spelling and refused it.&lt;/p&gt;

&lt;p&gt;Except the URL never arrives in that spelling. The WHATWG URL parser, which is what &lt;code&gt;new URL()&lt;/code&gt; uses in Node and in every browser, &lt;strong&gt;rewrites it to &lt;code&gt;::ffff:a00:1&lt;/code&gt;&lt;/strong&gt;, the same address in hex. My check matched the human spelling and never saw the hex one, so &lt;code&gt;http://[::ffff:a00:1]/&lt;/code&gt; sailed through the guard and routed to &lt;code&gt;10.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to match both:&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;dotted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&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;/^::ffff:&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\d&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="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;dotted&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;isPrivateV4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dotted&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&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;/^::ffff:&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{1,4})&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{1,4})&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;hex&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;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;16&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;isPrivateV4&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;hi&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&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;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mi"&gt;255&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;&lt;strong&gt;Validate the normalised form, not the form you expect to receive.&lt;/strong&gt; Anything between the attacker and your check, whether a URL parser, a proxy or a framework, may rewrite the input. If your test suite only ever feeds it the spelling a human would type, it tests the wrong string.&lt;/p&gt;

&lt;p&gt;The related default matters too. The address-parsing function ends:&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;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not an IP we understand - refuse rather than guess&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;An address form the checker doesn't recognise is treated as private. Refusing a few exotic-but-legitimate addresses is a support ticket. Allowing one unparsed address is an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap I have not closed: DNS rebinding
&lt;/h2&gt;

&lt;p&gt;Being honest about this is better than quietly hoping nobody notices.&lt;/p&gt;

&lt;p&gt;The renderer resolves the hostname to check it, and then Chrome resolves the hostname again when it fetches. Two lookups. A DNS record engineered to change between them, a very short TTL with the public address first and the private address second, can get past the check.&lt;/p&gt;

&lt;p&gt;Literal private IPs are still refused for every subresource, so the attack needs a hostname rather than an address. But a hostname that resolves into a private range is only checked at navigation time.&lt;/p&gt;

&lt;p&gt;Closing it properly means pinning the resolved address and forcing Chrome to use that exact address, via &lt;code&gt;--host-resolver-rules&lt;/code&gt;. It is in&lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/SECURITY.md" rel="noopener noreferrer"&gt;SECURITY.md&lt;/a&gt; rather than left unsaid, and the honest mitigation today is deployment shape: run the renderer where there is nothing worth reaching. No route to your database, your internal services, or your metadata endpoint. Then a rebinding win lands somewhere boring.&lt;/p&gt;

&lt;p&gt;That is also why &lt;code&gt;ALLOW_PRIVATE_HOSTS=true&lt;/code&gt;, the flag that turns all of this off, should only ever be on a box that is not reachable from the internet. If your reason for self-hosting is screenshotting internal dashboards, that is the flag, and network isolation becomes the whole of your security.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other direction: your key in public HTML
&lt;/h2&gt;

&lt;p&gt;The headline use case for returning image bytes directly is embedding a capture in a page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.screenshotline.com/take?url=https://example.com&amp;amp;access_key=..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The moment you do that, your key is in view-source for anyone who looks, and your quota belongs to them.&lt;/p&gt;

&lt;p&gt;Signed URLs fix it. Set a signing secret, and the server accepts a request carrying an HMAC-SHA256 over its own sorted query parameters:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;canonicalQuery&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="k"&gt;return&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;filter&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="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Sorting matters: it means the signature covers the parameters as a &lt;em&gt;set&lt;/em&gt;, not as the order someone happened to write them. Changing any of them, the target URL, the viewport or the format, invalidates it. The comparison is constant-time, and &lt;code&gt;REQUIRE_SIGNATURE=true&lt;/code&gt; refuses unsigned requests entirely, so a leaked key alone buys nothing.&lt;/p&gt;

&lt;p&gt;One detail that is easy to get wrong: the cache key deliberately &lt;strong&gt;excludes&lt;/strong&gt; &lt;code&gt;access_key&lt;/code&gt; and &lt;code&gt;signature&lt;/code&gt;. Two customers requesting the same page with the same options should share a cache entry, and if the credential were part of the key, it would also make cache behaviour depend on who asked, which is how cross-tenant cache bugs start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;If you fetch URLs that strangers choose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Refuse private and reserved ranges&lt;/strong&gt;, in every spelling, and refuse addresses you cannot parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check on every redirect hop&lt;/strong&gt;, not just the submitted URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check subresources too.&lt;/strong&gt; The page fetches things you didn't ask for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run it somewhere boring&lt;/strong&gt;, with no route to anything you care about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you haven't fixed.&lt;/strong&gt; Mine is DNS rebinding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the URL will be public, sign it&lt;/strong&gt; rather than putting a key in it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole guard is one file, &lt;a href="https://github.com/MAMubeenKhan/screenshotline/blob/main/src/security.js" rel="noopener noreferrer"&gt;&lt;code&gt;src/security.js&lt;/code&gt;&lt;/a&gt;, and the signing is in &lt;code&gt;src/auth.js&lt;/code&gt;. Both are short enough to read in a sitting.&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Running a paid screenshot API on one small server</title>
      <dc:creator>Mohammed Abdul Mubeen Khan</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/mamubeenkhan/running-a-paid-screenshot-api-on-one-small-server-3ie6</link>
      <guid>https://dev.to/mamubeenkhan/running-a-paid-screenshot-api-on-one-small-server-3ie6</guid>
      <description>&lt;p&gt;The hosted version of &lt;a href="https://screenshotline.com/?ref=blog" rel="noopener noreferrer"&gt;Screenshotline&lt;/a&gt; runs on one server. One core, 2 GB of RAM, a KVM slice that costs a few dollars a month, in a single region. There is no autoscaling group, no load balancer, no Kubernetes.&lt;/p&gt;

&lt;p&gt;That is a deliberate choice with real consequences, and this post is both halves: what a box that size genuinely does with headless Chrome, and what it cannot do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one render actually costs
&lt;/h2&gt;

&lt;p&gt;Measurements from the production image, &lt;code&gt;POOL_SIZE=1&lt;/code&gt;, an isolated container on loopback, 46 pages from the benchmark with default options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;9.44 slot-seconds per render&lt;/strong&gt;, mean. That's wall-clock occupancy of the one rendering slot, not CPU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;7.3 CPU-seconds per render.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;77% of the core busy&lt;/strong&gt; while a single render is in flight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peak memory 1,228 MB&lt;/strong&gt; of the ~1,400 MB available to the container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three things fall out of those numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome costs real CPU, but not much money.&lt;/strong&gt; At full utilisation, the compute behind a thousand renders costs a couple of cents. The fee my payment processor takes on a single small subscription is worth more than the compute for a great many renders. When people assume a screenshot API is expensive to run, they are usually thinking of egress or GPU time, neither of which is in this path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One render uses most of one core.&lt;/strong&gt; So &lt;code&gt;POOL_SIZE&lt;/code&gt; is not a throughput dial you turn up for free: a second browser on a single-core box does not double throughput, it splits the core and makes both renders slower while doubling the memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory is the wall, not CPU.&lt;/strong&gt; Peak was 1,228 MB against a 1,400 MB ceiling. That is the number that decides whether this box can run two browsers, and it says no.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue, not a scale-up
&lt;/h2&gt;

&lt;p&gt;With one slot, the interesting question is who gets it next. The answer is explicit: &lt;strong&gt;paid requests jump the queue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every render acquires the slot through a small pool that sorts waiters by priority, and free and demo traffic sits behind paying customers. On a fixed price box, that ordering is what protects the thing that pays for the box.&lt;/p&gt;

&lt;p&gt;Two more fences sit behind it, and both are about &lt;em&gt;capacity&lt;/em&gt;, not money:&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;freeSlotShare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// share of pool time free + demo traffic may use, rolling hour&lt;/span&gt;
&lt;span class="nx"&gt;freeCallerShare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// share of THAT budget any one free caller may take&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;freeSlotShare&lt;/code&gt; at 0.75 on a one-slot box means roughly 45 minutes of rendering per hour is available to people who are not paying. That is deliberately generous. The box costs the same whether it is busy or idle, so unused capacity is not saved money, it is wasted capacity. The paid-first queue already protects customers minute to minute; this is a backstop against a sustained flood, and an emergency lever: set &lt;code&gt;FREE_SLOT_SHARE=0.3&lt;/code&gt; and restart, or &lt;code&gt;0&lt;/code&gt; to turn free rendering off entirely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;freeCallerShare&lt;/code&gt; exists because of a specific hole. A free key sending pages that run to the 30-second timeout is never billed and never touches its quota, because failures aren't charged. Without a per-caller cap, one key could spend the entire free tier's hour and lock every other free user out, at no cost to itself.&lt;/p&gt;

&lt;p&gt;There's a small implementation detail in that config worth stealing:&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;share&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;d&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&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="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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Number('')&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, so an empty &lt;code&gt;FREE_SLOT_SHARE=&lt;/code&gt; line in an env file would otherwise switch the whole free tier off silently. Parsing it by hand and falling back to the default turns a silent outage into a no-op.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set limits by what they cost on the day, not by what sounds prudent
&lt;/h2&gt;

&lt;p&gt;An earlier version of the free budget was a 50% rolling share. It sounded responsible. Played forward against a launch-day traffic spike, it would have switched the public demo off mid-surge to protect paying customers who did not exist yet. The demo, with no signup and no key, is the thing that convinces people the product works, and it would have gone dark exactly when it mattered.&lt;/p&gt;

&lt;p&gt;The per-domain limit taught the same lesson from the other direction. I set free renders of any one domain to 10 a minute, which felt reasonable, then noticed the landing page demo is &lt;strong&gt;pre-filled with example.com&lt;/strong&gt;. Every visitor who clicks "Try it" without editing the box renders the same domain, so they all share one bucket. Ten a minute would have rate-limited the demo against itself on the busiest day of the year. It is 30 now.&lt;/p&gt;

&lt;p&gt;Before setting any shared limit, check what the most common input actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when the box is unwell
&lt;/h2&gt;

&lt;p&gt;Two health endpoints, because 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 alone it will cheerfully report that all is well during an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/healthz/render&lt;/code&gt;&lt;/strong&gt; renders a real page and returns 503 when that fails. It is fenced so it can't become a free renderer or a load lever: at most one real render a minute, everyone else gets the cached last result, queued at low priority, never metered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The uptime monitor points at the second one, and it runs on someone else's infrastructure, not this box. A status page on the same server dies with the server and tells you everything is fine.&lt;/p&gt;

&lt;p&gt;Backups go off the box too, for the same reason. A VPS is one machine. The SQLite database with accounts, key hashes and usage counters is the only state that cannot be regenerated, and a snapshot that lives only on the machine it is protecting is not a backup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this setup cannot do
&lt;/h2&gt;

&lt;p&gt;I would rather write these down than have someone discover them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One region.&lt;/strong&gt; Latency from the other side of the world includes the trip. The hosted p50 is about 7.1s, against 5.7s on my benchmark laptop, and part of that gap is simply where the box is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deploy is a blip.&lt;/strong&gt; Rebuilding and restarting the container takes about 30 to 45 seconds, and the status page shows it. No rolling deploy, because there is nothing to roll.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The box is a single point of failure.&lt;/strong&gt; If it dies, everything is down until I move it. There is no SLA, and I don't pretend otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput has a ceiling&lt;/strong&gt; and it is not large. Sustained heavy traffic means a bigger box, then a second one, and the queue is what keeps paying customers first while that happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is advice to run your production on one small server. It is what an honest version of "we're small" looks like: measure what the machine does, decide explicitly who waits behind whom, and publish the limits rather than discovering them in public.&lt;/p&gt;

&lt;p&gt;If you'd rather own the box yourself, 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;, and the sizing numbers above are the ones to start from.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>node</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <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>
