<?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>Forem: Ohad Badihi</title>
    <description>The latest articles on Forem by Ohad Badihi (@rendershot).</description>
    <link>https://forem.com/rendershot</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901160%2Fb62d72eb-8d63-4de4-a6a3-9ea35a0e40eb.jpeg</url>
      <title>Forem: Ohad Badihi</title>
      <link>https://forem.com/rendershot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/rendershot"/>
    <language>en</language>
    <item>
      <title>Rendershot vs Urlbox: choosing a screenshot API in 2026</title>
      <dc:creator>Ohad Badihi</dc:creator>
      <pubDate>Mon, 04 May 2026 09:41:05 +0000</pubDate>
      <link>https://forem.com/rendershot/rendershot-vs-urlbox-choosing-a-screenshot-api-in-2026-3idn</link>
      <guid>https://forem.com/rendershot/rendershot-vs-urlbox-choosing-a-screenshot-api-in-2026-3idn</guid>
      <description>&lt;p&gt;Picking a screenshot API feels binary until you start integrating, then the edges show: one service has a Python SDK but no async queue, another has webhooks but charges extra for authenticated pages, a third makes you wire up an S3 bucket yourself. This post walks through how &lt;a href="https://rendershot.io" rel="noopener noreferrer"&gt;Rendershot&lt;/a&gt; and &lt;a href="https://urlbox.com" rel="noopener noreferrer"&gt;Urlbox&lt;/a&gt; compare across the dimensions that actually hurt to change later.&lt;/p&gt;

&lt;p&gt;Upfront: I build Rendershot, so treat this as a structured comparison with obvious bias, not an impartial review. I've tried to keep every claim about Urlbox pinned to their public docs and pricing page. If anything here drifts out of date, their docs are the source of truth.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick Urlbox&lt;/strong&gt; if you want an older, battle-tested product with a big feature surface and you're willing to pay a premium for polish. They've been shipping since ~2014.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick Rendershot&lt;/strong&gt; if you want transparent pay-as-you-go pricing, no-code distribution (Zapier, MCP), and AI-based cookie-banner cleanup baked in rather than sold as an add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both can render screenshots and PDFs, expose a REST API, and return files via URL or inline bytes. Below is where they diverge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing model
&lt;/h2&gt;

&lt;p&gt;Urlbox prices on &lt;strong&gt;renders per month&lt;/strong&gt; with tiered plans. Overages push you into the next tier. The starter plan (at time of writing) sits in the low double digits; teams with bursty traffic end up paying for headroom they rarely use.&lt;/p&gt;

&lt;p&gt;Rendershot prices on &lt;strong&gt;credits&lt;/strong&gt; — one credit per render, buy what you use. Unused credits roll forward. The free tier includes 200 renders per month with no card required, which is enough to prototype an entire Zap end-to-end before committing.&lt;/p&gt;

&lt;p&gt;Rough mental model: if your traffic is predictable and high volume, Urlbox tiers work out fine. If your traffic is spiky or you're still figuring out product-market fit, pay-as-you-go avoids the "we hit the limit on a Tuesday" failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting to the first screenshot
&lt;/h2&gt;

&lt;p&gt;Urlbox's signup → API key → first request flow takes a few minutes, plus you authenticate requests by signing URLs with HMAC on your side (their templates help, but it's still code to write).&lt;/p&gt;

&lt;p&gt;Rendershot hands you an &lt;code&gt;sk_live_…&lt;/code&gt; key and this curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.rendershot.io/v1/screenshot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-API-Key: sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url":"https://example.com","async":true}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No URL signing, no HMAC — just a header. If you're prototyping, this is a few hundred ms faster per round-trip in "does it work" land. For production, URL signing has security benefits; both approaches are fine, just different.&lt;/p&gt;

&lt;h2&gt;
  
  
  SDK coverage
&lt;/h2&gt;

&lt;p&gt;Both offer Python and Node.js SDKs. Rendershot additionally has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP server&lt;/strong&gt; (&lt;code&gt;@rendershot/mcp-server&lt;/code&gt;) for Claude, Cursor, Windsurf, and other MCP-compatible AI agents. You can ask an agent to "screenshot this URL" and it'll route through Rendershot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zapier app&lt;/strong&gt; (public beta), with a &lt;code&gt;capture_screenshot&lt;/code&gt; action, a &lt;code&gt;capture_pdf&lt;/code&gt; action, and a &lt;code&gt;new_render&lt;/code&gt; trigger that fires when an async render finishes — with a 24-hour presigned file URL attached, so downstream Gmail / Dropbox / Slack steps can fetch the file without an API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Urlbox has a Zapier integration too — worth comparing the action list to see which fits your workflow better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authenticated pages
&lt;/h2&gt;

