<?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: Ohad Badihi</title>
    <description>The latest articles on DEV Community by Ohad Badihi (@rendershot).</description>
    <link>https://dev.to/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>DEV Community: Ohad Badihi</title>
      <link>https://dev.to/rendershot</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rendershot"/>
    <language>en</language>
    <item>
      <title>Automate weekly pricing-page PDFs with Zapier + Rendershot</title>
      <dc:creator>Ohad Badihi</dc:creator>
      <pubDate>Wed, 20 May 2026 11:04:03 +0000</pubDate>
      <link>https://dev.to/rendershot/automate-weekly-pricing-page-pdfs-with-zapier-rendershot-11n6</link>
      <guid>https://dev.to/rendershot/automate-weekly-pricing-page-pdfs-with-zapier-rendershot-11n6</guid>
      <description>&lt;p&gt;Pricing pages change constantly and nobody notices. If you run a startup that competes on price, or a product team that needs a historical record for exec reviews, a weekly PDF archive of every page that matters is the kind of chore you definitely won't keep up manually.&lt;/p&gt;

&lt;p&gt;This post walks through a Zap that does it for you: every Monday at 9am, render your competitor's pricing page as a PDF and email it to the team. Takes ten minutes end-to-end. Zero ongoing maintenance.&lt;/p&gt;

&lt;p&gt;The two pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schedule by Zapier&lt;/strong&gt; — built-in trigger that fires on a cron.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendershot&lt;/strong&gt; — captures the URL, exposes the file to downstream steps via a presigned URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://rendershot.io" rel="noopener noreferrer"&gt;Rendershot&lt;/a&gt; account (free plan works — 200 renders/month).&lt;/li&gt;
&lt;li&gt;A Zapier account.&lt;/li&gt;
&lt;li&gt;The URL of the pricing page you want to archive. For this tutorial I'll use &lt;code&gt;https://competitor.example.com/pricing&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The Rendershot Zapier app is in public beta. Until it graduates to the directory, install it via the public invite link in Step 1 below — works identically to a directory install, including for existing Zaps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 1 — Add the Rendershot app to your Zapier account
&lt;/h2&gt;

&lt;p&gt;Click this invite link while signed in to Zapier:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zapier.com/developer/public-invite/240706/b0a58634fa38b75a421c9f0bf8826bd8/" rel="noopener noreferrer"&gt;&lt;strong&gt;Add Rendershot to Zapier →&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zapier adds the app to your account and the three building blocks — &lt;code&gt;Capture Screenshot&lt;/code&gt;, &lt;code&gt;Capture PDF&lt;/code&gt;, and the &lt;code&gt;New Render&lt;/code&gt; trigger — show up in the Zap editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Create a Rendershot API key
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://rendershot.io/dashboard/keys" rel="noopener noreferrer"&gt;rendershot.io/dashboard/keys&lt;/a&gt; and click &lt;strong&gt;Generate key&lt;/strong&gt;. Copy the value — it starts with &lt;code&gt;sk_live_…&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Your API key is the only secret you need for this Zap. Treat it like a password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3 — Build the Zap
&lt;/h2&gt;

&lt;p&gt;In the Zapier editor:&lt;/p&gt;

&lt;h3&gt;
  
  
  Trigger: Schedule by Zapier
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; Schedule by Zapier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event:&lt;/strong&gt; Every Week&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day of week:&lt;/strong&gt; Monday&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time:&lt;/strong&gt; 09:00&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test the trigger — Zapier produces a sample "pretty_date" value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Action 1: Rendershot — Capture PDF
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App:&lt;/strong&gt; Rendershot&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event:&lt;/strong&gt; Capture PDF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When asked to connect, paste your &lt;code&gt;sk_live_…&lt;/code&gt; key. Zapier verifies it against &lt;code&gt;GET /v1/ping&lt;/code&gt; (free — no credits consumed).&lt;/p&gt;

&lt;p&gt;Configure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URL:&lt;/strong&gt; &lt;code&gt;https://competitor.example.com/pricing&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paper size:&lt;/strong&gt; &lt;code&gt;A4&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orientation:&lt;/strong&gt; &lt;code&gt;Portrait&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Print background graphics:&lt;/strong&gt; on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for:&lt;/strong&gt; &lt;code&gt;Network idle (lazy-loaded content, slower)&lt;/code&gt; — pricing pages often lazy-load A/B test variants, Network idle gives the page time to settle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test the step. You'll get back a &lt;code&gt;job_id&lt;/code&gt;, &lt;code&gt;status: queued&lt;/code&gt;, and a &lt;code&gt;result_url&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Action 2: Rendershot — New Render (wait step)
&lt;/h3&gt;

