<?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: Grabbit</title>
    <description>The latest articles on DEV Community by Grabbit (grabbit).</description>
    <link>https://dev.to/grabbit</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%2Forganization%2Fprofile_image%2F13692%2F644a5de6-bf00-4ad6-907f-873e90cb3bd7.png</url>
      <title>DEV Community: Grabbit</title>
      <link>https://dev.to/grabbit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grabbit"/>
    <language>en</language>
    <item>
      <title>How to Capture a Webpage Screenshot Programmatically (URL to Image via API)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:43:30 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-capture-a-webpage-screenshot-programmatically-url-to-image-via-api-5h8i</link>
      <guid>https://dev.to/grabbit/how-to-capture-a-webpage-screenshot-programmatically-url-to-image-via-api-5h8i</guid>
      <description>&lt;p&gt;To capture a webpage screenshot from your code, send the page URL to a screenshot API and it returns a hosted image. One HTTP request in, one image URL out. The headless browser that renders the page runs in the cloud, so there is no Chromium to install and no browser process to keep alive.&lt;/p&gt;

&lt;p&gt;That is the fast path. Below are the ways to capture a webpage, when each one fits, and how to get the full-page, format, and timing options right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick answer
&lt;/h2&gt;

&lt;p&gt;If you just want a URL captured as an image from your code, this is the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "height": 720,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is JSON with a hosted &lt;code&gt;image_url&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grb_01jx..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;62140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"execution_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;940&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-27T09:00:00.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that &lt;code&gt;image_url&lt;/code&gt;, embed it, or hand it to another service. The rest of this guide is about picking the right method and getting the capture right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual versus programmatic capture
&lt;/h2&gt;

&lt;p&gt;"Capture a webpage screenshot" splits into two different jobs, and the right tool depends on which one you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual, one page.&lt;/strong&gt; You are looking at a page and want to save it. Your browser already does this. In Chrome or Edge, open the command menu with Ctrl+Shift+P (Cmd+Shift+P on Mac), type "screenshot," and choose "Capture full size screenshot" for the whole page. Edge has a dedicated shortcut: Ctrl+Shift+S, then "Capture full page." An extension like GoFullPage does the same through a toolbar button. For one page you are looking at right now, use one of these and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Programmatic, repeatable.&lt;/strong&gt; You need to capture pages from code, on a schedule, or across many URLs. The manual path falls apart here: you cannot click a browser menu inside a cron job, a CI pipeline, or a webhook handler. You need an endpoint you can call. That is the rest of this guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing a webpage with an API
&lt;/h2&gt;

&lt;p&gt;For anything repeatable, a screenshot API is the shortest path. You already saw the curl call. Here is the same capture in a few languages, since "how do I do this in my stack" is the real question.&lt;/p&gt;

&lt;p&gt;Python, using &lt;code&gt;requests&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.grabbit.live/v1/grabs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;Authorization&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;Bearer sk_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;url&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;https://example.com&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&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;webp&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;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node.js, using the built-in &lt;code&gt;fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.grabbit.live/v1/grabs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer sk_live_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&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="na"&gt;height&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="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these is a plain HTTP call. No chromedriver, no 300 MB Chromium download, no async browser session to tear down. That is the whole reason to reach for an API over a local capture: the rendering machinery lives somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing the full page, not just the viewport
&lt;/h2&gt;

&lt;p&gt;By default a capture stops at the viewport height you request. To grab the entire scrollable page as one tall image, set &lt;code&gt;full_page&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "full_page": true,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;full_page&lt;/code&gt; on, &lt;code&gt;height&lt;/code&gt; is ignored and the render extends to the bottom of the document. This is the "scrolling screenshot" people ask for: everything below the fold, in one image. &lt;code&gt;width&lt;/code&gt; must be between 320 and 1920 pixels; &lt;code&gt;height&lt;/code&gt;, when you use it, between 240 and 1080.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling pages that load content late
&lt;/h2&gt;

&lt;p&gt;The reason a real browser beats a raw-HTML fetch is that it runs JavaScript. Single-page apps, lazy-loaded images, and content that appears a beat after load all render the way they do for a visitor, because the capture happens in headless Chromium, not an HTML parser.&lt;/p&gt;

&lt;p&gt;Two options control the timing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;delay_ms&lt;/code&gt; waits a fixed number of milliseconds (0 to 10000) after load before firing the capture. Use it when content animates or streams in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;selector&lt;/code&gt; waits for a specific element to appear and captures once it does. Use it when you know the exact node that signals "the page is ready."
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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/dashboard",
    "width": 1440,
    "height": 900,
    "delay_ms": 1500,
    "format": "png"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why an API can capture pages where a browser extension fails. A page can break extension-based capture with its layout, but a server that loads the URL like a normal visitor still gets a clean render of what the browser paints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing PNG, JPEG, or WebP
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;format&lt;/code&gt; field takes &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;, and picking wrong means either bloated files or lost quality.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebP&lt;/strong&gt; is the best default. For a typical webpage render it produces the smallest file at the same visual quality, which adds up fast when you store and serve thousands of captures. Use it unless you have a reason not to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG&lt;/strong&gt; is lossless and supports transparency. Reach for it when exact pixel fidelity matters, for example a baseline image in a &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;visual regression test&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG&lt;/strong&gt; is the safest bet for older tooling that predates WebP. It is lossy, so text edges soften slightly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switching format is a one-word change in the request body, so you can experiment without rewriting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a local capture is the better call
&lt;/h2&gt;

&lt;p&gt;An API is not always the answer. Capture locally instead when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are doing a single manual capture.&lt;/strong&gt; Use the browser shortcut from earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already have the HTML, not a URL.&lt;/strong&gt; If the markup lives in your app and is never served at an address, a local render avoids a round trip. See &lt;a href="https://www.grabbit.live/blog/html-to-image" rel="noopener noreferrer"&gt;HTML to image&lt;/a&gt; for that pattern, including hosting your template at a URL and capturing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot make outbound requests.&lt;/strong&gt; In an air-gapped environment an API is off the table, so a bundled headless browser is the only option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything else, especially anything on a schedule or in CI, the API path keeps your deployment small and removes a whole class of "it works locally but not in the container" failures.&lt;/p&gt;

&lt;p&gt;Pricing is worth a word here because it is where these tools differ most. Grabbit charges a flat $0.002 per live capture as prepaid credits that never reset or expire monthly, so a batch that runs once a quarter does not forfeit an unused monthly allowance. That is different from the metered monthly quotas most screenshot tools bill on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; covers the full parameter set, authentication, and rate limits. If you are wiring this into a job, the &lt;a href="https://www.grabbit.live/blog/screenshot-from-url" rel="noopener noreferrer"&gt;screenshot from a URL&lt;/a&gt; guide walks the request end to end, &lt;a href="https://www.grabbit.live/blog/screenshot-automation" rel="noopener noreferrer"&gt;screenshot automation&lt;/a&gt; covers running captures on a schedule without a browser, and &lt;a href="https://www.grabbit.live/blog/screenshot-list-of-urls" rel="noopener noreferrer"&gt;screenshot a list of URLs&lt;/a&gt; handles capturing many pages at once.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/webpage-screenshot-capture" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Add a Screenshot Tool to Your AI Agent with MCP</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Fri, 24 Jul 2026 11:41:38 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-add-a-screenshot-tool-to-your-ai-agent-with-mcp-fhc</link>
      <guid>https://dev.to/grabbit/how-to-add-a-screenshot-tool-to-your-ai-agent-with-mcp-fhc</guid>
      <description>&lt;p&gt;An AI agent that writes frontend code has no way to see the result. It edits CSS, ships a component, and moves on, blind to whether the page actually renders correctly. The fix is to give the agent a tool that turns a URL into an image it can look at. Model Context Protocol (MCP) is the standard that makes wiring that tool a one-line job.&lt;/p&gt;

&lt;p&gt;A screenshot MCP server exposes exactly one capability the agent was missing: send a URL, get back a rendered image. This walks through what a screenshot MCP server is, how to add a hosted one to Claude Code, Cursor, or any MCP host in a single command, and when a hosted server beats a self-hosted one that runs Chromium on your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a screenshot MCP server actually does
&lt;/h2&gt;

&lt;p&gt;MCP is a protocol for connecting AI hosts to external tools. A host (Claude Code, Cursor, Claude Desktop, an agent framework) speaks MCP to a server, discovers the tools that server offers, and calls them mid-conversation. The agent never needs to know how the tool works internally, only its name, description, and inputs.&lt;/p&gt;

&lt;p&gt;A screenshot MCP server offers a capture tool. The contract is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The agent calls the tool with a URL (and optional width, format, and a few knobs).&lt;/li&gt;
&lt;li&gt;The server renders the page in a real browser.&lt;/li&gt;
&lt;li&gt;The server returns a hosted image, which the host passes back to the model as an image block.&lt;/li&gt;
&lt;li&gt;The model looks at the pixels and reasons about them: spacing, overflow, a broken hero, a layout that only breaks at a real viewport.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole loop. What varies between servers is where the rendering happens. Some servers wrap a local Puppeteer or Playwright install and screenshot on your machine. A hosted server runs the browser on its own infrastructure, so there is nothing to install and nothing to keep alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add a hosted screenshot MCP server in one command
&lt;/h2&gt;

&lt;p&gt;Grabbit is a hosted screenshot MCP server at &lt;code&gt;mcp.grabbit.live&lt;/code&gt;, spoken over Streamable HTTP. In Claude Code, adding it is one command plus a browser sign-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add Grabbit's hosted MCP server (Streamable HTTP)&lt;/span&gt;
claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http grabbit https://mcp.grabbit.live

&lt;span class="c"&gt;# Authenticate in your browser (no API key to paste):&lt;/span&gt;
&lt;span class="c"&gt;#   /mcp   → select "grabbit" → Authenticate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That registers five tools with the host: &lt;code&gt;grab&lt;/code&gt; (screenshot a URL), &lt;code&gt;get_grab&lt;/code&gt; and &lt;code&gt;list_grabs&lt;/code&gt; (fetch or browse past captures), &lt;code&gt;get_usage&lt;/code&gt; (credits and plan), and &lt;code&gt;get_pricing&lt;/code&gt; (an honest, dated comparison against ScreenshotOne, Urlbox, Browserless, and others). The agent can now call &lt;code&gt;grab&lt;/code&gt; any time it needs to see a page.&lt;/p&gt;

&lt;p&gt;Prefer a key over the OAuth flow? Pass a Bearer token as a header instead. A free test key (&lt;code&gt;sk_test_...&lt;/code&gt;) returns placeholder renders at no cost, so you can prove the wiring before paying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Alternative: pass a key as a header instead of the OAuth flow.&lt;/span&gt;
claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http grabbit https://mcp.grabbit.live &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_test_..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same server works for any MCP host that speaks Streamable HTTP, not just Claude Code. Cursor, Claude Desktop, and agent frameworks point at the same &lt;code&gt;https://mcp.grabbit.live&lt;/code&gt; endpoint and discover the same tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The see, fix, verify loop
&lt;/h2&gt;

&lt;p&gt;The reason this matters shows up the moment an agent is doing visual work. Without a screenshot tool, a coding agent writes CSS and hopes. With one, it can close the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You, in Claude Code:
  "Screenshot https://my-feature.preview.vercel.app and check the
   hero spacing against the mockup."

Claude calls the grab tool, looks at the returned image, edits the
CSS, then screenshots again to confirm the fix.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent captures the page it just shipped, looks at the rendered pixels, fixes what is off, and screenshots again to confirm. This is the same pattern practitioners keep landing on independently: give the agent a real rendered reference instead of asking it to reason about a page it cannot see. It generalizes past frontend work to visual QA after a deploy, verifying an OG card before a post goes live, or feeding a live competitor page into a vision step.&lt;/p&gt;