&lt;p&gt;Screenshotting pages behind a login is the feature that most often determines which API sticks. Both services support it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Urlbox supports cookie / header injection and has a "sessions" concept to reuse authentication across calls.&lt;/li&gt;
&lt;li&gt;Rendershot supports &lt;a href="https://rendershot.io/docs/authenticated-pages" rel="noopener noreferrer"&gt;authenticated pages&lt;/a&gt; via per-request auth params (cookies, headers, storage state), with no separate session storage to manage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need to re-use the same authenticated browser context across many calls in a short window, Urlbox's sessions are easier. If you'd rather send auth context per-request and keep your API stateless, Rendershot matches that shape directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI cleanup
&lt;/h2&gt;

&lt;p&gt;Cookie banners and newsletter popups wreck screenshots taken for marketing/reporting purposes. Both services offer ways to block them — Urlbox has selector-based hiding, Rendershot has an &lt;code&gt;ai_cleanup&lt;/code&gt; flag (&lt;code&gt;fast&lt;/code&gt; / &lt;code&gt;thorough&lt;/code&gt;) that removes them semantically without you writing selectors.&lt;/p&gt;

&lt;p&gt;The AI approach is the real differentiator here: it handles sites you haven't seen before, GDPR-compliant sites in different jurisdictions, and redesigns that would break your hard-coded selectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async / queue model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Urlbox returns screenshots synchronously by default and supports polling for large renders.&lt;/li&gt;
&lt;li&gt;Rendershot supports &lt;a href="https://rendershot.io/docs/async" rel="noopener noreferrer"&gt;both modes&lt;/a&gt;: set &lt;code&gt;async: true&lt;/code&gt; to get back a job ID immediately, poll &lt;code&gt;/v1/jobs/&amp;lt;id&amp;gt;&lt;/code&gt; for status, or subscribe a webhook to be notified when the render finishes. The webhook payload includes a 24-hour presigned file URL — crucial for no-code pipelines where downstream steps can't authenticate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your workload is mostly fast single renders, sync is simpler. If you render long-animated pages or bulk-render thousands of URLs, async + webhooks will save you retries and timeouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Output storage
&lt;/h2&gt;

&lt;p&gt;Urlbox can return the file directly or upload to your S3 bucket — you bring the storage. Rendershot stores rendered files for 24 hours on Hetzner Object Storage and returns a presigned URL; after 24 hours the file is deleted. You don't need to configure anything.&lt;/p&gt;

&lt;p&gt;If compliance requires files stored in your own buckets, Urlbox's BYO-S3 model wins. If you want zero storage configuration and 24h retention is fine, Rendershot's model wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to pick which
&lt;/h2&gt;

&lt;p&gt;Pick &lt;strong&gt;Urlbox&lt;/strong&gt; if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want a long-lived product with a broad feature surface.&lt;/li&gt;
&lt;li&gt;You need browser-session reuse for authenticated multi-page flows.&lt;/li&gt;
&lt;li&gt;You need renders stored in your own S3 bucket for compliance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick &lt;strong&gt;Rendershot&lt;/strong&gt; if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You value transparent pricing and a generous free tier.&lt;/li&gt;
&lt;li&gt;You want a Zapier / MCP / webhook-native integration story.&lt;/li&gt;
&lt;li&gt;You want AI-based cookie-banner cleanup rather than selector lists.&lt;/li&gt;
&lt;li&gt;You'd rather start with &lt;code&gt;curl&lt;/code&gt; in 60 seconds and pay for what you use.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Try Rendershot for free:&lt;/strong&gt; create an API key at &lt;a href="https://rendershot.io/register" rel="noopener noreferrer"&gt;rendershot.io/register&lt;/a&gt;. 200 renders / month on the free plan, no card required. If you ship something with it, I'd love to see it — &lt;code&gt;support@rendershot.io&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>saas</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Headless Chromium at scale: four fixes for a fleet that kept eating RAM</title>
      <dc:creator>Ohad Badihi</dc:creator>
      <pubDate>Thu, 30 Apr 2026 07:05:34 +0000</pubDate>
      <link>https://forem.com/rendershot/headless-chromium-at-scale-four-fixes-for-a-fleet-that-kept-eating-ram-1mdp</link>
      <guid>https://forem.com/rendershot/headless-chromium-at-scale-four-fixes-for-a-fleet-that-kept-eating-ram-1mdp</guid>
      <description>&lt;p&gt;The first time a worker died with an OOM kill in the middle of a render, I assumed it was a bad page — some site with an infinite-scroll loop or a 200MB hero video. The second time it happened, on a different worker rendering a different URL, I started paying attention. The third time, a Tuesday morning, every worker in the fleet went down inside a five-minute window.&lt;/p&gt;