&lt;p&gt;Here's the clever bit: Rendershot actions return a &lt;strong&gt;job ID&lt;/strong&gt; immediately, not the finished PDF. We need to wait until the render completes to attach the actual file to an email.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;new_render&lt;/code&gt; trigger fires when any async render in your account finishes. You can either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a &lt;strong&gt;second Zap&lt;/strong&gt; with &lt;code&gt;new_render&lt;/code&gt; as the trigger and Gmail as the action (decoupled; recommended).&lt;/li&gt;
&lt;li&gt;Use the Rendershot &lt;code&gt;new_render&lt;/code&gt; step with a job ID filter inside this Zap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For simplicity, use approach 1 — it scales better and keeps each Zap doing one thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Action 3: Gmail — Send Email (in the second Zap)
&lt;/h3&gt;

&lt;p&gt;Create a &lt;strong&gt;second Zap&lt;/strong&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trigger:&lt;/strong&gt; Rendershot → New Render&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; Gmail → Send Email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Gmail step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;To:&lt;/strong&gt; &lt;code&gt;team@example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subject:&lt;/strong&gt; &lt;code&gt;Competitor pricing snapshot — {{New Render Completed At}}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Body:&lt;/strong&gt; A short note plus the &lt;code&gt;{{File URL}}&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attachments:&lt;/strong&gt; map to &lt;code&gt;{{File URL (no auth required, 24h)}}&lt;/code&gt; — the 24-hour presigned URL Rendershot ships in every webhook payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; Do &lt;strong&gt;not&lt;/strong&gt; map Attachments to &lt;code&gt;{{Result URL}}&lt;/code&gt;. That URL requires an &lt;code&gt;X-API-Key&lt;/code&gt; header, which Gmail can't inject when fetching the file. You'll get a silent 401 and no attachment. Always use &lt;strong&gt;File URL&lt;/strong&gt; for downstream no-code steps.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4 — Publish both Zaps
&lt;/h2&gt;

&lt;p&gt;Turn them on. The first Zap fires every Monday at 9am, queues a render. A few seconds later Rendershot delivers a &lt;code&gt;job.completed&lt;/code&gt; webhook to Zapier, which fires the second Zap, which emails the team the PDF.&lt;/p&gt;

&lt;p&gt;Cost per week: &lt;strong&gt;1 Rendershot credit&lt;/strong&gt; + 1 Zapier task per step. On the free Rendershot plan that's 0.5% of your monthly budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;Once the two Zaps are running, the pattern generalises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Archive multiple competitors.&lt;/strong&gt; Put URLs in a Google Sheet, change the first Zap's trigger to Sheets → New Row, and the single Zap captures all of them as they're added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post to Slack instead of Gmail.&lt;/strong&gt; Swap the second Zap's action to Slack → Upload File. Same &lt;code&gt;File URL&lt;/code&gt; field, same auth-less fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload to Dropbox / Google Drive.&lt;/strong&gt; For long-term archiving. Both apps have "Upload File from URL" actions that work with the File URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff against last week's PDF.&lt;/strong&gt; Pipe both weeks through a diff tool — out of scope for this post but a natural next step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;The reason this Zap is reliable (vs. something you wire with a headless browser script on a cron) is the three pieces that Rendershot ships out of the box for no-code pipelines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Async rendering&lt;/strong&gt; — long pages don't time out mid-Zap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook trigger&lt;/strong&gt; — Zapier doesn't need to poll; it reacts to the completed event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presigned File URL&lt;/strong&gt; — downstream steps can fetch the file without injecting an API key, which Zapier can't do when attaching files to emails or uploading to cloud storage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is the most common foot-gun with screenshot/PDF APIs in no-code land. Rendershot's &lt;code&gt;new_render&lt;/code&gt; trigger hands you a 24-hour presigned URL on every completed render so attachments just work.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want to try?&lt;/strong&gt; The free Rendershot plan includes 200 renders / month — enough to run this Zap for ~4 years without paying. &lt;a href="https://rendershot.io/dashboard/keys" rel="noopener noreferrer"&gt;Grab an API key&lt;/a&gt;, then &lt;a href="https://zapier.com/developer/public-invite/240706/b0a58634fa38b75a421c9f0bf8826bd8/" rel="noopener noreferrer"&gt;add Rendershot to Zapier&lt;/a&gt;. More on the integration in the &lt;a href="https://rendershot.io/docs/zapier" rel="noopener noreferrer"&gt;Zapier setup guide&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>zapier</category>
      <category>nocode</category>
      <category>productivity</category>
    </item>
    <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://dev.to/rendershot/rendershot-vs-urlbox-choosing-a-screenshot-api-in-2026-3idn</link>
      <guid>https://dev.to/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://dev.to/rendershot/headless-chromium-at-scale-four-fixes-for-a-fleet-that-kept-eating-ram-1mdp</link>
      <guid>https://dev.to/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>