&lt;p&gt;Because Grabbit captures public URLs and blocks localhost (SSRF protection is on by default), point the agent at a deployed preview or staging URL. That constraint is also what makes the loop work inside CI and remote runners: there is no local browser to drive, and the hosted render does not care.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to self-host and when to use a hosted server
&lt;/h2&gt;

&lt;p&gt;Search "screenshot MCP server" and most of the results are open-source repos that wrap Puppeteer or Playwright and run the capture locally. Those are a good fit when you want the browser on your own machine and are happy to maintain it. Be honest about what that maintenance is: a headless Chromium to install and patch, memory leaks to bound, fonts and locale to get right on Linux, and the same anti-bot walls a local browser hits on the open web.&lt;/p&gt;

&lt;p&gt;A hosted screenshot MCP server removes that surface entirely. There is no Chromium in your environment, the render runs on infrastructure that already handles cookie and consent banners and JavaScript-heavy pages, and it works in sandboxes and CI where a local browser would have nothing to drive. The tradeoff is that you pay per capture instead of paying in devops time.&lt;/p&gt;

&lt;p&gt;Grabbit's pricing is a flat $0.002 per live capture on the $50/year plan (25,000 captures), with prepaid credits that never reset or expire monthly, and test-environment captures are free. Browserless and Thum.io list a lower per-grab rate; Grabbit's edge is flat annual billing with no monthly-reset waste and the one-line MCP onboarding above. If your only need from a browser is a screenshot of a URL, a hosted server is the shorter path.&lt;/p&gt;

&lt;h2&gt;
  
  
  A screenshot server and a browser-automation server do different jobs
&lt;/h2&gt;

&lt;p&gt;A screenshot MCP server is not a replacement for a browser-driving one. &lt;a href="https://www.grabbit.live/blog/playwright-mcp" rel="noopener noreferrer"&gt;Playwright MCP&lt;/a&gt; gives an agent a real browser to click, type, and read the accessibility tree, which is what you want to walk through a login or a multi-step flow. A screenshot server has a narrower job: turn a URL into an image the agent can look at.&lt;/p&gt;

&lt;p&gt;They pair naturally. The accessibility tree tells the agent what is on the page; a rendered screenshot shows it what the page looks like: visual bugs, CSS regressions, whether the thing actually looks right. Many agents use both. For the deeper pattern of feeding rendered images into a vision model, see &lt;a href="https://www.grabbit.live/blog/screenshots-for-ai-agents" rel="noopener noreferrer"&gt;Screenshots for AI Agents&lt;/a&gt;. To call the same capture from your own code without MCP, &lt;a href="https://www.grabbit.live/blog/screenshot-from-url" rel="noopener noreferrer"&gt;How to Screenshot a Website from a URL&lt;/a&gt; covers the plain HTTP path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calling the same capture over plain HTTP
&lt;/h2&gt;

&lt;p&gt;MCP is the agent-native front door, but the same capture is one HTTP POST when you want it in a script, a cron job, or a framework tool you define by hand. This is what the &lt;code&gt;grab&lt;/code&gt; MCP tool calls under the hood:&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.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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://my-feature.preview.vercel.app",
    "width": 1280,
    "height": 720,
    "format": "webp",
    "full_page": true,
    "delay_ms": 500
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes a hosted &lt;code&gt;image_url&lt;/code&gt; you pass straight to a vision model as an image input. The knobs that matter most for agent work: &lt;code&gt;full_page&lt;/code&gt; captures the whole document, &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) waits for late-loading content to settle, &lt;code&gt;selector&lt;/code&gt; targets a single element by CSS selector, and &lt;code&gt;format: "webp"&lt;/code&gt; keeps images small when you are passing them into a model. Width accepts 320 to 1920, height 240 to 1080.&lt;/p&gt;

&lt;p&gt;Whether you wire it through MCP or call it directly, the point is the same: the agent stops working blind. It gets eyes on the web it is building.&lt;/p&gt;

&lt;p&gt;For the full API reference, see the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;Grabbit screenshot API&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/screenshot-mcp-server" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>startup</category>
      <category>saas</category>
    </item>
    <item>
      <title>How to Run Playwright in Docker (and the Browser-Deps Tax It Adds)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Thu, 23 Jul 2026 11:39:40 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-run-playwright-in-docker-and-the-browser-deps-tax-it-adds-275g</link>
      <guid>https://dev.to/grabbit/how-to-run-playwright-in-docker-and-the-browser-deps-tax-it-adds-275g</guid>
      <description>&lt;p&gt;Playwright in Docker is mostly a solved problem, and the solution is one line: use the official image. The reason people end up here anyway is the failure mode underneath it. Playwright drives a real browser, a real browser links against dozens of native shared libraries, and slim container base images do not ship them. Miss one and you get a launch error that names a &lt;code&gt;.so&lt;/code&gt; file instead of anything to do with your test.&lt;/p&gt;

&lt;p&gt;This guide covers the working setup, the errors that actually show up in CI, and the honest boundary where containerizing a browser stops being worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the official image
&lt;/h2&gt;

&lt;p&gt;Microsoft publishes an image with the browsers and their system dependencies preinstalled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mcr.microsoft.com/playwright:v1.61.1-noble
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the tag to the same Playwright version your project depends on. This is not a style preference. The npm package and the browser binaries are versioned together, and a mismatch produces a launch failure that reads like a corrupted install. If &lt;code&gt;package.json&lt;/code&gt; says &lt;code&gt;1.61.1&lt;/code&gt;, the image tag says &lt;code&gt;v1.61.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A working Dockerfile for a Node project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mcr.microsoft.com/playwright:v1.61.1-noble&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npx", "playwright", "test"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is missing: no &lt;code&gt;playwright install&lt;/code&gt;, no &lt;code&gt;install-deps&lt;/code&gt;, no &lt;code&gt;apt-get&lt;/code&gt; block. The image already has the browsers and the libraries. What it does not have is your &lt;code&gt;node_modules&lt;/code&gt;, which is why &lt;code&gt;npm ci&lt;/code&gt; still runs. That distinction, the browsers are in the image but the package is in your project, is the thing the Stack Overflow threads keep circling.&lt;/p&gt;

&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; my-tests &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--ipc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host my-tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--ipc=host&lt;/code&gt; flag is not optional in practice. Chromium uses shared memory heavily, Docker's default &lt;code&gt;/dev/shm&lt;/code&gt; is 64MB, and the result is a browser that starts fine and then dies partway through a run. If you cannot use &lt;code&gt;--ipc=host&lt;/code&gt;, use &lt;code&gt;--shm-size=1gb&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building your own image
&lt;/h2&gt;

&lt;p&gt;Sometimes the official image does not fit: you need a specific base, a different runtime version, or a smaller final layer. Then you own the dependency problem, and the command that solves it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;npx playwright &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--with-deps&lt;/span&gt; chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--with-deps&lt;/code&gt; is the important half. Without it you get the browser binary and none of the system libraries it dynamically links against, which is exactly the error people paste into forums: &lt;code&gt;libnss3.so: cannot open shared object file&lt;/code&gt;. Naming a single browser (&lt;code&gt;chromium&lt;/code&gt;) rather than installing all three cuts image size substantially when your suite only targets one.&lt;/p&gt;

&lt;p&gt;Two things to know before you go this route. Alpine is not supported, because Playwright's browser builds link against glibc and Alpine ships musl, so start from a Debian or Ubuntu base. And installing browsers yourself adds a slow, network-dependent layer to every rebuild that the official image has already paid for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The errors you will actually hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The container exits immediately.&lt;/strong&gt; Usually correct behavior rather than a bug: a container lives as long as PID 1, and if PID 1 is &lt;code&gt;playwright test&lt;/code&gt;, the container stops when the run ends. Check the exit code before assuming it crashed. A non-zero code means the tests failed; zero means it worked and finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser closed unexpectedly, mid-run.&lt;/strong&gt; Shared memory. Use &lt;code&gt;--ipc=host&lt;/code&gt; or &lt;code&gt;--shm-size=1gb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing &lt;code&gt;.so&lt;/code&gt; file at launch.&lt;/strong&gt; Either a custom image without &lt;code&gt;--with-deps&lt;/code&gt;, or an Alpine base.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works locally, fails in CI.&lt;/strong&gt; Nearly always a version mismatch between the image tag and the installed Playwright package after a dependency bump. Pin both, bump both together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots differ between your machine and the container.&lt;/strong&gt; This is expected, not broken. Font rendering and available font families differ across environments, so pixel comparisons made on a laptop will not match baselines made in a container. The fix is to generate baselines in the same container that verifies them, which is the main reason visual regression suites get containerized at all. There is more on that in &lt;a href="https://www.grabbit.live/blog/playwright-visual-regression-testing" rel="noopener noreferrer"&gt;visual regression testing in Playwright CI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Docker is the right call, and when it is not
&lt;/h2&gt;

&lt;p&gt;Containerizing Playwright buys you a reproducible browser environment. That is worth real setup cost when your suite is doing browser work: navigating multi-step flows, logging in, filling forms, asserting on state, comparing screenshots against baselines that must be byte-stable. In those cases you need the whole browser under your control, and a container is how you make "the whole browser" identical everywhere.&lt;/p&gt;

&lt;p&gt;The calculation changes when the browser is incidental. A large share of Playwright-in-Docker setups exist to produce an image of a page: an OG card, a report thumbnail, a nightly capture of a dashboard, a preview for a marketing site. There the container is not giving you determinism you need, it is giving you a browser you are obligated to maintain, so that an HTTP request can come back with a PNG.&lt;/p&gt;

&lt;p&gt;If that is the job, the render can move off your infrastructure entirely:&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;-sS&lt;/span&gt; https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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/pricing",
    "width": 1280,
    "full_page": true,
    "delay_ms": 1000,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries an &lt;code&gt;image_url&lt;/code&gt; pointing at the stored capture. No image to build, no browser version to pin, no &lt;code&gt;--shm-size&lt;/code&gt; to remember, nothing to rebuild when a base image ships a new glibc.&lt;/p&gt;

&lt;p&gt;The parameters map closely to what you would have written in Playwright: &lt;code&gt;width&lt;/code&gt; (320 to 1920) pins the viewport the way &lt;code&gt;setViewportSize&lt;/code&gt; does, &lt;code&gt;full_page&lt;/code&gt; is the equivalent of &lt;code&gt;fullPage: true&lt;/code&gt;, &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) replaces a fixed &lt;code&gt;waitForTimeout&lt;/code&gt; after load, &lt;code&gt;selector&lt;/code&gt; captures a single element the way a locator screenshot does, and &lt;code&gt;format&lt;/code&gt; accepts &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The honest boundary: if the capture has to happen after a login, a click, or a form submission, none of this helps you and Playwright in a container is the correct tool. An API call renders a URL. It does not drive a session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost, honestly
&lt;/h2&gt;

&lt;p&gt;The relevant comparison is not per-request price against zero, because self-hosting is not free either. A containerized browser costs CI minutes on every build, image storage, and the recurring attention of whoever fixes it when a base image or Playwright version breaks the setup.&lt;/p&gt;

