<?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: Zakaria Tebani</title>
    <description>The latest articles on DEV Community by Zakaria Tebani (@frameforge).</description>
    <link>https://dev.to/frameforge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4239766%2F06090605-7374-4721-b9c2-c51a117901fe.png</url>
      <title>DEV Community: Zakaria Tebani</title>
      <link>https://dev.to/frameforge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/frameforge"/>
    <language>en</language>
    <item>
      <title>I got tired of babysitting Playwright, so I turned it into a small render API</title>
      <dc:creator>Zakaria Tebani</dc:creator>
      <pubDate>Wed, 11 Nov 2026 19:45:08 +0000</pubDate>
      <link>https://dev.to/frameforge/i-got-tired-of-babysitting-playwright-so-i-turned-it-into-a-small-render-api-1da6</link>
      <guid>https://dev.to/frameforge/i-got-tired-of-babysitting-playwright-so-i-turned-it-into-a-small-render-api-1da6</guid>
      <description>&lt;p&gt;Every small product I ship ends up needing one of two things: an Open Graph image, or a PDF of some HTML I already have.&lt;/p&gt;

&lt;p&gt;Self-hosting Playwright or Puppeteer &lt;em&gt;works&lt;/em&gt;. It also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creeps up in memory until the box runs out&lt;/li&gt;
&lt;li&gt;leaves Chrome hanging when a page never finishes loading&lt;/li&gt;
&lt;li&gt;captures a GDPR banner over the thing you actually wanted&lt;/li&gt;
&lt;li&gt;turns a weekend feature into an ops job&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I put it behind one endpoint, and it's now live as &lt;strong&gt;FrameForge&lt;/strong&gt;: HTML or a URL in, PNG, JPEG or PDF out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -X POST https://95-217-220-34.sslip.io/render \
  -H &lt;span class="s2"&gt;"X-API-Key: $KEY"&lt;/span&gt; \
  -H &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; \
  -d &lt;span class="s1"&gt;'{"html":"&amp;lt;!doctype html&amp;gt;&amp;lt;h1&amp;gt;Invoice 1042&amp;lt;/h1&amp;gt;","format":"pdf"}'&lt;/span&gt; \
  -o invoice.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;html&lt;/code&gt; for &lt;code&gt;url&lt;/code&gt;, or &lt;code&gt;pdf&lt;/code&gt; for &lt;code&gt;png&lt;/code&gt; / &lt;code&gt;jpeg&lt;/code&gt;. &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; go up to 3840 and &lt;code&gt;fullPage&lt;/code&gt; captures the whole page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design choices I made on purpose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One browser, a new context per request.&lt;/strong&gt; Sharing tabs is how cookies and memory leaks spread between jobs. Each render gets a clean context that is closed afterwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard timeouts.&lt;/strong&gt; 20 seconds to navigate, 30 for the whole render. A hung page must not hold one of only two slots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap concurrency at 2 on a 2 vCPU / 4 GB box.&lt;/strong&gt; Chromium contexts aren't free. A queue of 8 is better than an OOM kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache by hash.&lt;/strong&gt; The request body is SHA-256 hashed. An identical request is served from disk in well under a second, which makes retries fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide banners with CSS rather than clicking "Accept".&lt;/strong&gt; Clicking consent on other people's sites is messy and fragile. Hiding the common OneTrust / Cookiebot / Quantcast / generic cookie nodes covers most cases. It's best effort, and shadow-DOM banners still get through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every URL as hostile.&lt;/strong&gt; Private and link-local addresses are refused, and the check runs again on every redirect and every subresource the page loads, so the renderer can't be used to probe internal networks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it runs on
&lt;/h2&gt;

&lt;p&gt;Node, Playwright 1.56, Docker and Caddy for HTTPS on a single Hetzner CPX22 in Helsinki. The box costs about €17 a month, so one Starter customer pays for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free trial:&lt;/strong&gt; 100 renders over 14 days. Email &lt;a href="mailto:service+frameforge@rightemp.co.uk"&gt;service+frameforge@rightemp.co.uk&lt;/a&gt; and I'll send you a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter&lt;/strong&gt; £9/month for 3,000 renders, &lt;strong&gt;Studio&lt;/strong&gt; £29/month for 20,000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founding Member:&lt;/strong&gt; £49 once for 12 months of Studio, limited to the first 100 people.&lt;/li&gt;
&lt;li&gt;30-day refund on all of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Details and checkout: &lt;a href="https://frameforge-efg.pages.dev" rel="noopener noreferrer"&gt;https://frameforge-efg.pages.dev&lt;/a&gt; (disclosure: this is my project).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm still unsure about
&lt;/h2&gt;

&lt;p&gt;Is a 100-render trial enough, or does an API like this need a permanent free tier? And is banner hiding a must-have or a nice-to-have for you?&lt;/p&gt;

&lt;p&gt;If you've run Chrome in production, I'd like to hear what broke first.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>node</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