&lt;p&gt;Headless Chromium leaks memory. Not in a "oh that's a bug, file an issue" way — in a "this is the operating reality of a 30-million-line C++ browser, and you have to plan around it" way. If you run Playwright or Puppeteer in production for more than a few minutes per request, you will eventually meet this reality. This post is the four things I changed in &lt;a href="https://rendershot.io" rel="noopener noreferrer"&gt;Rendershot&lt;/a&gt; — a screenshot and PDF API I run — that took us from "workers crashing twice a day" to "workers running for weeks without intervention."&lt;/p&gt;

&lt;p&gt;None of these are clever. They're the boring discipline of treating a browser like a long-lived process, not a function call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Each Rendershot worker is a Docker container running an &lt;a href="https://arq-docs.helpmanual.io/" rel="noopener noreferrer"&gt;ARQ&lt;/a&gt; (Redis-backed) job queue. Jobs come off the queue, get rendered with &lt;a href="https://playwright.dev" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt;, and the resulting bytes are uploaded and the file path written back to Postgres. Concurrency is bounded; the worker fleet scales horizontally — no shared state between workers, just one Chromium process each.&lt;/p&gt;

&lt;p&gt;That last part was the first fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1 — One browser per worker, not per request
&lt;/h2&gt;

&lt;p&gt;The naive way to run Playwright is the way the docs suggest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine for a script. It is &lt;em&gt;catastrophic&lt;/em&gt; for a server. Launching Chromium takes 300–600ms on a modern Linux box, allocates ~150MB of resident memory before you've even pointed it at a URL, and forks a small army of helper processes (renderer, GPU, network, utility). Tearing it down repeats most of that work.&lt;/p&gt;

&lt;p&gt;If your worker handles 10 renders per second, you are spending more time launching and killing browsers than you are rendering anything. And every leaked file descriptor, zombie subprocess, or partially-released shared memory segment compounds.&lt;/p&gt;

&lt;p&gt;The fix is to launch the browser &lt;em&gt;once per worker&lt;/em&gt;, on startup, and reuse it for every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;on_startup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;startup&lt;/span&gt;
    &lt;span class="n"&gt;on_shutdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shutdown&lt;/span&gt;
    &lt;span class="n"&gt;max_jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_max_pages&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;startup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BrowserPool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# launches one Chromium
&lt;/span&gt;    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each render now creates a &lt;em&gt;page&lt;/em&gt; (cheap, ~5ms), uses it, and closes it. The browser stays alive for the lifetime of the worker. Crash isolation is per-container — if a worker's browser dies, we lose that worker, not the fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — Cap concurrent pages with a semaphore (and match it to your job queue)
&lt;/h2&gt;

&lt;p&gt;A persistent browser will happily let you open 50 tabs. It will also happily eat 8GB of RAM doing it.&lt;/p&gt;

&lt;p&gt;You need a hard cap on how many pages render concurrently inside one browser. We use an &lt;code&gt;asyncio.Semaphore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclasses.dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BrowserPool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;max_pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;_semaphore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Semaphore&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_semaphore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_CHROMIUM_ARGS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_semaphore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
            &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-obvious part: &lt;strong&gt;the semaphore alone isn't enough&lt;/strong&gt;. Your job queue needs to match it. ARQ has a &lt;code&gt;max_jobs&lt;/code&gt; setting that controls how many tasks the worker pulls off Redis simultaneously. If &lt;code&gt;max_jobs &amp;gt; max_pages&lt;/code&gt;, jobs get pulled, hit the semaphore, and &lt;em&gt;wait&lt;/em&gt; — eating queue slots that another worker could be servicing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;max_jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_max_pages&lt;/span&gt;  &lt;span class="c1"&gt;# match the semaphore
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both numbers tied to the same setting. No oversubscription. The "right" number for both is a function of how much RAM your container has and how heavy your renders are; we tune ours per environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3 — Restart the browser on a schedule, not on failure
&lt;/h2&gt;

&lt;p&gt;This is the one that took us longest to accept.&lt;/p&gt;

&lt;p&gt;Chromium's memory growth is not linear. Most pages cause a small bump that gets mostly reclaimed when the page closes. Some pages — a video, a leaky JavaScript framework, a page with a couple thousand DOM nodes — cause a bump that &lt;em&gt;never&lt;/em&gt; gets reclaimed. Over hours and tens of thousands of renders, the resident set creeps. By hour 8 you're at 1.5GB. By hour 24 you're getting OOM-killed.&lt;/p&gt;

&lt;p&gt;You can chase the leaks. Profile, diff snapshots, file Chromium bugs. Some of these are real bugs that get fixed. Others are by design — V8's garbage collector is not optimised for long-running, multi-tenant browser fleets.&lt;/p&gt;