&lt;p&gt;Grabbit is $0.002 per live grab, prepaid, with credits that do not reset monthly, which fits bursty work like a capture pipeline that runs hard some weeks and not at all in others. Test-environment keys return placeholder images at no cost, so you can wire the whole flow before any real capture runs. Other providers list lower per-grab rates, so if unit price is the only variable you care about, compare directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you are keeping the container and just want the capture code to be right, &lt;a href="https://www.grabbit.live/blog/playwright-screenshot" rel="noopener noreferrer"&gt;how to take screenshots in Playwright&lt;/a&gt; covers the full-page, element, and CI cases. If you are weighing whether to keep running a browser at all, &lt;a href="https://www.grabbit.live/blog/puppeteer-alternative" rel="noopener noreferrer"&gt;Puppeteer alternatives in 2026&lt;/a&gt; walks the same decision from the other library, and the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; page covers the hosted side in depth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/playwright-docker" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
      <category>programming</category>
    </item>
    <item>
      <title>Screenshot Automation: How to Capture Web Pages on a Schedule</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Wed, 22 Jul 2026 11:40:28 +0000</pubDate>
      <link>https://dev.to/grabbit/screenshot-automation-how-to-capture-web-pages-on-a-schedule-1h71</link>
      <guid>https://dev.to/grabbit/screenshot-automation-how-to-capture-web-pages-on-a-schedule-1h71</guid>
      <description>&lt;p&gt;Screenshot automation for web pages comes down to two pieces: something that fires on a schedule, and something that renders the page. You almost certainly already have the first piece. The second is where people get stuck, because the obvious answer, running headless Chromium yourself, means owning a browser fleet to produce a single image on a timer.&lt;/p&gt;

&lt;p&gt;This guide covers the scheduled-capture pattern: how to wire a cron job to a screenshot API, how to pick an interval, how to keep captures consistent enough to compare, and where to store the results.&lt;/p&gt;

&lt;p&gt;One clarification first, because search results for this topic mix two unrelated jobs. Capturing your own desktop or phone screen on a timer needs a local screen-capture utility. Capturing a &lt;strong&gt;web page&lt;/strong&gt; on a timer, from a server, with no one at the keyboard, is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: scheduler plus render
&lt;/h2&gt;

&lt;p&gt;Every scheduled screenshot setup has the same three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trigger.&lt;/strong&gt; A cron entry, a GitHub Actions schedule, a Vercel or Cloudflare cron, or a workflow tool fires on an interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render.&lt;/strong&gt; One HTTP request per URL returns a hosted image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store.&lt;/strong&gt; Write the image URL plus a timestamp somewhere you can query later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trigger is a solved problem in whatever stack you already run. The render stage is the only real decision, and it comes down to whether you run the browser or someone else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the browser yourself
&lt;/h2&gt;

&lt;p&gt;The DIY version is a scheduled script that launches Chromium through Puppeteer or Playwright, navigates to the URL, calls &lt;code&gt;page.screenshot()&lt;/code&gt;, and uploads the result. It works, and for a single URL on a laptop it is genuinely fine.&lt;/p&gt;

&lt;p&gt;The cost shows up when it has to run unattended. A scheduled job means a long-lived environment, which means keeping the browser alive across runs or paying the launch cost every time. Serverless runtimes do not ship the native libraries Chromium expects, so you end up on &lt;code&gt;@sparticuz/chromium&lt;/code&gt; or a custom container. Chromium leaks memory over long runs, so scheduled jobs that never restart eventually get killed. None of these are unsolvable, but they are all maintenance on infrastructure whose entire output is a PNG.&lt;/p&gt;

&lt;p&gt;The honest rule: if your scheduled job needs to log in, click through a flow, or fill a form before the capture, you need your own browser automation and this maintenance is the price of the job. If it just needs to render a URL, you do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it as an API call
&lt;/h2&gt;

&lt;p&gt;If the deliverable is a rendered image of a URL, the render stage collapses into one request. A cron entry, once a day at 6am:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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/pricing",
    "width": 1280,
    "full_page": true,
    "delay_ms": 1000,
    "format": "webp"
  }'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/pricing-snapshots.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes an &lt;code&gt;image_url&lt;/code&gt; pointing at the stored capture. That URL is the artifact: append it to a log, insert it into a table, post it to Slack. There is no browser on your side, nothing to keep alive between runs, and nothing to patch.&lt;/p&gt;

&lt;p&gt;The parameters are the consistency controls, and they matter more in a scheduled pipeline than in a one-off capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;width&lt;/code&gt;&lt;/strong&gt; (320 to 1920) pins the viewport. Fix it and the layout is identical every run, so a diff shows real changes rather than reflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;full_page&lt;/code&gt;&lt;/strong&gt; captures the entire document instead of just the visible viewport, so a change below the fold is not invisible to your pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;delay_ms&lt;/code&gt;&lt;/strong&gt; (0 to 10000) waits after load for lazy-loaded images and animations to settle. This is the single most common fix for scheduled captures that look different every run for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;format&lt;/code&gt;&lt;/strong&gt; accepts &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;. For an archive that accumulates one image per run, &lt;code&gt;webp&lt;/code&gt; keeps storage down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selector&lt;/code&gt;&lt;/strong&gt; captures a single element instead of the page. Scoping a monitor to &lt;code&gt;#pricing-table&lt;/code&gt; removes noise from unrelated sections of the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scheduling more than one URL
&lt;/h2&gt;

&lt;p&gt;Most real automations track a list, not a single page. Loop the list in the scheduled job and make one request per URL. Keep two things in mind:&lt;/p&gt;

&lt;p&gt;Grabbit rate-limits to 60 requests per minute per team, so a list longer than about 60 URLs needs to be paced rather than fired in one burst. For a nightly job this is a non-issue; add a short sleep between calls and it stays well under.&lt;/p&gt;

&lt;p&gt;Second, be deliberate about the identifier you store. A screenshot is only useful later if you can find it, so record the URL, the capture timestamp, and the returned &lt;code&gt;image_url&lt;/code&gt; together. Overwriting a single "latest.png" throws away the history that made the automation worth building.&lt;/p&gt;

&lt;p&gt;There is a fuller walkthrough of the batch case in &lt;a href="https://www.grabbit.live/blog/screenshot-list-of-urls" rel="noopener noreferrer"&gt;how to screenshot a list of URLs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking an interval
&lt;/h2&gt;

&lt;p&gt;The interval should track how fast the content actually changes, not how often you would like to look at it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you are capturing&lt;/th&gt;
&lt;th&gt;Reasonable interval&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your own app, for visual regressions&lt;/td&gt;
&lt;td&gt;On every deploy, not a clock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Competitor pricing or marketing pages&lt;/td&gt;
&lt;td&gt;Daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;News, dashboards, status pages&lt;/td&gt;
&lt;td&gt;Hourly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal or compliance archiving&lt;/td&gt;
&lt;td&gt;Daily, retained long term&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A page that changes weekly at most&lt;/td&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Running more often than the page changes produces a pile of identical images and a bill that scales with nothing. The exception is your own application, where the right trigger is not a clock at all: capture on deploy, because that is the moment the page can change.&lt;/p&gt;

&lt;h2&gt;
  
  
  From automation to monitoring
&lt;/h2&gt;

&lt;p&gt;A scheduled capture on its own is an archive. Add an image diff between consecutive captures and it becomes a monitor that tells you when something changed, which is usually the actual goal. &lt;code&gt;pixelmatch&lt;/code&gt; handles strict pixel comparison and &lt;code&gt;resemblejs&lt;/code&gt; does a more forgiving perceptual comparison for pages with anti-aliased text.&lt;/p&gt;

&lt;p&gt;The consistency parameters above are what make that diff trustworthy. A pipeline with a floating viewport width or no settle delay will produce diffs on every run and you will stop reading the alerts within a week. &lt;a href="https://www.grabbit.live/blog/website-change-monitoring" rel="noopener noreferrer"&gt;Monitoring a website for visual changes&lt;/a&gt; covers the diff and alerting side in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost, honestly
&lt;/h2&gt;

&lt;p&gt;Scheduled automation is the case where per-request pricing compounds, so it is worth doing the arithmetic before you set the interval. Grabbit is $0.002 per live grab. Fifty URLs captured daily is 1,500 grabs a month, or about $3. The same fifty URLs hourly is roughly 36,000 grabs a month, closer to $72. The interval, not the tool, is what drives the number.&lt;/p&gt;

&lt;p&gt;Credits are prepaid and do not reset monthly, which suits scheduled work specifically: a job that runs unevenly, or that you pause for a quarter, does not forfeit anything at a billing boundary. Test-environment keys return placeholder images at no cost, so you can wire and debug the full schedule before any real capture runs. Other providers list lower per-grab rates, so if raw unit price is the only variable that matters to you, compare directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;If you are building scheduled capture into a product, &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;automated screenshots&lt;/a&gt; covers the API side in more depth. If you are starting from a single URL and have not automated anything yet, &lt;a href="https://www.grabbit.live/blog/screenshot-from-url" rel="noopener noreferrer"&gt;how to screenshot a website from a URL&lt;/a&gt; is the shorter starting point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/screenshot-automation" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Fix Puppeteer Memory Leaks in Production (and When to Stop Running Chromium)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Tue, 21 Jul 2026 11:40:18 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-fix-puppeteer-memory-leaks-in-production-and-when-to-stop-running-chromium-23c0</link>
      <guid>https://dev.to/grabbit/how-to-fix-puppeteer-memory-leaks-in-production-and-when-to-stop-running-chromium-23c0</guid>
      <description>&lt;p&gt;If you run Puppeteer in production long enough, you eventually meet the same graph: resident memory climbing steadily over hours, flat under test, until a container hits its limit and the OOM killer takes it. The short version is that Puppeteer memory leaks in production are almost never a single bug. They are three problems stacked on top of each other, and the fix is a combination of hygiene, bounding, and an honest question about whether you should be running Chromium at all.&lt;/p&gt;

&lt;p&gt;This post covers all three: what actually leaks, the fixes people ship (and keep shipping), how to find the real source instead of guessing, and the case for handing the whole thing off when your only job is a screenshot.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Close everything.&lt;/strong&gt; Every &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;BrowserContext&lt;/code&gt; you open must be closed in a &lt;code&gt;finally&lt;/code&gt; block, on every path including timeouts and throws. Leaked pages are leaked renderer processes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat Chromium as disposable.&lt;/strong&gt; A long-lived browser accumulates state it never fully releases. Recycle it after a fixed number of jobs instead of keeping one alive forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound the blast radius.&lt;/strong&gt; Cap concurrency, launch with &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;, and set a memory ceiling so one bad page cannot take the box down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reap zombies.&lt;/strong&gt; Run under an init process (&lt;code&gt;--init&lt;/code&gt; / &lt;code&gt;tini&lt;/code&gt;) and force-kill on timeout so crashed captures do not leave orphaned Chromium behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then ask if you need Chromium at all.&lt;/strong&gt; If the deliverable is an image of a URL, a hosted screenshot API removes the fleet, and the memory problem with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest explains each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually leaks (it is not your Node heap)
&lt;/h2&gt;

&lt;p&gt;The first mistake is measuring the wrong thing. You watch &lt;code&gt;process.memoryUsage()&lt;/code&gt;, see the Node heap sitting calm, and conclude there is no leak. Meanwhile the &lt;em&gt;container's&lt;/em&gt; memory keeps climbing. That gap is the whole story: Chromium runs as a tree of child processes, and the memory you are leaking lives in those children, not in your Node process.&lt;/p&gt;

&lt;p&gt;Three things compound:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unclosed pages and contexts.&lt;/strong&gt; Every &lt;code&gt;browser.newPage()&lt;/code&gt; and &lt;code&gt;browser.createBrowserContext()&lt;/code&gt; spins up renderer state. If your handler throws or times out before it closes them, that state stays resident. Do this a few thousand times an hour and the leak is your own code path, not Chromium's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single long-lived browser accumulating state.&lt;/strong&gt; The common "open one browser at startup, reuse it forever" pattern looks efficient and leaks slowly. Across thousands of navigations, Chromium holds onto caches, detached DOM nodes inside pages, and listeners it never fully frees. RSS drifts up even when you are closing pages correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zombie processes.&lt;/strong&gt; A capture that crashes or hangs can leave an orphaned Chromium process the OS still counts against your cgroup. These do not show up in your app metrics at all. They show up as a box that is "out of memory" while your app swears it is using very little.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the resident-set-size line you watch climb on your dashboard is usually the sum of leaked child processes and orphaned browsers, not a classic heap leak. That changes how you fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: close everything, in a finally block
&lt;/h2&gt;

&lt;p&gt;The unglamorous fix that removes most of the leak. Wrap every capture so the page and browser are closed no matter how the function exits, including on a thrown error or a timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&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="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&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="nx"&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="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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="nx"&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="c1"&gt;// runs on success, throw, and timeout alike&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 &lt;code&gt;finally&lt;/code&gt; is the point. A lot of leaking code closes the page on the happy path and skips it whenever &lt;code&gt;goto&lt;/code&gt; throws, which is exactly when captures pile up (a slow page, a network blip, a bot wall). Close in &lt;code&gt;finally&lt;/code&gt;, and audit every early &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;throw&lt;/code&gt; above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: treat the browser as disposable
&lt;/h2&gt;

&lt;p&gt;Even with perfect page hygiene, one browser held open for the life of the process drifts upward. The pattern teams converge on is a pooled browser that gets recycled after a fixed number of jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_JOBS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// recycle before the drift matters&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_JOBS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="nx"&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="nx"&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="nx"&gt;puppeteer&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="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--no-sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--disable-dev-shm-usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;browser&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;You reuse the browser enough to amortize the several-hundred-millisecond startup cost, then throw it away before accumulated state becomes a problem. A brand-new browser per request is also leak-free but pays that cold start on &lt;em&gt;every&lt;/em&gt; capture. Recycling is the middle path, and "disposable Chromium" is the reliability idea, not any single flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: bound the blast radius
&lt;/h2&gt;

&lt;p&gt;None of the launch flags below fix a leak. They stop one bad page from taking the whole container down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;&lt;/strong&gt; makes Chromium use &lt;code&gt;/tmp&lt;/code&gt; instead of the default &lt;code&gt;/dev/shm&lt;/code&gt;, which is tiny (often 64MB) in containers and a frequent cause of crashes that turn into zombies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--js-flags="--max-old-space-size=512"&lt;/code&gt;&lt;/strong&gt; caps the V8 heap inside the renderer so a runaway page fails fast instead of eating the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency cap.&lt;/strong&gt; Never run more parallel captures than the container has RAM for. A simple queue with a fixed worker count beats "spawn a browser per incoming request" every time. Each Chromium instance can easily want a few hundred MB, so on a 1GB container you are looking at a small handful of concurrent captures, not dozens.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 4: reap the zombies
&lt;/h2&gt;

&lt;p&gt;Orphaned Chromium processes are the leak that does not show up in your metrics. Two defenses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run under an init process.&lt;/strong&gt; In Docker, pass &lt;code&gt;--init&lt;/code&gt;, or use &lt;code&gt;tini&lt;/code&gt; / &lt;code&gt;dumb-init&lt;/code&gt; as PID 1. Without one, orphaned children are never reaped and accumulate until OOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-kill on timeout.&lt;/strong&gt; Wrap the whole capture in an overall timeout that calls &lt;code&gt;browser.close()&lt;/code&gt; (and, if that hangs, kills the process) so a stuck page cannot leave a browser resident forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then watch for them: periodically log &lt;code&gt;ps&lt;/code&gt; filtered to &lt;code&gt;chrome&lt;/code&gt; so you can see the process count creeping before the OOM killer tells you the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually find the source
&lt;/h2&gt;

&lt;p&gt;Guessing is slow. Measure in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch RSS and process count, not the Node heap.&lt;/strong&gt; If total container RSS and the number of &lt;code&gt;chrome&lt;/code&gt; processes climb while your Node heap is flat, the leak is unclosed pages, an un-recycled browser, or zombies. That alone narrows it to Fixes 1, 2, and 4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff two heap snapshots under steady load.&lt;/strong&gt; Take a snapshot of one browser context, run an hour of representative traffic, snapshot again, and diff. Detached DOM nodes and retained listeners that grow between snapshots point at page-level leaks in the site you are capturing (or in an &lt;code&gt;page.on(...)&lt;/code&gt; handler you forgot to remove).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm your &lt;code&gt;finally&lt;/code&gt; blocks run.&lt;/strong&gt; Add a counter for pages opened vs. pages closed. If they diverge, you found it before touching a profiler.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The DevTools heap profiler shows an OOM-killed container as "RSS growing over hours" rather than a single spike, which is why time-series RSS plus process count is the diagnostic that actually catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stop running Chromium
&lt;/h2&gt;

&lt;p&gt;Here is the honest part. Every fix above is real, and if you need a browser you should ship all of them. But notice what the work &lt;em&gt;is&lt;/em&gt;: memory hygiene, process reaping, cold-start tuning, and a 3am pager rotation, all in service of keeping a browser fleet alive.&lt;/p&gt;

&lt;p&gt;If the only reason you reached for Puppeteer is to take a screenshot of a URL, that is a lot of infrastructure for a job that is one HTTP request. A &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;hosted screenshot API&lt;/a&gt; runs the browser for you and returns a hosted image, so there is no Chromium to launch, no pages to close, no zombies to reap, and no memory graph to watch:&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://grabbit.live/api/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_your_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "full_page": true,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameters map to the Puppeteer options you were already setting: &lt;code&gt;width&lt;/code&gt; (320 to 1920), &lt;code&gt;height&lt;/code&gt; (240 to 1080), &lt;code&gt;full_page&lt;/code&gt; for the whole scroll height, &lt;code&gt;format&lt;/code&gt; (&lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;), &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) to wait for late content, and &lt;code&gt;selector&lt;/code&gt; to capture a single element. Grabbit is $0.002 per grab on prepaid credits that do not reset monthly, so you pay for the captures you run and nothing for a browser sitting idle. The &lt;a href="https://www.grabbit.live/blog/nodejs-screenshot-website" rel="noopener noreferrer"&gt;Node.js screenshot guide&lt;/a&gt; shows the same request from &lt;code&gt;fetch&lt;/code&gt;, and if you are wiring this into an agent, &lt;a href="https://www.grabbit.live/blog/screenshots-for-ai-agents" rel="noopener noreferrer"&gt;screenshots for AI agents&lt;/a&gt; covers that path.&lt;/p&gt;

&lt;p&gt;This is not "never use Puppeteer." Keep the library when you click, type, scrape, or run end-to-end tests and genuinely need to drive the browser. For a broader take on that split, see &lt;a href="https://www.grabbit.live/blog/puppeteer-alternative" rel="noopener noreferrer"&gt;Puppeteer alternatives by job&lt;/a&gt;. But when the deliverable is a picture of a page, handing the browser off is how the memory problem stops being your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Puppeteer memory leaks in production are three problems wearing one trench coat: unclosed pages, an un-recycled browser, and zombie processes. Fix them with &lt;code&gt;finally&lt;/code&gt;-block cleanup, disposable browser recycling, concurrency caps and bounding flags, and an init process that reaps orphans. Then measure RSS and process count, not just the Node heap, so you find the real source instead of guessing. And if screenshots are the whole job, the most durable fix is to not run Chromium at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/puppeteer-memory-leak" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Convert a Webpage to an Image (URL to JPG, PNG, or WebP)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:37:33 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-convert-a-webpage-to-an-image-url-to-jpg-png-or-webp-2n0o</link>
      <guid>https://dev.to/grabbit/how-to-convert-a-webpage-to-an-image-url-to-jpg-png-or-webp-2n0o</guid>
      <description>&lt;p&gt;To convert a webpage to an image, send the page URL to a screenshot API and it returns a hosted JPG, PNG, or WebP. One HTTP request in, one image URL out. The headless browser that does the rendering runs in the cloud, so there is no converter to install and no local Chrome to babysit.&lt;/p&gt;

&lt;p&gt;That is the fast path. Below are the approaches that work, when each one fits, and how to get the format, dimensions, and full-page options right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick answer
&lt;/h2&gt;

&lt;p&gt;If you just want a URL turned into an image from your code, this is the whole thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "height": 720,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is JSON with a hosted &lt;code&gt;image_url&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grb_01jx..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;62140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"execution_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;940&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-01T09:00:00.000Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that &lt;code&gt;image_url&lt;/code&gt;, embed it, or hand it to another service. The rest of this guide is about picking the right tool and the right options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual versus programmatic
&lt;/h2&gt;

&lt;p&gt;"Convert a webpage to an image" splits into two very different jobs, and the right tool depends on which one you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual, one-off.&lt;/strong&gt; You are looking at a page and want to save it as a JPG. Your browser already does this. In Chrome or Edge, open the command menu with Ctrl+Shift+P (Cmd+Shift+P on Mac), type "screenshot," and choose "Capture full size screenshot." No tool required. Free online converters like Web2PDF Convert or CloudConvert do the same thing through a form: paste a URL, download the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Programmatic, repeatable.&lt;/strong&gt; You need to convert pages to images from code, on a schedule, or across many URLs. This is where the manual path falls apart: you cannot click a browser menu inside a cron job, a CI pipeline, or a webhook handler. You need an endpoint you can call.&lt;/p&gt;

&lt;p&gt;This guide is about the second job. If you only need a one-off image, use the browser shortcut above and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting a webpage to an image with an API
&lt;/h2&gt;

&lt;p&gt;For anything repeatable, a screenshot API is the shortest path. You already saw the curl call. Here is the same request in a few languages, since "how do I do this in my stack" is the real question.&lt;/p&gt;

&lt;p&gt;Python, using &lt;code&gt;requests&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.grabbit.live/v1/grabs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&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;Authorization&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;Bearer sk_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&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;url&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;https://example.com&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;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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&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;webp&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;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node.js, using the built-in &lt;code&gt;fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.grabbit.live/v1/grabs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer sk_live_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;width&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="na"&gt;height&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="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of these is a plain HTTP call. No chromedriver, no 300 MB Chromium download, no async browser session to tear down afterward. That is the whole reason to reach for an API over a local converter: the rendering machinery lives somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing PNG, JPEG, or WebP
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;format&lt;/code&gt; field takes &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;. They are not interchangeable, and picking wrong means either bloated files or lost quality.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebP&lt;/strong&gt; is the best default. For a typical webpage render it produces the smallest file at the same visual quality, which adds up fast when you store and serve thousands of images. Use it unless you have a specific reason not to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG&lt;/strong&gt; is lossless and supports transparency. Reach for it when exact pixel fidelity matters, for example a baseline image in a &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;visual regression test&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG&lt;/strong&gt; is the safest bet for compatibility with older tooling that predates WebP. It is lossy, so text edges soften slightly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switching format is a one-word change in the request body, so you can experiment without rewriting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capturing the full page, not just the viewport
&lt;/h2&gt;

&lt;p&gt;By default a capture stops at the viewport height you request. To convert the entire scrollable page into one tall image, set &lt;code&gt;full_page&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "full_page": true,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;full_page&lt;/code&gt; on, &lt;code&gt;height&lt;/code&gt; is ignored and the render extends to the bottom of the document. This is the option you want for archiving a whole article, a long landing page, or anything below the fold. The &lt;a href="https://www.grabbit.live/blog/screenshot-entire-web-page" rel="noopener noreferrer"&gt;full-page screenshot guide&lt;/a&gt; covers the edge cases (lazy loading, sticky headers) in more depth.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;width&lt;/code&gt; must be between 320 and 1920 pixels; &lt;code&gt;height&lt;/code&gt; between 240 and 1080. If the page renders content client-side after load, add &lt;code&gt;"delay_ms": 1500&lt;/code&gt; to wait before the capture fires, or &lt;code&gt;"selector": "#main"&lt;/code&gt; to wait for a specific element to appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a local converter or library is the better call
&lt;/h2&gt;