&lt;p&gt;Or you can preempt: every hour, kill the browser and start a fresh one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_restart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_restart&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restart_interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_last_restart&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restart_interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_launch_browser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We call this from an hourly ARQ cron. The lock prevents two coroutines racing into a restart; the double-check inside the lock handles the case where one already won. A restart costs us about 800ms of latency on whichever request is unlucky enough to land during the swap — we accept it as the price of not paging an engineer.&lt;/p&gt;

&lt;p&gt;If you can stomach a slightly more aggressive cadence (every 30 min, every 1000 renders), you can probably get away with a smaller container. We tuned to one hour because it's the sweet spot for our workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4 — A fresh &lt;code&gt;BrowserContext&lt;/code&gt; per render, and close everything in &lt;code&gt;finally&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You are not just running renders. You are running &lt;em&gt;other people's&lt;/em&gt; renders. Different tenants. Different cookies, different basic auth, different custom headers.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;BrowserContext&lt;/code&gt; is Playwright's isolation unit — its own cookies, storage, cache. If two tenants share a context, tenant A's session cookie can leak into tenant B's render. This is bad. You make a fresh context per render and you close it after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;context_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;viewport&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;viewport&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&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="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;context_kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;extra_http_headers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;basic_auth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;context_kwargs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http_credentials&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;basic_auth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;context_kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cookies&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_cookies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cookies&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the consumer side — &lt;em&gt;always&lt;/em&gt; in a &lt;code&gt;finally&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(...),&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;asyncio.wait_for&lt;/code&gt; is a hard cap on render time — without it, a page can hang on &lt;code&gt;networkidle&lt;/code&gt; indefinitely and tie up a semaphore slot. With it, we always close. Without it, a single slow page becomes a fleet outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Chromium launch flags that actually matter
&lt;/h2&gt;

&lt;p&gt;Most "performance flag" lists you'll find online are cargo-culted. Here's the short list that's been load-bearing for us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_CHROMIUM_ARGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--no-sandbox&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--disable-setuid-sandbox&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--disable-dev-shm-usage&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# use /tmp instead of /dev/shm
&lt;/span&gt;    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--disable-gpu&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--disable-extensions&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--disable-background-networking&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--mute-audio&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--hide-scrollbars&lt;/span&gt;&lt;span class="sh"&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;The most important one is &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;. By default Chromium uses &lt;code&gt;/dev/shm&lt;/code&gt; for shared memory between processes; in a container, &lt;code&gt;/dev/shm&lt;/code&gt; is typically tiny (64MB), and a busy renderer will OOM the moment it tries to allocate a large pixmap. Routing it to &lt;code&gt;/tmp&lt;/code&gt; (which is just regular disk-backed memory) trades a small amount of latency for not crashing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--no-sandbox&lt;/code&gt; and &lt;code&gt;--disable-setuid-sandbox&lt;/code&gt; are required if you're running as a non-root user in Docker without the right capabilities. They're a downgrade in defense-in-depth — if you're rendering URLs supplied by your own tenants you should weigh whether to instead grant the container the right caps. For our threat model (tenants render their own URLs, not ours), the tradeoff is acceptable.&lt;/p&gt;

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

&lt;p&gt;If I were starting again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap viewport size aggressively at the schema layer&lt;/strong&gt;, not in the renderer. We started lenient ("let people render at 4K!") and walked it back when one tenant's 8K full-page screenshot used 2GB of RSS for one render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track per-render memory, not just per-worker.&lt;/strong&gt; A page that allocates 800MB before crashing should be killed &lt;em&gt;and the tenant should see a clear error&lt;/em&gt;, not a generic 504. We added this later; should have been from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat browser restarts as a SLO, not a coincidence.&lt;/strong&gt; Once we started measuring "% of requests that landed during a restart," we could tune the cadence with data instead of hunches.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;There's nothing magical here. One browser per worker, semaphore-capped concurrency, scheduled restarts, fresh contexts. The discipline is in actually doing all four; skipping any one of them eventually crashes a worker.&lt;/p&gt;

&lt;p&gt;If you're running a screenshot API, a PDF generator, an HTML-to-image pipeline, or any other long-running headless-browser workload, the same pattern applies. If you'd rather not run any of this yourself, &lt;a href="https://rendershot.io" rel="noopener noreferrer"&gt;Rendershot&lt;/a&gt; is the API that comes out of the patterns above — free tier of 200 renders/month, no card required.&lt;/p&gt;

&lt;p&gt;If you're sizing up screenshot/PDF APIs, I also wrote a structured comparison: &lt;a href="https://dev.to/rendershot/rendershot-vs-urlbox-choosing-a-screenshot-api-in-2026-3idn"&gt;Rendershot vs Urlbox: choosing a screenshot API in 2026&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>playwright</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