&lt;p&gt;An API is not always the answer. Skip it and render locally when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already have a full HTML string, not a URL.&lt;/strong&gt; If the markup lives in your app and never gets served at an address, a client-side library such as &lt;code&gt;html2canvas&lt;/code&gt;, or a local &lt;code&gt;page.screenshot()&lt;/code&gt; in Puppeteer, avoids a round trip. Note that &lt;code&gt;html2canvas&lt;/code&gt; reads the existing DOM and does not execute scripts, so it misses dynamically loaded content. See &lt;a href="https://www.grabbit.live/blog/html-to-image" rel="noopener noreferrer"&gt;HTML to image&lt;/a&gt; for that pattern, including the trick of hosting your template at a URL and then capturing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are doing a single manual capture.&lt;/strong&gt; Use the browser shortcut from earlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot make outbound requests.&lt;/strong&gt; In an air-gapped environment an API is off the table, so a bundled headless browser is the only option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everything else, especially anything that runs on a schedule or in CI, the API path keeps your deployment small and removes an entire class of "it works locally but not in the container" failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;automated screenshots guide&lt;/a&gt; covers scheduling, async jobs, and webhooks for turning many URLs into images in a pipeline. For capturing pages that extend past the viewport, see the &lt;a href="https://www.grabbit.live/blog/screenshot-entire-web-page" rel="noopener noreferrer"&gt;full-page screenshot guide&lt;/a&gt;, and for rendering your own markup, &lt;a href="https://www.grabbit.live/blog/html-to-image" rel="noopener noreferrer"&gt;HTML to image&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/webpage-to-image" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>@vercel/og vs a Screenshot API: When to Render OG Images with a Real Browser</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Sun, 19 Jul 2026 11:38:02 +0000</pubDate>
      <link>https://dev.to/grabbit/vercelog-vs-a-screenshot-api-when-to-render-og-images-with-a-real-browser-gf6</link>
      <guid>https://dev.to/grabbit/vercelog-vs-a-screenshot-api-when-to-render-og-images-with-a-real-browser-gf6</guid>
      <description>&lt;p&gt;If you build with Next.js, &lt;code&gt;@vercel/og&lt;/code&gt; is probably your default for dynamic Open Graph images, and for good reason: it is fast, it runs at the edge, and it is built in. But it renders with satori, which supports only a subset of CSS, and developers keep hitting the same wall. As one put it in a &lt;a href="https://www.reddit.com/r/nextjs/comments/1u92o10/dynamic_og_images_in_next_sticking_with_vercelog/" rel="noopener noreferrer"&gt;recent r/nextjs thread&lt;/a&gt;: "stuck with satori as long as possible tbh, the perf difference is real (its basically instant vs 1-2s for headless). only moved to a screenshot-based approach for like 3 templates out of 40+ that needed actual CSS grid or custom fonts satori chokes on."&lt;/p&gt;

&lt;p&gt;That is the whole decision in one sentence. This guide gives you the rule for when to keep &lt;code&gt;@vercel/og&lt;/code&gt; and when to render with a real browser instead.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Keep &lt;code&gt;@vercel/og&lt;/code&gt;&lt;/strong&gt; for cards you design as a JSX layout (title, author, brand, a gradient) that do not need to match a real page. It is instant and edge-cached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for a screenshot API&lt;/strong&gt; when one of two things is true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;satori breaks on your content (CSS grid, right-to-left languages, fonts it cannot load), or&lt;/li&gt;
&lt;li&gt;you want the OG image to be a real render of an actual page or app, not a separate layout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then cache the rendered image per URL so the slower render happens once, not on every share.&lt;/p&gt;

&lt;h2&gt;
  
  
  What @vercel/og actually is (and its limits)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@vercel/og&lt;/code&gt; converts HTML and CSS into a PNG using &lt;a href="https://github.com/vercel/satori" rel="noopener noreferrer"&gt;satori&lt;/a&gt;, a renderer that turns markup into an image without a browser. That is why it is so fast. The tradeoff is that satori supports a deliberately limited slice of CSS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No CSS grid.&lt;/strong&gt; Layouts have to be built with flexbox. Anything depending on grid will not render the way you expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No bidirectional text.&lt;/strong&gt; Right-to-left languages like Arabic (&lt;code&gt;ar-SA&lt;/code&gt;) and Hebrew (&lt;code&gt;he-IL&lt;/code&gt;) can come out broken. A developer in the same thread noted they were evaluating alternatives "primarily because Satori does not support Right To Left layouts and languages... We have customers in the middle east and it straight up breaks."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every font must be supplied explicitly.&lt;/strong&gt; You pass each font and weight as data. Miss one and text falls back or fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a bug. satori trades CSS coverage for speed, and for the common case (a templated card you control) it is the right call. The problem is only when your content lives outside that subset.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a real browser render is the better fit
&lt;/h2&gt;

&lt;p&gt;A screenshot API renders with full Chromium, so it has none of satori's CSS limits. It is the better fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You hit a satori limit.&lt;/strong&gt; CSS grid, RTL text, a font satori will not load, a CSS feature it does not support. A real browser already handles all of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The OG image should be the real page.&lt;/strong&gt; A dashboard, a chart, a map, a live preview of the actual content. With &lt;code&gt;@vercel/og&lt;/code&gt; you would rebuild that as a JSX layout and keep it in sync by hand. A screenshot captures what is already there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want one image pipeline across stacks.&lt;/strong&gt; If not everything you ship is Next.js, a URL-in, image-out API gives every service the same OG path instead of porting satori everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest tradeoff is speed. A real browser render of a page typically takes one to two seconds, where satori is close to instant. You do not want that on the request path for every share, which is what caching is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render once, cache forever
&lt;/h2&gt;

&lt;p&gt;The pattern that makes the screenshot approach practical: render each unique URL once, store the result, and serve the stored image afterward. The slow one-to-two-second render happens a single time per page, and every share after that is a fast static file from your CDN. OG images are a perfect fit for this because the card for a given URL rarely changes.&lt;/p&gt;

&lt;p&gt;So the decision is not "fast vs slow on every request." It is "fast layout you maintain in JSX" vs "real render you cache once." Pick based on whether your content fits satori, not on raw latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to render an OG image with a screenshot API
&lt;/h2&gt;

&lt;p&gt;Build or reuse a page at a public URL that shows the title and branding (the same idea as a templated OG route, except it is a real page a browser loads), then capture it at the standard OG size. Here is a single call against Grabbit's API capturing a card at 1200 by 630:&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://grabbit.live/api/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_your_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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://yoursite.com/og/my-post",
    "width": 1200,
    "height": 630,
    "format": "png",
    "delay_ms": 300
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes the hosted image URL. Store that, point &lt;code&gt;og:image&lt;/code&gt; at it, and you are done. The &lt;code&gt;delay_ms&lt;/code&gt; gives webfonts and any client rendering a moment to settle before the capture, so the card is never half-painted. Use &lt;code&gt;format: "webp"&lt;/code&gt; if you want a smaller file, or &lt;code&gt;jpeg&lt;/code&gt; for photographic cards.&lt;/p&gt;

&lt;p&gt;If you want the OG image to be the full page rather than a fixed card, add &lt;code&gt;"full_page": true&lt;/code&gt; and drop the height, though for social previews the 1200 by 630 card is almost always what you want because that is the &lt;a href="https://www.grabbit.live/blog/og-image-sizes" rel="noopener noreferrer"&gt;exact size platforms render&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Templated card (title, author, brand) in JSX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@vercel/og&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All-Next.js, content fits satori's CSS subset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@vercel/og&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTL language, CSS grid, or a font satori cannot load&lt;/td&gt;
&lt;td&gt;Screenshot API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OG image should be a real render of the page or app&lt;/td&gt;
&lt;td&gt;Screenshot API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One OG pipeline across multiple stacks, not just Next.js&lt;/td&gt;
&lt;td&gt;Screenshot API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It is not either/or for a whole project. The developer who started this was right to keep satori for 40-plus templates and only switch the 3 that broke. Use &lt;code&gt;@vercel/og&lt;/code&gt; by default, and reach for a real browser render exactly where satori stops working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;Grabbit is a &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; that turns a URL into a hosted image with one call, which is the "real browser render" half of this decision. Pricing is a flat $0.002 per live capture on prepaid credits that never expire, and test keys render free placeholders so you can wire it up before adding a card.&lt;/p&gt;

&lt;p&gt;For the surrounding workflow, see &lt;a href="https://www.grabbit.live/blog/next-js-og-image" rel="noopener noreferrer"&gt;how to add dynamic OG images in Next.js&lt;/a&gt; (both the native and screenshot paths), &lt;a href="https://www.grabbit.live/blog/og-image-generator" rel="noopener noreferrer"&gt;how to generate dynamic OG images from any URL&lt;/a&gt;, and &lt;a href="https://www.grabbit.live/blog/what-is-an-og-image" rel="noopener noreferrer"&gt;what an OG image is&lt;/a&gt; if you are still getting broken link previews.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/vercel-og-screenshot" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>html</category>
    </item>
    <item>
      <title>How to Take a Website Screenshot in PHP (Browsershot and the API Alternative)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Fri, 17 Jul 2026 11:36:01 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-take-a-website-screenshot-in-php-browsershot-and-the-api-alternative-14kf</link>
      <guid>https://dev.to/grabbit/how-to-take-a-website-screenshot-in-php-browsershot-and-the-api-alternative-14kf</guid>
      <description>&lt;p&gt;PHP has no built-in way to render a web page, so the moment you search for "php screenshot website" you hit a fork: install a headless browser stack and drive it from PHP, or skip the browser and call a hosted API over HTTP. This guide covers both honestly, with working code and the gotcha each one hits. First, a trap to clear out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: imagegrabscreen is not what you want
&lt;/h2&gt;

&lt;p&gt;The top result people find is PHP's &lt;code&gt;imagegrabscreen()&lt;/code&gt;. It captures the operating system's desktop screen, the actual pixels on a monitor. It needs a graphical session, only works where a display is attached, and has nothing to do with loading a URL. On a typical headless Linux web server there is no screen to grab. If you want a screenshot of a website from its URL, &lt;code&gt;imagegrabscreen&lt;/code&gt; is the wrong function. You need something that renders the page in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: Spatie Browsershot (the standard DIY route)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/spatie/browsershot" rel="noopener noreferrer"&gt;Browsershot&lt;/a&gt; is the most popular PHP screenshot library. It does not render anything itself; it shells out to a Node.js script that drives Puppeteer and a headless Chromium. So the API is clean PHP, but the work happens in a real browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Spatie\Browsershot\Browsershot&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Browsershot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;windowSize&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="mi"&gt;720&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;waitUntilNetworkIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'example.png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install it with &lt;code&gt;composer require spatie/browsershot&lt;/code&gt;. The catch is everything that has to exist before that line runs: the server needs Node.js, the &lt;code&gt;puppeteer&lt;/code&gt; npm package, and a Chromium binary, all installed and kept current. &lt;code&gt;-&amp;gt;fullPage()&lt;/code&gt; captures the entire scrolling page instead of the viewport, and &lt;code&gt;-&amp;gt;waitUntilNetworkIdle()&lt;/code&gt; is how you avoid capturing a half-loaded page. Browsershot is the right pick when you control the server and can run that Node plus Chromium stack. It is the wrong pick when you are on shared hosting, a locked-down container, or a platform where you would rather not babysit a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha: waiting for the page
&lt;/h2&gt;

&lt;p&gt;The most common bug in any headless capture is firing the screenshot before the page is ready. Do not lean on a fixed sleep. Browsershot gives you real signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Browsershot&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;waitUntilNetworkIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'document.fonts.ready'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setDelay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'example.png'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;waitUntilNetworkIdle()&lt;/code&gt; waits for in-flight requests to settle, &lt;code&gt;waitForFunction('document.fonts.ready')&lt;/code&gt; avoids a fallback-font flash, and &lt;code&gt;setDelay&lt;/code&gt; adds a final cushion in milliseconds for animations or late hydration. The same class of problem and the same controls show up whether you drive &lt;a href="https://www.grabbit.live/blog/nodejs-screenshot-website" rel="noopener noreferrer"&gt;Puppeteer directly in Node&lt;/a&gt; or &lt;a href="https://www.grabbit.live/blog/python-screenshot-website" rel="noopener noreferrer"&gt;a browser in Python&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: Skip the browser, call an API
&lt;/h2&gt;

&lt;p&gt;Browsershot means you install and operate a browser on your PHP server: Node, Puppeteer, a Chromium binary, the memory each headless instance eats, and the upgrade treadmill when Chrome ships a breaking change. If screenshots are a production feature rather than a one-off script, a &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; removes all of that. You send one HTTP request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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",
    "width": 1280,
    "full_page": true,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same call from PHP with Guzzle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://api.grabbit.live/v1/grabs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Bearer sk_live_...'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'https://example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'width'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'full_page'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'format'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'webp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$imageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'image_url'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a hosted image URL you can store and reuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grb_01jx..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;48210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"execution_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1180&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Browsershot options map cleanly onto request parameters: window size is &lt;code&gt;width&lt;/code&gt; (320 to 1920) and &lt;code&gt;height&lt;/code&gt; (240 to 1080), &lt;code&gt;-&amp;gt;fullPage()&lt;/code&gt; is &lt;code&gt;full_page&lt;/code&gt;, the wait becomes &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000), and a single-element capture is a &lt;code&gt;selector&lt;/code&gt; field. &lt;code&gt;format&lt;/code&gt; is &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;. There is no Node, Puppeteer, or Chrome to install on your side, because the render runs on the provider's infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You control the server and can run Node plus Chromium:&lt;/strong&gt; Browsershot. It is well maintained and gives you full control over the headless browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared hosting, a slim container, or you would rather not operate a browser:&lt;/strong&gt; a screenshot API, so PHP just makes an HTTP request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whatever you do, not &lt;code&gt;imagegrabscreen&lt;/code&gt;:&lt;/strong&gt; it captures the desktop, not a web page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the same decision in other languages, see &lt;a href="https://www.grabbit.live/blog/python-screenshot-website" rel="noopener noreferrer"&gt;screenshots in Python&lt;/a&gt; and &lt;a href="https://www.grabbit.live/blog/nodejs-screenshot-website" rel="noopener noreferrer"&gt;screenshots in Node.js&lt;/a&gt;. If you are weighing hosted providers, the &lt;a href="https://www.grabbit.live/blog/best-screenshot-api" rel="noopener noreferrer"&gt;honest comparison of screenshot APIs&lt;/a&gt; covers the trade-offs without the marketing. Test-environment keys render free placeholders, so you can wire the request into your PHP code before adding a card.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/php-screenshot-website" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>node</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Automate Website Screenshots in GitHub Actions</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Thu, 16 Jul 2026 11:37:23 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-automate-website-screenshots-in-github-actions-4cpo</link>
      <guid>https://dev.to/grabbit/how-to-automate-website-screenshots-in-github-actions-4cpo</guid>
      <description>&lt;p&gt;To screenshot a website in GitHub Actions, add a workflow step that sends the URL to a screenshot API and saves the image it returns. The API renders the page in a hosted browser, so the step is a single &lt;code&gt;curl&lt;/code&gt; command with no browser to install in the runner and no headless-Chrome flags to debug. That is the short answer. The rest of this guide covers the full workflow, the difference between capturing a URL and capturing your test run, scheduled snapshots, and the headless-Chrome path if you would rather run the browser yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different jobs people call "screenshot in CI"
&lt;/h2&gt;

&lt;p&gt;The search results for this mix two tasks that need different tools. Sort out which one you have first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture an external URL.&lt;/strong&gt; You want an image of a live page (your production site, a staging deploy, a competitor) as part of a workflow. This is a render-the-URL problem, and the cleanest path is one API request. Most of this guide is about this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture the app under test.&lt;/strong&gt; Your job already runs Playwright, Cypress, or a Compose UI test, and you want the screenshot that test produced (often on failure) attached to the run. That image comes from the test framework itself, then &lt;code&gt;actions/upload-artifact&lt;/code&gt; carries it out. We cover that path near the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are setting up visual regression, you likely want both: the test framework captures the app it is driving, and a hosted render gives you consistent baselines for pages and external URLs the test does not control. &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;Visual regression testing&lt;/a&gt; goes deeper on that split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why headless Chrome is painful inside a runner
&lt;/h2&gt;

&lt;p&gt;The reason "screenshot github actions not working" is a real related search: CI runners are a hostile place to run a browser. The recurring failure modes are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timing.&lt;/strong&gt; The capture fires before the page finishes loading, so you get a blank or half-rendered image. You have to wait for network idle, not just &lt;code&gt;DOMContentLoaded&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts.&lt;/strong&gt; The Ubuntu runner ships a minimal font set. Pages that use system or web fonts render with tofu boxes or fall back to a different face, so the screenshot does not match what users see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Viewport.&lt;/strong&gt; The default window size is not the viewport you want, so the layout that renders is not the layout you meant to capture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maintenance.&lt;/strong&gt; Every Chromium bump can change a flag, and you own the dependency list, the version pinning, and the install time on every run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is hard once, but it is a standing tax on the workflow. Offloading the render removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-step workflow: screenshot a URL
&lt;/h2&gt;

&lt;p&gt;Here is a complete workflow that captures a URL on every push and saves the image as an artifact. The capture itself is one step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/screenshot.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Screenshot site&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capture&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Capture screenshot&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;IMAGE_URL=$(curl -s https://api.grabbit.live/v1/grabs \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Authorization: Bearer ${{ secrets.GRABBIT_API_KEY }}" \&lt;/span&gt;
            &lt;span class="s"&gt;-H "Content-Type: application/json" \&lt;/span&gt;
            &lt;span class="s"&gt;-d '{&lt;/span&gt;
              &lt;span class="s"&gt;"url": "https://example.com",&lt;/span&gt;
              &lt;span class="s"&gt;"width": 1280,&lt;/span&gt;
              &lt;span class="s"&gt;"height": 720,&lt;/span&gt;
              &lt;span class="s"&gt;"format": "webp"&lt;/span&gt;
            &lt;span class="s"&gt;}' | jq -r '.image_url')&lt;/span&gt;
          &lt;span class="s"&gt;curl -s "$IMAGE_URL" -o screenshot.webp&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload artifact&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;site-screenshot&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;screenshot.webp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each piece does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The capture step POSTs the URL to the API. &lt;code&gt;width&lt;/code&gt; pins the desktop layout, &lt;code&gt;height&lt;/code&gt; gives a 16:9 above-the-fold crop, and &lt;code&gt;format&lt;/code&gt; of &lt;code&gt;webp&lt;/code&gt; keeps the artifact small. The response is JSON; &lt;code&gt;jq&lt;/code&gt; pulls out the &lt;code&gt;image_url&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The second &lt;code&gt;curl&lt;/code&gt; downloads the hosted image into the workspace as &lt;code&gt;screenshot.webp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actions/upload-artifact&lt;/code&gt; attaches that file to the run, so you can open it from the run summary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store your key as a repository secret (&lt;code&gt;GRABBIT_API_KEY&lt;/code&gt;) and reference it with &lt;code&gt;${{ secrets.GRABBIT_API_KEY }}&lt;/code&gt; so it never lands in the workflow file. The runner needs no browser, no font install, and no &lt;code&gt;apt-get&lt;/code&gt; step. &lt;code&gt;ubuntu-latest&lt;/code&gt; already has &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For pages whose content loads after the first paint, add &lt;code&gt;"delay_ms": 800&lt;/code&gt; so lazy images settle before the capture. To capture the whole document instead of the top crop, set &lt;code&gt;"full_page": true&lt;/code&gt; and drop &lt;code&gt;height&lt;/code&gt;. To capture one component, such as a hero or a pricing card, pass &lt;code&gt;"selector": "#hero"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshot on a schedule
&lt;/h2&gt;

&lt;p&gt;A common reason to put captures in CI is to build a running history: a daily snapshot of your landing page or a dashboard. Swap the &lt;code&gt;push&lt;/code&gt; trigger for a &lt;code&gt;schedule&lt;/code&gt; and the same workflow runs on a cron cadence with no commit needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt; &lt;span class="c1"&gt;# every day at 09:00 UTC&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# also allow a manual run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run captures the URLs you list and uploads the images. Add a commit step if you want the snapshots versioned in the repo, or keep them as artifacts if you only need recent history. To capture several pages, loop over a list of URLs and call the API once per URL; &lt;a href="https://www.grabbit.live/blog/screenshot-list-of-urls" rel="noopener noreferrer"&gt;how to screenshot a list of URLs&lt;/a&gt; covers that loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshot the app your test is driving
&lt;/h2&gt;

&lt;p&gt;If your job already runs a browser test, the screenshot comes from the test, not from a separate render. Capture it in the framework and upload the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playwright:&lt;/strong&gt; call &lt;code&gt;await page.screenshot({ path: 'shot.png' })&lt;/code&gt;, or let the runner capture on failure with &lt;code&gt;screenshot: 'only-on-failure'&lt;/code&gt; in your config. The artifacts land in &lt;code&gt;test-results/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cypress:&lt;/strong&gt; &lt;code&gt;cy.screenshot()&lt;/code&gt; writes to &lt;code&gt;cypress/screenshots/&lt;/code&gt;, and the runner captures automatically on a failed test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then attach them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Upload test screenshots&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt; &lt;span class="c1"&gt;# upload even when the test failed&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-screenshots&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test-results/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;if: always()&lt;/code&gt; matters: without it, a failed test step skips the upload, and the failure screenshot you most want is the one you lose. For the full setup of capturing inside a Playwright run and comparing against a baseline, see &lt;a href="https://www.grabbit.live/blog/playwright-visual-regression-testing" rel="noopener noreferrer"&gt;visual testing in Playwright CI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use the API vs the framework
&lt;/h2&gt;

&lt;p&gt;A quick rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the API&lt;/strong&gt; when you are capturing a URL the workflow does not run: production, a deployed preview, an external site, or a page you want a consistent baseline of regardless of the CI environment. One request, no browser to maintain, and the same render every time because the font set and viewport are fixed on the API's side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the framework&lt;/strong&gt; when you are capturing the app the job is already driving through a test, especially failure screenshots tied to a specific assertion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many pipelines use both, and that is fine. The point of moving the URL captures to an API is that the part of CI most likely to break (a headless browser on a minimal runner) becomes an HTTP call that does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;To automate website screenshots in GitHub Actions: POST the URL to a screenshot API in a single workflow step, download the returned image, and upload it as an artifact. Add a &lt;code&gt;schedule&lt;/code&gt; trigger for daily snapshots, and reach for your test framework's own screenshot for the app under test. The runner stays clean, the captures stay consistent, and there is no browser to install or keep working.&lt;/p&gt;

&lt;p&gt;For the full set of capture options (viewport, format, full-page, selector, delay) see the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt;. For running captures across many URLs on a schedule, see &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;automated screenshots&lt;/a&gt;. And if you are wiring screenshots into a test suite rather than a plain workflow, &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;visual regression testing&lt;/a&gt; covers the baseline-and-compare loop end to end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/screenshot-github-actions" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Screenshot a Specific Element on a Page (With a CSS Selector)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Wed, 15 Jul 2026 11:39:39 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-screenshot-a-specific-element-on-a-page-with-a-css-selector-5an9</link>
      <guid>https://dev.to/grabbit/how-to-screenshot-a-specific-element-on-a-page-with-a-css-selector-5an9</guid>
      <description>&lt;p&gt;To screenshot a specific element instead of the whole page, target it with a CSS selector and capture only its bounding box. You can do this three ways: manually in the browser's DevTools, in code with a headless browser, or over an API by sending the page URL and the selector. Which one fits depends on whether it is a one-off, whether you control the page, and how many elements you need to capture.&lt;/p&gt;

&lt;p&gt;This guide covers all three, the difference between a true element screenshot and a client-side &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; re-render, and why element capture is often the right call for monitoring a single component.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual way: DevTools node screenshot
&lt;/h2&gt;

&lt;p&gt;For a quick one-off, the browser already does this. In Chrome:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Right-click the element and choose &lt;strong&gt;Inspect&lt;/strong&gt; to select its node in the Elements panel.&lt;/li&gt;
&lt;li&gt;Open the Command Menu with &lt;strong&gt;Cmd/Ctrl + Shift + P&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Type &lt;code&gt;screenshot&lt;/code&gt; and choose &lt;strong&gt;Capture node screenshot&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Chrome saves a PNG cropped to that element. Firefox has the same feature: in the Inspector, right-click a node and pick &lt;strong&gt;Screenshot Node&lt;/strong&gt;. This is perfect when you need one image once. It does not help when you need the same element from many pages, on a schedule, or from a server, which is where the next two paths come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code way: capture an element's bounding box
&lt;/h2&gt;

&lt;p&gt;Every headless browser can screenshot a single element. The shape in Puppeteer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DIY: find the element, screenshot only its box&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;puppeteer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#pricing-table&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollIntoView&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;element.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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;Playwright is &lt;code&gt;page.locator('#pricing-table').screenshot()&lt;/code&gt;. Selenium 4 added it too: find the element, then call &lt;code&gt;getScreenshotAs&lt;/code&gt; (Java) or &lt;code&gt;.screenshot()&lt;/code&gt; (Python) on the element itself. See &lt;a href="https://www.grabbit.live/blog/selenium-screenshot" rel="noopener noreferrer"&gt;how to take screenshots in Selenium&lt;/a&gt; for the full element-capture example there.&lt;/p&gt;

&lt;p&gt;Two things bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scroll into view.&lt;/strong&gt; If the element is below the fold, the capture can come back clipped or blank. Scroll it into view first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait for it to render.&lt;/strong&gt; If the element is populated by JavaScript after load, the selector resolves before the content paints. Wait for the element, or add a short delay, before capturing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the right path when the page is yours and you are already running a browser. The cost is the same as any DIY screenshot setup: you own the browser pool, the scaling, and the storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API way: send a URL and a selector
&lt;/h2&gt;

&lt;p&gt;When you want one element from a page you do not control, or the same element across many URLs, sending the work to a screenshot API is the shortest path. You pass the page URL and a CSS &lt;code&gt;selector&lt;/code&gt;, and you get back an image cropped to that element. No browser to run, no scroll-and-wait logic to maintain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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://stripe.com/pricing",
    "selector": ".PricingTable",
    "format": "webp",
    "delay_ms": 800
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API renders the real page in a browser, finds the element matching &lt;code&gt;selector&lt;/code&gt;, and returns an &lt;code&gt;image_url&lt;/code&gt; pointing at a hosted capture of just that element. &lt;code&gt;delay_ms&lt;/code&gt; of 800 gives content that loads after first paint time to settle before the capture. &lt;code&gt;format&lt;/code&gt; of &lt;code&gt;webp&lt;/code&gt; keeps the file small.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.grabbit.live%2Fblog%2Fheaders%2Fscreenshot-element.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.grabbit.live%2Fblog%2Fheaders%2Fscreenshot-element.webp" title="The same call against  raw `selector: \" alt="A screenshot cropped to just the header element of a web page, captured by sending the page URL and a selector to an API." width="800" height="48"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few notes on the parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selector&lt;/code&gt;&lt;/strong&gt; takes any CSS selector: an id (&lt;code&gt;#hero&lt;/code&gt;), a class (&lt;code&gt;.PricingTable&lt;/code&gt;), or a more specific path. If it matches multiple elements, the first match is captured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;width&lt;/code&gt;&lt;/strong&gt; still sets the viewport the page renders at (320 to 1920), which decides whether the element renders in its desktop or mobile layout before it is cropped. Leave it at the default 1280 for a desktop render.&lt;/li&gt;
&lt;li&gt;Skip &lt;code&gt;full_page&lt;/code&gt; here. Element capture and full-page capture are different jobs: &lt;code&gt;selector&lt;/code&gt; crops to one element, &lt;code&gt;full_page&lt;/code&gt; captures the entire document. If you want the whole page instead, see &lt;a href="https://www.grabbit.live/blog/full-page-screenshot" rel="noopener noreferrer"&gt;how to take a full-page screenshot&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Element screenshot vs a client-side div re-render
&lt;/h2&gt;

&lt;p&gt;There is a popular fourth approach that looks like an element screenshot but is not one: &lt;code&gt;html2canvas&lt;/code&gt; and similar libraries. These run in the user's browser and redraw a DOM node onto a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;. That is a re-render of the DOM, not a capture of what the browser actually painted, so it has real limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can miss or blank out cross-origin images the canvas cannot read.&lt;/li&gt;
&lt;li&gt;Complex CSS (filters, some gradients, transforms, custom fonts) often renders differently from the real page.&lt;/li&gt;
&lt;li&gt;It only works on a page already loaded in a browser you control, so it is no help for capturing an element from an external URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a faithful image of an element as a browser renders it, you want a real render: headless Chrome in code, or a screenshot API. The client-side canvas trick is fine for capturing something inside your own running app (a generated chart, a receipt) where pixel-perfect fidelity to the live page does not matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to capture one element instead of the whole page
&lt;/h2&gt;

&lt;p&gt;Element capture is not just a smaller screenshot. For some jobs it is the better one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Component monitoring.&lt;/strong&gt; Watching a single pricing table, status banner, or hero for changes is cleaner against just that element. A full-page diff lights up every time an unrelated ad or footer shifts; an element capture only changes when the thing you care about does. This pairs naturally with &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;visual regression testing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding a specific piece.&lt;/strong&gt; Pulling one card, chart, or quote out of a page to show elsewhere is exactly an element crop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable captures across pages.&lt;/strong&gt; Capturing &lt;code&gt;.product-card&lt;/code&gt; across a catalog gives you uniform images without per-page cropping math.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;To screenshot a specific element: pick it with a CSS selector, then capture only its bounding box. For a one-off, DevTools' node screenshot is instant. In your own app with a browser already running, screenshot the element in Puppeteer, Playwright, or Selenium. For an element from a URL you do not control, or the same element across many pages, send the URL and &lt;code&gt;selector&lt;/code&gt; to an API and get back a cropped image with no browser to run.&lt;/p&gt;

&lt;p&gt;For every capture option (viewport, format, full-page, selector, delay) see the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt;. To capture whole pages from URLs, see &lt;a href="https://www.grabbit.live/blog/screenshot-from-url" rel="noopener noreferrer"&gt;how to screenshot a website from a URL&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/screenshot-element" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Generate a Website Preview Image from a URL</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Tue, 14 Jul 2026 11:40:46 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-generate-a-website-preview-image-from-a-url-54p</link>
      <guid>https://dev.to/grabbit/how-to-generate-a-website-preview-image-from-a-url-54p</guid>
      <description>&lt;p&gt;A website preview image is a rendered picture of a web page, almost always a screenshot of the page captured in a real browser. Link directories, dashboards, "recently added site" feeds, portfolio grids, and monitoring tools all show one so a person can recognize a site at a glance without loading it. To generate a preview from a URL, send the URL to a screenshot API and store the image it returns.&lt;/p&gt;

&lt;p&gt;The preview itself is the easy part. The work is rendering the page faithfully, at the right size, for many URLs, on a schedule, without running a browser fleet yourself. This guide covers what a website preview actually is, how it differs from a social link preview, what size to capture, the build-it-yourself path, and the one-call API path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Website preview vs social link preview
&lt;/h2&gt;

&lt;p&gt;Search results for "website preview" mix two different jobs, so it is worth separating them up front:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;website preview image&lt;/strong&gt; is a screenshot of the page. You generate it for sites you link to, list, or watch, and it refreshes when you recapture. This is a render-the-URL problem.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;social link preview&lt;/strong&gt; is the &lt;code&gt;og:image&lt;/code&gt; a site sets for itself, the card that shows when its link is shared on Slack, Discord, X, or LinkedIn. It is a hand-designed image, not a live screenshot, and you control it only for your own pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is about the first one: turning a URL into a real rendered image you can show. If you are trying to fix the card that appears when your own link is shared, that is an Open Graph problem. See &lt;a href="https://www.grabbit.live/blog/what-is-an-og-image" rel="noopener noreferrer"&gt;what is an OG image&lt;/a&gt; for that path instead.&lt;/p&gt;

&lt;p&gt;There is also a third thing people call a "preview": the browser-extension popup that shows a live page when you hover a link. That is an in-browser convenience, not an image you can store, host, or put in a dashboard. For anything programmatic you want a captured image, which is what the rest of this guide produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What size should a website preview be
&lt;/h2&gt;

&lt;p&gt;Capture at a desktop viewport width, then display the result scaled down. The two decisions are separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capture width&lt;/strong&gt; sets which layout renders. 1280px is a safe default: it triggers the desktop layout most visitors see. Rendering at a tiny viewport instead pushes the page into its mobile breakpoint, so the preview shows a hamburger menu and a single stacked column, which is not a recognizable preview of the site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Displayed size&lt;/strong&gt; is a CSS concern. Render the captured image into whatever box your grid or card uses. Downscaling a wide capture keeps text crisp and the layout faithful.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the most common framing, an above-the-fold preview, capture a fixed height rather than the whole page. A &lt;code&gt;1280x720&lt;/code&gt; capture gives you a clean 16:9 crop of the top of the page. When you want the entire document (long landing pages, articles), use &lt;code&gt;full_page&lt;/code&gt; instead and let the height grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build-it-yourself path
&lt;/h2&gt;

&lt;p&gt;You can generate previews with headless Chrome. The shape in Puppeteer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DIY: you own the browser, the scaling, and the storage&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;puppeteer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&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="na"&gt;height&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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="c1"&gt;// then: upload to storage, store the URL, serve the image&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works for one URL on your laptop. The cost shows up at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure.&lt;/strong&gt; Each capture needs a Chromium process. Previewing a directory of hundreds of sites means a browser pool, a queue, and memory limits, because one runaway page can take down the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability.&lt;/strong&gt; Cookie and consent banners, lazy-loaded images, and pages that block headless browsers all produce blank or broken previews unless you handle each case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; You still have to upload, host, and serve every image yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a handful of static previews this is fine. For an automated, always-current feed it becomes a service you maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-call API path
&lt;/h2&gt;

&lt;p&gt;A screenshot API does the rendering and hosting, so your code is just the request. Send the URL, get back a hosted image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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://stripe.com",
    "width": 1280,
    "height": 720,
    "format": "webp"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;width&lt;/code&gt; pins the desktop layout. &lt;code&gt;height&lt;/code&gt; of 720 gives a 16:9 above-the-fold crop, the classic preview frame. &lt;code&gt;format&lt;/code&gt; of &lt;code&gt;webp&lt;/code&gt; keeps the file small, which matters when a feed loads many previews at once. The response includes an &lt;code&gt;image_url&lt;/code&gt; pointing at the stored capture. Save that URL and render it in your &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; or as a CSS &lt;code&gt;background-image&lt;/code&gt; in the card.&lt;/p&gt;

&lt;p&gt;Want the entire page instead of the top crop? Set &lt;code&gt;"full_page": true&lt;/code&gt; and drop &lt;code&gt;height&lt;/code&gt;. For pages whose content loads after the first paint, add &lt;code&gt;"delay_ms": 800&lt;/code&gt; so lazy images settle before the capture. To preview just one part of the page, such as a hero or a pricing card, pass &lt;code&gt;"selector": "#hero"&lt;/code&gt; and only that element is captured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating previews in bulk
&lt;/h2&gt;

&lt;p&gt;A directory or feed is the same request in a loop. There is no separate batch endpoint to learn:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Iterate over your list of URLs.&lt;/li&gt;
&lt;li&gt;Call the API once per URL with the same width, height, and format.&lt;/li&gt;
&lt;li&gt;Store each returned &lt;code&gt;image_url&lt;/code&gt; keyed by the source URL.&lt;/li&gt;
&lt;li&gt;Render the stored URLs in your grid or feed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the API owns the browser pool, capturing a hundred previews is the same code as capturing one. If you are building this around a list of sites, &lt;a href="https://www.grabbit.live/blog/screenshot-list-of-urls" rel="noopener noreferrer"&gt;how to screenshot a list of URLs&lt;/a&gt; covers the loop, concurrency, and error handling in depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping previews current
&lt;/h2&gt;

&lt;p&gt;A preview goes stale the moment the source site redesigns. The fix is to recapture, but recapturing on every page load is wasteful. The pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache the &lt;code&gt;image_url&lt;/code&gt;.&lt;/strong&gt; Store it with the source URL and a captured-at timestamp. Serve the cached image, not a fresh capture, on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh on a schedule.&lt;/strong&gt; Recapture each URL on a cadence that matches how often the sites change. A daily refresh is plenty for most feeds; weekly is fine for stable portfolios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh on demand.&lt;/strong&gt; Let users trigger a recapture for their own listing so a redesign shows up immediately instead of waiting for the next run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps the feed current without rendering a browser on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;To generate a website preview image from a URL: capture at a desktop width, request a small format, store the returned image URL, and recapture on a schedule. With a hosted API the whole thing is one request per URL plus a cache, with no browser fleet to run.&lt;/p&gt;

&lt;p&gt;For the full set of capture options (viewport, format, full-page, selector, delay) see the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt;. For running previews across many URLs on a schedule, see &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;automated screenshots&lt;/a&gt;. And if you want the top crop versus the whole document, &lt;a href="https://www.grabbit.live/blog/website-thumbnail" rel="noopener noreferrer"&gt;how to generate a website thumbnail from a URL&lt;/a&gt; walks through the framing choices in detail.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/website-preview" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Twitter Card Image Size: The Right Dimensions for X Link Previews</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Mon, 13 Jul 2026 11:40:44 +0000</pubDate>
      <link>https://dev.to/grabbit/twitter-card-image-size-the-right-dimensions-for-x-link-previews-4npl</link>
      <guid>https://dev.to/grabbit/twitter-card-image-size-the-right-dimensions-for-x-link-previews-4npl</guid>
      <description>&lt;p&gt;When you paste a link into a tweet, X should unfurl it into a card with a title, a description, and an image. The single setting that decides whether that image shows up large or as a cramped thumbnail is the &lt;strong&gt;Twitter card image size&lt;/strong&gt;. Get the dimensions right and one image gives you a full-width preview on X and every other platform. Get them wrong and the card collapses to a small square or shows no image at all.&lt;/p&gt;

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

&lt;p&gt;For a large, full-width card on X, use &lt;strong&gt;1200 by 628 pixels&lt;/strong&gt; at a &lt;strong&gt;1.91 to 1&lt;/strong&gt; aspect ratio. That is the size for a Summary Card with Large Image, the card type you almost always want.&lt;/p&gt;

&lt;p&gt;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aspect ratio is what X actually enforces.&lt;/strong&gt; The card is 1.91 to 1. 1200 by 628 hits it; the near-identical 1200 by 630 Open Graph size renders the same way, so you do not need a separate image for X.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum 4096 by 4096 pixels, under 5MB.&lt;/strong&gt; Above that, X will not render the image. Stay well under.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formats: JPG, PNG, or WebP.&lt;/strong&gt; (Animated GIFs are not supported on cards.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The small Summary Card is different.&lt;/strong&gt; If you use &lt;code&gt;summary&lt;/code&gt; instead of &lt;code&gt;summary_large_image&lt;/code&gt;, the image is a square thumbnail beside the text, minimum 144 by 144 pixels. This guide focuses on the large card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because 1200 by 628 is effectively the same 1.91 to 1 frame Facebook, LinkedIn, Discord, and Slack use, a single correctly sized image covers all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How X builds a link card
&lt;/h2&gt;

&lt;p&gt;X does not screenshot your page or guess at its content. When a URL is posted, X fetches the raw HTML and reads two families of meta tags from the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;: the &lt;code&gt;twitter:&lt;/code&gt; tags and, as a fallback, the Open Graph &lt;code&gt;og:&lt;/code&gt; tags.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your Page Title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"A one-line summary of the page."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yourdomain.com/og.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Open Graph fallbacks X also reads --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your Page Title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"A one-line summary of the page."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yourdomain.com/og.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one tag with no Open Graph equivalent is &lt;code&gt;twitter:card&lt;/code&gt;. It tells X which layout to use; &lt;code&gt;summary_large_image&lt;/code&gt; is what gives you the 1.91 to 1 banner. For everything else, X falls back to the &lt;code&gt;og:&lt;/code&gt; tag when the matching &lt;code&gt;twitter:&lt;/code&gt; tag is missing. So in practice you can set &lt;code&gt;twitter:card&lt;/code&gt; plus the four &lt;code&gt;og:&lt;/code&gt; tags and skip the redundant &lt;code&gt;twitter:title&lt;/code&gt;, &lt;code&gt;twitter:description&lt;/code&gt;, and &lt;code&gt;twitter:image&lt;/code&gt;. That is why the same image and tags that fix a &lt;a href="https://www.grabbit.live/blog/linkedin-link-preview" rel="noopener noreferrer"&gt;LinkedIn link preview&lt;/a&gt; or a &lt;a href="https://www.grabbit.live/blog/discord-link-preview" rel="noopener noreferrer"&gt;Discord link preview&lt;/a&gt; also fix the X card.&lt;/p&gt;

&lt;p&gt;Two things trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;X reads the raw HTML, not the rendered page.&lt;/strong&gt; It does not run your JavaScript. If your meta tags are set client-side (a single-page app that injects them after load), X never sees them. The tags must be in the HTML the server sends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;twitter:image&lt;/code&gt; / &lt;code&gt;og:image&lt;/code&gt; must be an absolute, public URL.&lt;/strong&gt; A relative path like &lt;code&gt;/og.png&lt;/code&gt; will not resolve, and an image behind a login or a redirect X cannot follow renders as no image. Use the full &lt;code&gt;https://yourdomain.com/og.png&lt;/code&gt; and confirm it loads in a private browser window.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Making the card image a real render of your page
&lt;/h2&gt;

&lt;p&gt;The hardest part of a good card is not the four meta tags. It is producing an image at exactly 1200 by 628 that actually reflects the page, instead of a generic logo. For a marketing page, a docs page, or a launch announcement, the most compelling preview is often a clean shot of the page itself.&lt;/p&gt;

&lt;p&gt;You can produce that with a &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt;: point it at the URL, get back a hosted image at the size you ask for, and use that image as your &lt;code&gt;og:image&lt;/code&gt; / &lt;code&gt;twitter:image&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.grabbit.live/v1/grabs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer sk_live_..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&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://yourdomain.com/og",
    "width": 1200,
    "height": 628,
    "format": "png",
    "delay_ms": 500
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes a hosted &lt;code&gt;image_url&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grb_01jx..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;628&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;68820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"execution_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1180&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;width&lt;/code&gt; accepts 320 to 1920 and &lt;code&gt;height&lt;/code&gt; 240 to 1080, so the 1200 by 628 card is in range. &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) gives client-rendered content or web fonts a moment to settle before the capture fires. Drop the returned &lt;code&gt;image_url&lt;/code&gt; into your tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.grabbit.live/grabs/grb_01jx....png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capture once per unique page and cache the &lt;code&gt;image_url&lt;/code&gt; alongside the page record rather than rendering on every request. A common pattern is to build a dedicated &lt;code&gt;/og&lt;/code&gt; route with your normal components, capture that at 1200 by 628, and reuse the result everywhere. The same template-and-cache approach is covered in &lt;a href="https://www.grabbit.live/blog/og-image-generator" rel="noopener noreferrer"&gt;how to generate dynamic OG images from any URL&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to validate the card
&lt;/h2&gt;

&lt;p&gt;X retired its standalone Card Validator, so the old "paste a URL into the validator" workflow no longer exists. There are three reliable ways to check a card before it goes out (covered in depth in &lt;a href="https://www.grabbit.live/blog/twitter-card-validator" rel="noopener noreferrer"&gt;how to test your X link preview now that the validator is gone&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Draft the tweet.&lt;/strong&gt; Paste the URL into the X composer (or a test account) and look at the preview it renders. This is the ground truth, since it is X reading your live tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect the tags directly.&lt;/strong&gt; Run view-source on the live URL or &lt;code&gt;curl&lt;/code&gt; it and confirm the &lt;code&gt;twitter:card&lt;/code&gt; and &lt;code&gt;og:image&lt;/code&gt; tags are present in the served HTML, before any JavaScript runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a third-party Open Graph debugger.&lt;/strong&gt; Because X reads the same &lt;code&gt;og:&lt;/code&gt; tags, a general Open Graph preview tool shows you how the card will look. Our &lt;a href="https://www.grabbit.live/blog/og-image-checker" rel="noopener noreferrer"&gt;OG image checker&lt;/a&gt; walks through previewing the image before you publish.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the card looks wrong even though the tags are correct, the cause is almost always caching: X stored the metadata from the first time the URL was shared. Append a unique query string such as &lt;code&gt;?v=2&lt;/code&gt; to the URL when you re-post, and X treats it as a new link with no cache entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your card still is not showing
&lt;/h2&gt;

&lt;p&gt;If the tags are present, the image URL works in a browser, and you have re-posted with a cache-busting query string but the card still looks wrong, work through these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The image is behind auth or a redirect.&lt;/strong&gt; If &lt;code&gt;twitter:image&lt;/code&gt; requires a login, sits behind a paywall, or redirects in a way X will not follow, the card renders without an image. Test the raw image URL in a private window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags are set by JavaScript.&lt;/strong&gt; X reads static HTML and does not execute scripts. Server-render the meta tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image is the wrong shape or too small.&lt;/strong&gt; Below the 1.91 to 1 ratio, X may show a small square thumbnail instead of a full-width banner, or fall back to the small card. Use 1200 by 628.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;twitter:card&lt;/code&gt; is missing.&lt;/strong&gt; Without &lt;code&gt;summary_large_image&lt;/code&gt;, X defaults to the small summary card, so even a correctly sized image appears as a thumbnail. Set the card type explicitly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Work top to bottom: confirm &lt;code&gt;twitter:card&lt;/code&gt; is &lt;code&gt;summary_large_image&lt;/code&gt; first, then verify the image is public and absolute, then bust the cache by re-posting with a fresh query string.&lt;/p&gt;

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

&lt;p&gt;A Twitter (X) card is just your page's &lt;code&gt;twitter:&lt;/code&gt; and Open Graph tags, rendered as a preview. Set &lt;code&gt;twitter:card&lt;/code&gt; to &lt;code&gt;summary_large_image&lt;/code&gt;, point &lt;code&gt;og:image&lt;/code&gt; / &lt;code&gt;twitter:image&lt;/code&gt; at an absolute, public &lt;strong&gt;1200 by 628&lt;/strong&gt; image, and X gives you a full-width card that also covers Facebook, LinkedIn, Discord, and Slack. The validator is gone, so check the card by drafting a tweet or inspecting the served tags. If you want the image to be a real render of the page instead of a static logo, a &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; turns the live URL into the hosted image you point the tags at. If you are new to the format, start with &lt;a href="https://www.grabbit.live/blog/what-is-an-og-image" rel="noopener noreferrer"&gt;what is an OG image&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/twitter-card-image-size" rel="noopener noreferrer"&gt;Grabbit blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>html</category>
    </item>
  </channel>
</rss>
