<?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>Screenshot API Alternatives: An Honest Comparison (2026)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Wed, 12 Aug 2026 11:39:27 +0000</pubDate>
      <link>https://dev.to/grabbit/screenshot-api-alternatives-an-honest-comparison-2026-2a0k</link>
      <guid>https://dev.to/grabbit/screenshot-api-alternatives-an-honest-comparison-2026-2a0k</guid>
      <description>&lt;p&gt;If you are comparing screenshot API alternatives, you have almost certainly hit one of three walls: a monthly quota that resets before you use it, a feature the tool does not have, or an integration path that does not fit how you actually build. This is an honest, side-by-side comparison of the main options, with a clear note on where each one wins.&lt;/p&gt;

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

&lt;p&gt;Every screenshot API does the same core job: send a URL, get back a rendered image. They differ on three things that actually matter, so pick by whichever is your bottleneck:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Billing model.&lt;/strong&gt; Monthly plans reset unused capacity; a flat annual plan does not. This is the biggest hidden cost for spiky or seasonal workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-grab rate.&lt;/strong&gt; Browserless and Thum.io list the lowest rates. Neither is a one-call REST endpoint for every feature, so cheap-per-grab comes with a trade-off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration.&lt;/strong&gt; A raw REST endpoint, a first-party SDK for your language, or an agent-callable MCP tool are three different developer experiences.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grabbit is the alternative when monthly waste and agent onboarding are your pain: &lt;strong&gt;$0.002 per capture, 25,000 prepaid credits per year that never reset or expire&lt;/strong&gt;, plus a built-in MCP server. It is not the cheapest option overall, and this post says so plainly below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the alternatives compare
&lt;/h2&gt;

&lt;p&gt;Prices verified June 2026 from each provider's public pricing page, normalized to roughly 10,000 grabs per month. "Included" is each provider's entry-plan allowance. The honest caveat up front: &lt;strong&gt;two providers list a lower per-grab rate than Grabbit.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Per grab&lt;/th&gt;
&lt;th&gt;Billing&lt;/th&gt;
&lt;th&gt;Included&lt;/th&gt;
&lt;th&gt;Monthly waste&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grabbit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.002&lt;/td&gt;
&lt;td&gt;Annual flat&lt;/td&gt;
&lt;td&gt;25,000 / yr&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;One-line + MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScreenshotOne&lt;/td&gt;
&lt;td&gt;$0.0079&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;2,000 / mo&lt;/td&gt;
&lt;td&gt;Resets&lt;/td&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Urlbox&lt;/td&gt;
&lt;td&gt;$0.0066&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;2,000 / mo&lt;/td&gt;
&lt;td&gt;Resets&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ApiFlash&lt;/td&gt;
&lt;td&gt;$0.0035&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;1,000 / mo&lt;/td&gt;
&lt;td&gt;Resets&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScrapingBee&lt;/td&gt;
&lt;td&gt;$0.0049 †&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;~10,000 / mo&lt;/td&gt;
&lt;td&gt;Resets&lt;/td&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browserless&lt;/td&gt;
&lt;td&gt;$0.0013 ‡&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;20,000 units / mo&lt;/td&gt;
&lt;td&gt;Resets&lt;/td&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thum.io&lt;/td&gt;
&lt;td&gt;$0.0002&lt;/td&gt;
&lt;td&gt;Usage metered&lt;/td&gt;
&lt;td&gt;$1–$20 / mo min.&lt;/td&gt;
&lt;td&gt;Minimum spend&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;† ScrapingBee is credit-based (roughly 25 credits per screenshot grab). ‡ Browserless bills in units (roughly 1 unit per grab) and is a managed browser you drive with your own scripts, not a one-call REST endpoint.&lt;/p&gt;

&lt;p&gt;Browserless and Thum.io are cheaper per grab. The trade-offs are real: Browserless is a headless browser you drive with Puppeteer or Playwright, so you still write and maintain the capture code, and Thum.io has a narrow feature set (no full-page capture, no wait controls, no selector targeting). If raw per-grab price is the only thing you care about, one of those two is the honest recommendation, not Grabbit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the alternative to the pain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  If monthly resets are wasting money
&lt;/h3&gt;

&lt;p&gt;On a monthly reset plan, a team that captures 1,500 pages in January and 9,000 in March pays the full monthly allowance both months, and January's unused capacity evaporates. You pad the plan to cover the busy month and forfeit the difference every quiet one. Flat annual prepaid credits remove that pressure: you buy a block once, it covers actual usage, and nothing expires at the end of a cycle. For variable workloads (OG images on publish, monitoring runs, batch research), the billing model often matters more than the headline rate. See &lt;a href="https://www.grabbit.live/blog/screenshot-api-pricing" rel="noopener noreferrer"&gt;why monthly screenshot quotas waste money&lt;/a&gt; for the full math.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you need a first-party SDK
&lt;/h3&gt;

&lt;p&gt;ScreenshotOne maintains client libraries for Ruby, PHP, Go, Java, C#, and more. If you want a supported SDK in your language rather than a raw HTTP call, that is a genuine advantage and a good reason to stay with it. Grabbit is a plain REST endpoint plus an MCP server, not a per-language SDK set.&lt;/p&gt;

&lt;h3&gt;
  
  
  If an AI agent needs to see the page
&lt;/h3&gt;

&lt;p&gt;Most screenshot APIs were designed before agent frameworks existed: you get an API key and call the endpoint from backend code, which means custom glue for every agent that needs visual data. Grabbit and ScreenshotOne both ship an MCP server, so an agent registers capture as a callable tool and gets back a hosted &lt;code&gt;image_url&lt;/code&gt; with no glue code. If agent onboarding is why you are shopping, prefer a provider with a one-line or MCP path over a REST-only endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you just want the lowest possible rate
&lt;/h3&gt;

&lt;p&gt;Browserless ($0.0013) or Thum.io ($0.0002). Accept that Browserless means writing your own browser scripts and Thum.io means a thin feature set. That is the trade you make for the lowest number in the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try an alternative before you pay
&lt;/h2&gt;

&lt;p&gt;You can wire up Grabbit and run it in CI before adding a card. Every account gets a &lt;strong&gt;test key&lt;/strong&gt; (&lt;code&gt;sk_test_...&lt;/code&gt;) that returns a placeholder image at no charge; switch to a &lt;strong&gt;live key&lt;/strong&gt; (&lt;code&gt;sk_live_...&lt;/code&gt;) for real captures. Here is a real request against Grabbit's API:&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",
    "full_page": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes &lt;code&gt;image_url&lt;/code&gt; (the hosted screenshot), &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;execution_ms&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;. Set &lt;code&gt;full_page: false&lt;/code&gt; with a fixed &lt;code&gt;height&lt;/code&gt; (240 to 1080) for a viewport shot, add &lt;code&gt;delay_ms&lt;/code&gt; (up to 10000) to wait for JavaScript-heavy content, or &lt;code&gt;selector&lt;/code&gt; to clip to a single element. Point at a &lt;code&gt;sk_test_&lt;/code&gt; key first, so migrating an existing integration costs nothing to prototype.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monthly reset waste, variable volume:&lt;/strong&gt; Grabbit (flat annual, no reset).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lowest per-grab rate, willing to script:&lt;/strong&gt; Browserless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple thumbnails, minimal budget:&lt;/strong&gt; Thum.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-party SDKs for a non-JS backend:&lt;/strong&gt; ScreenshotOne.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An AI agent needs to call it:&lt;/strong&gt; Grabbit or ScreenshotOne (both ship MCP).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your comparison is really "managed cloud browser vs. a screenshot endpoint," see the &lt;a href="https://www.grabbit.live/blog/browserbase-alternative" rel="noopener noreferrer"&gt;Browserbase alternative breakdown&lt;/a&gt;. For a wider field than this table, read our &lt;a href="https://www.grabbit.live/blog/best-screenshot-api" rel="noopener noreferrer"&gt;honest comparison of the best screenshot APIs&lt;/a&gt;. For Grabbit's pricing, parameters, and agent onboarding, see &lt;a href="https://www.grabbit.live/screenshots-as-a-service" rel="noopener noreferrer"&gt;screenshots as a service&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-api-alternatives" 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>tools</category>
    </item>
    <item>
      <title>Browserbase Alternative: When You Need a Screenshot, Not a Whole Cloud Browser</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:43:50 +0000</pubDate>
      <link>https://dev.to/grabbit/browserbase-alternative-when-you-need-a-screenshot-not-a-whole-cloud-browser-dc1</link>
      <guid>https://dev.to/grabbit/browserbase-alternative-when-you-need-a-screenshot-not-a-whole-cloud-browser-dc1</guid>
      <description>&lt;p&gt;If you are searching for a Browserbase alternative, the first question worth asking is not "which vendor," it is "do I actually need a cloud browser?" Browserbase gives your code a full, programmable Chrome running in the cloud: persistent sessions, live interaction, stealth, the works. That is the right tool when you are driving a browser through multi-step flows. It is a lot of machinery when all you needed was a picture of a page.&lt;/p&gt;

&lt;p&gt;This is an honest comparison, including an explicit section on when you should stay on Browserbase.&lt;/p&gt;

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

&lt;p&gt;Browserbase is cloud browser infrastructure for AI agents and automation: you open a session, connect over CDP or an SDK, and operate a real browser remotely. It is built to run whole interaction flows, and it bills accordingly (by session and concurrency).&lt;/p&gt;

&lt;p&gt;If your job is narrower, capture a rendered screenshot of a URL, then a &lt;strong&gt;screenshot API&lt;/strong&gt; is the simpler and usually cheaper alternative. You send one request with a URL and get back a hosted image. There is no session to open, keep alive, or tear down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;Grabbit&lt;/a&gt; is that alternative when the job is a screenshot: &lt;strong&gt;$0.002 per capture&lt;/strong&gt; on flat prepaid credits that never reset or expire monthly, plus a built-in MCP server so an agent can call it as a tool. Grabbit is not a cloud browser and does not pretend to be one, and this post says plainly below when Browserbase is the correct choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different jobs that look similar
&lt;/h2&gt;

&lt;p&gt;The reason "Browserbase alternative" is a confusing search is that a screenshot and a browser session look related but are different jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cloud browser&lt;/strong&gt; is a live, stateful thing. You navigate, click, type, wait, read the DOM, maybe hand control to a human. You are &lt;em&gt;operating&lt;/em&gt; the page. Browserbase, Browserless, Hyperbrowser, Anchor Browser, and Steel all sell this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A screenshot API&lt;/strong&gt; is a stateless request. You name a URL and how you want it rendered, and you get an image. You are &lt;em&gt;observing&lt;/em&gt; the page, not operating it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the "Browserbase alternatives" you will find in a SERP are other cloud browsers, which makes sense if you truly need to drive a browser. But a large share of the traffic to that search is people who reached for a cloud browser because they thought a screenshot required one. It does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they compare for the screenshot job
&lt;/h2&gt;

&lt;p&gt;Scoped to "I need a rendered image of a URL," not to full browser automation. Cloud-browser pricing is session or minute based, so a per-grab comparison is only fair for the capture use case. Prices verified August 2026 from each provider's public pricing page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Billing shape&lt;/th&gt;
&lt;th&gt;Best when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grabbit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Screenshot API&lt;/td&gt;
&lt;td&gt;Per grab ($0.002), flat prepaid&lt;/td&gt;
&lt;td&gt;You need a rendered image of a URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browserbase&lt;/td&gt;
&lt;td&gt;Cloud browser&lt;/td&gt;
&lt;td&gt;Per session / concurrency&lt;/td&gt;
&lt;td&gt;You drive a real browser through steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browserless&lt;/td&gt;
&lt;td&gt;Cloud browser + REST screenshot&lt;/td&gt;
&lt;td&gt;Per unit / monthly&lt;/td&gt;
&lt;td&gt;You want full control and self-host options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hyperbrowser&lt;/td&gt;
&lt;td&gt;Cloud browser (containers)&lt;/td&gt;
&lt;td&gt;Per session / monthly&lt;/td&gt;
&lt;td&gt;Lightweight isolated sessions, CAPTCHA handling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read: if you need persistent sessions and live interaction, do not pick a screenshot API, pick a cloud browser. If you need a picture of a page, do not rent a whole browser to take it. Grabbit is also &lt;strong&gt;not the cheapest screenshot API&lt;/strong&gt; in the field; Browserless and Thum.io list lower per-grab rates. Grabbit's edge is the fit and the billing model, prepaid credits that never expire, not a bottom-of-market rate. For the wider field of screenshot APIs specifically, see our &lt;a href="https://www.grabbit.live/blog/best-screenshot-api" rel="noopener noreferrer"&gt;honest comparison of the best screenshot APIs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the billing shapes differ
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the cost comparison feel unfair until you see it: you are paying for different things.&lt;/p&gt;

&lt;p&gt;A cloud browser holds a real Chrome open for you. You pay for the time that browser is alive and for how many you run at once, because keeping browsers healthy at concurrency is the expensive, hard part (memory creep, restart storms, sticky workers). That cost is real and Browserbase absorbs it so you do not lose weekends to it.&lt;/p&gt;

&lt;p&gt;A screenshot API runs the render, uploads the image, and is done. There is no session to keep warm between calls, so it bills per capture. For a workload that is "render this URL, give me the image," paying for session time is paying for a capability you are not using.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it before you commit
&lt;/h2&gt;

&lt;p&gt;You can wire up Grabbit and run it in CI before adding a card. Every account gets a &lt;strong&gt;test key&lt;/strong&gt; (&lt;code&gt;sk_test_...&lt;/code&gt;) that returns a placeholder image at no charge; switch to a &lt;strong&gt;live key&lt;/strong&gt; (&lt;code&gt;sk_live_...&lt;/code&gt;) when you want real captures. Here is a real request against Grabbit's API:&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://www.browserbase.com",
    "width": 1280,
    "height": 720,
    "format": "webp",
    "full_page": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes &lt;code&gt;image_url&lt;/code&gt; (the hosted screenshot), &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;execution_ms&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;. Use &lt;code&gt;full_page: true&lt;/code&gt; for a scrolling capture, &lt;code&gt;delay_ms&lt;/code&gt; (up to 10000) to wait for JavaScript-heavy content to settle, or &lt;code&gt;selector&lt;/code&gt; to clip to a single element. Because test captures are free placeholders, prototyping the swap from a cloud-browser screenshot call to Grabbit costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  For AI agents
&lt;/h2&gt;

&lt;p&gt;Many people arrive at Browserbase because an agent needs to work with the web, and "the agent needs the web" gets rounded up to "the agent needs a whole browser." Sometimes it does: if the agent clicks, types, and navigates across steps, that is a cloud-browser job and Browserbase (or an agent framework on top of it) is the right call.&lt;/p&gt;

&lt;p&gt;But a large slice of agent-web work is just &lt;em&gt;seeing&lt;/em&gt; a page: feed the rendered view into a vision step, verify your own frontend, or ground the model in what a URL actually looks like. For that, a screenshot API is the lighter path. Grabbit ships an &lt;strong&gt;MCP server&lt;/strong&gt;, so the agent registers capture as a callable tool and gets back a hosted &lt;code&gt;image_url&lt;/code&gt; to pass downstream, with no browser session to manage. For the agent-eyes use case in depth, see &lt;a href="https://www.grabbit.live/blog/screenshots-for-ai-agents" rel="noopener noreferrer"&gt;screenshots for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to stay on Browserbase
&lt;/h2&gt;

&lt;p&gt;An honest comparison names when not to switch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need multi-step interaction.&lt;/strong&gt; Logging in, filling forms, clicking through a flow, reading the DOM between steps. A screenshot API captures a page; it cannot operate one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need persistent, authenticated sessions.&lt;/strong&gt; Holding a logged-in browser open across calls is exactly what a cloud browser is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You use an interactive agent framework.&lt;/strong&gt; Tools like Stagehand drive a real browser. A screenshot endpoint is not a substitute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need live takeover or stealth.&lt;/strong&gt; Human-in-the-loop control and advanced anti-bot handling are cloud-browser features, not screenshot-API features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grabbit's pitch is not "we replace Browserbase." It is narrower and honest: if the job is a screenshot, you do not need a whole cloud browser to take it, and paying per session for a per-image job is waste.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need a rendered image of a URL:&lt;/strong&gt; a screenshot API (Grabbit, or a cheaper-per-grab option if raw rate is all that matters).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You drive a browser through multi-step flows:&lt;/strong&gt; stay on Browserbase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a cloud browser but with self-host options:&lt;/strong&gt; Browserless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your agent needs to see, not operate, a page:&lt;/strong&gt; Grabbit (one call + MCP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your agent needs to click and navigate:&lt;/strong&gt; Browserbase or an interactive framework.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Grabbit's pricing, parameters, and agent onboarding, see the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; page. To schedule captures without running any browser yourself, see &lt;a href="https://www.grabbit.live/blog/screenshot-automation" rel="noopener noreferrer"&gt;screenshot automation&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/browserbase-alternative" 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>tools</category>
    </item>
    <item>
      <title>Why Monthly Screenshot Quotas Waste Your Money (Screenshot API Pricing, Explained)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Sun, 09 Aug 2026 11:42:15 +0000</pubDate>
      <link>https://dev.to/grabbit/why-monthly-screenshot-quotas-waste-your-money-screenshot-api-pricing-explained-1l04</link>
      <guid>https://dev.to/grabbit/why-monthly-screenshot-quotas-waste-your-money-screenshot-api-pricing-explained-1l04</guid>
      <description>&lt;p&gt;If you are comparing screenshot API pricing, the pricing pages all look roughly the same: a few tiers, a monthly price, a screenshot quota per tier, and overage per extra capture. The number that decides your real cost is not on the card. It is the quota that resets every month, and how much of it you actually use. On a spiky workload, a monthly reset plan makes you pay for a peak you hit occasionally and forfeit the rest. This post explains the mechanic, then shows how to read a pricing page so you buy captures instead of empty allowance.&lt;/p&gt;

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

&lt;p&gt;Most screenshot APIs bill a &lt;strong&gt;fixed monthly subscription with a reset quota&lt;/strong&gt;: you pay for, say, 2,000 captures a month whether you make 2,000 or 200, and next month the counter goes back to zero. That model is fine when your usage is steady. It quietly wastes money when your usage is &lt;strong&gt;spiky&lt;/strong&gt;, which screenshot workloads almost always are.&lt;/p&gt;

&lt;p&gt;The waste is the gap between what you provision and what you use. To cover an occasional busy month you size up your plan, then forfeit the unused allowance in every quiet month. The sticker price hides that gap.&lt;/p&gt;

&lt;p&gt;Grabbit takes the other approach: &lt;strong&gt;$50 per year for 25,000 prepaid captures&lt;/strong&gt; ($0.002 each) that &lt;strong&gt;never reset or expire monthly&lt;/strong&gt;. Grabbit is not the cheapest screenshot API on headline price, and this post is explicit about that below. The point here is the pricing &lt;em&gt;model&lt;/em&gt;, not a rock-bottom rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How screenshot API pricing usually works
&lt;/h2&gt;

&lt;p&gt;Walk a typical pricing page and you will see one of three billing models:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monthly subscription with a reset quota (the common one).&lt;/strong&gt; A tier bundles a fixed number of captures per month. Unused captures do not roll over. Go over and you pay overage per extra grab, or get blocked until next cycle. This is what most listings on a "screenshot api pricing" search use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage metered.&lt;/strong&gt; You pay per request with a small monthly minimum. No quota to waste, but the minimum spend is its own small floor, and metered plans often have a thinner feature set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prepaid credits.&lt;/strong&gt; You buy a block of captures once. They draw down as you use them and do not expire on a monthly boundary. Grabbit sells these on a flat annual plan.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The three models can show nearly identical per-grab math on paper and produce very different bills, because the reset quota charges you for allowance, not for work done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a reset quota wastes money on spiky workloads
&lt;/h2&gt;

&lt;p&gt;Screenshot volume is rarely a flat line. The real pattern looks like bursts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A batch of Open Graph images generated the day you publish a dozen posts.&lt;/li&gt;
&lt;li&gt;A visual monitoring run that fires on a schedule, then nothing until the next one.&lt;/li&gt;
&lt;li&gt;A one-off research crawl capturing a list of competitor pages, then quiet.&lt;/li&gt;
&lt;li&gt;An agent pipeline that captures heavily during a task and idles between tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put concrete numbers on it. Say you capture &lt;strong&gt;1,500 pages in January, 9,000 in March, and a few hundred most other months.&lt;/strong&gt; To not get blocked in March, you provision a plan sized for roughly 9,000 captures a month. In January you pay for that whole allowance and use a sixth of it. Every quiet month, you pay for thousands of captures you never make. Over a year, the forfeited allowance can dwarf the cost of the captures you actually took.&lt;/p&gt;

&lt;p&gt;That is the mechanic. &lt;strong&gt;A reset quota bills your peak every month and hands you nothing back for the troughs.&lt;/strong&gt; The lower the ratio of your average month to your busiest month, the more a monthly plan overcharges you.&lt;/p&gt;

&lt;p&gt;Prepaid credits invert it: you buy a block once, draw it down at your real pace, and the busy month and the quiet month both bill only the captures you made. For variable capture volume, the billing model usually matters more than a fraction of a cent on the per-grab rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat: cheapest sticker price is not Grabbit
&lt;/h2&gt;

&lt;p&gt;A "screenshot api pricing" search is full of pages claiming the lowest number, so here is the honest version. Grabbit is &lt;strong&gt;not&lt;/strong&gt; the cheapest screenshot API by headline price. Some providers advertise entry plans from $5 to $9 per month, and on raw per-grab rate &lt;strong&gt;Browserless ($0.0013)&lt;/strong&gt; and &lt;strong&gt;Thum.io ($0.0002)&lt;/strong&gt; both list lower numbers than Grabbit's $0.002.&lt;/p&gt;

&lt;p&gt;The trade-offs behind those lower numbers are real: Browserless is a managed headless browser you drive with your own Puppeteer or Playwright scripts rather than a single REST call, and Thum.io has a limited feature set (no full-page capture, no wait controls, no selector targeting). If the only thing you care about is the lowest per-grab rate and you are willing to script, one of those is the honest pick, not Grabbit.&lt;/p&gt;

&lt;p&gt;Grabbit's claim is narrower: flat annual billing with &lt;strong&gt;zero monthly reset waste&lt;/strong&gt;, and one-line agent onboarding. If your pain is a quota you keep forfeiting, that is the thing that saves you money, not the sticker price. Prices here were verified June 2026 from each provider's public pricing page, normalized to roughly 10,000 grabs per month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "temporary need" is the category's real pricing problem
&lt;/h2&gt;

&lt;p&gt;There is a deeper reason the reset-quota model fits this category badly. The screenshot API market skews toward &lt;strong&gt;temporary needs&lt;/strong&gt;: a project that ends, a marketing job that ships, a migration that finishes. When the market leader ScreenshotOne was publicly torn down in 2026, the founder said the category's core problem in the open: there is no single ICP it can scale with, and customers churn because the need was temporary. A monthly subscription is the worst possible fit for a temporary need, you keep paying after the work is done until you remember to cancel.&lt;/p&gt;

&lt;p&gt;Prepaid credits that never expire suit a temporary-or-spiky need better: you buy what the job needs, use it when the job runs, and nothing keeps billing you between jobs. (Whether that changes long-run retention is a separate question we are not claiming to have answered, it is a better fit for the buying pattern, full stop.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read a screenshot API pricing page
&lt;/h2&gt;

&lt;p&gt;When you land on a pricing page, get past the sticker price and check five things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reset or roll over?&lt;/strong&gt; Does unused quota carry forward, or evaporate at the cycle boundary? This is the single biggest driver of real cost on spiky usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effective cost per capture &lt;em&gt;you make&lt;/em&gt;.&lt;/strong&gt; Divide the plan price by the captures you realistically make in an average month, not by the quota. That is your true per-grab cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overage behavior.&lt;/strong&gt; Do you pay per extra grab, or get hard-blocked until next cycle? A hard block can break a publish or a monitoring run at the worst moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the free tier excludes.&lt;/strong&gt; Free plans often gate full-page capture, custom viewports, or output formats. Confirm the free number is a real preview of the paid product, not a stripped-down one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature parity across tiers.&lt;/strong&gt; Some providers put the parameters you need (selector targeting, wait delays, WebP) behind a higher tier, so the cheap plan cannot actually do your job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test the integration before you pay a cent
&lt;/h2&gt;

&lt;p&gt;You do not have to guess. Grabbit gives every account a &lt;strong&gt;test key&lt;/strong&gt; (&lt;code&gt;sk_test_...&lt;/code&gt;) that returns a placeholder image at no charge, so you can wire up the API and run it in CI before adding a card. Switch to a &lt;strong&gt;live key&lt;/strong&gt; (&lt;code&gt;sk_live_...&lt;/code&gt;) when you want real captures. Here is a real 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,
    "height": 720,
    "format": "webp",
    "full_page": true
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes &lt;code&gt;image_url&lt;/code&gt; (the hosted screenshot), &lt;code&gt;bytes&lt;/code&gt;, &lt;code&gt;execution_ms&lt;/code&gt;, and &lt;code&gt;id&lt;/code&gt;. Add &lt;code&gt;delay_ms&lt;/code&gt; (up to 10000) to wait for JavaScript-heavy content, or &lt;code&gt;selector&lt;/code&gt; to clip to a single element. Point at a &lt;code&gt;sk_test_&lt;/code&gt; key first: test captures are free placeholders, so proving out an integration costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spiky or seasonal volume, quota waste is your pain:&lt;/strong&gt; a flat prepaid plan like Grabbit (no monthly reset).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steady, predictable monthly volume:&lt;/strong&gt; a monthly subscription wastes nothing, and the per-grab math is what it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lowest per-grab rate, willing to write scripts:&lt;/strong&gt; Browserless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple thumbnails, minimal budget:&lt;/strong&gt; Thum.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low, flat volume and you enjoy owning infra:&lt;/strong&gt; self-host Puppeteer or Playwright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a full field comparison, see our &lt;a href="https://www.grabbit.live/blog/best-screenshot-api" rel="noopener noreferrer"&gt;honest comparison of the best screenshot APIs&lt;/a&gt; and the head-to-head &lt;a href="https://www.grabbit.live/blog/screenshotone-alternative" rel="noopener noreferrer"&gt;ScreenshotOne alternative&lt;/a&gt; breakdown. If an agent is what needs to capture pages, the &lt;a href="https://www.grabbit.live/blog/screenshots-for-ai-agents" rel="noopener noreferrer"&gt;screenshots for AI agents&lt;/a&gt; guide covers the integration path. For Grabbit's pricing, parameters, and onboarding, see &lt;a href="https://www.grabbit.live/screenshots-as-a-service" rel="noopener noreferrer"&gt;screenshots as a service&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-api-pricing" 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>tools</category>
    </item>
    <item>
      <title>Open Graph Meta Tags: The Complete Reference (Every Tag, With Examples)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:41:15 +0000</pubDate>
      <link>https://dev.to/grabbit/open-graph-meta-tags-the-complete-reference-every-tag-with-examples-36f6</link>
      <guid>https://dev.to/grabbit/open-graph-meta-tags-the-complete-reference-every-tag-with-examples-36f6</guid>
      <description>&lt;p&gt;Open Graph meta tags are the snippets in your page's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; that control how a link looks when someone shares it. Get them right and your URL unfurls into a rich card with a title, a summary, and an image. Miss them and the same link shows up as bare text. This is the complete reference: every tag worth setting, what each one does, and copy-paste examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer: five tags
&lt;/h2&gt;

&lt;p&gt;Most pages need exactly five Open Graph tags. Put these in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&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;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://yoursite.com/og/your-page.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:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/your-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:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"website"&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;Facebook, LinkedIn, Slack, Discord, iMessage, and WhatsApp all read these. Set them and your link previews work almost everywhere. The rest of this reference covers the optional tags and the X-specific ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The required tags, one by one
&lt;/h2&gt;

&lt;p&gt;These five carry the card. Skip any of them and the preview degrades.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The headline of the card. Independent of the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; element.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The summary line under the title. One or two sentences.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The picture in the card. Absolute URL, 1200 by 630 for the large card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The canonical URL of the page, without tracking parameters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;og:type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The category: &lt;code&gt;website&lt;/code&gt;, &lt;code&gt;article&lt;/code&gt;, &lt;code&gt;video.movie&lt;/code&gt;, and so on.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details that trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;og:title&lt;/code&gt; is not your &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;.&lt;/strong&gt; They can match, but &lt;code&gt;og:title&lt;/code&gt; lets you write a version tuned for sharing. Keep it under about 60 characters so it does not truncate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;og:url&lt;/code&gt; should be the canonical URL.&lt;/strong&gt; Use the clean permanent address, not the one with &lt;code&gt;?utm_source=...&lt;/code&gt; on it, so every share collapses to the same object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;og:image&lt;/code&gt; must be an absolute &lt;code&gt;https://&lt;/code&gt; URL.&lt;/strong&gt; A relative path like &lt;code&gt;/og.png&lt;/code&gt; will not resolve for the crawler. It also has to be publicly reachable: if the image needs a login, the card shows nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The image tags that make the large card render
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;og:image&lt;/code&gt; alone works, but three companion tags make the difference between a big card and a small thumbnail:&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;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://yoursite.com/og/your-page.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:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&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:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&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:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"A short description of the image"&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;Declaring &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt; lets the platform reserve the right space and render the large card immediately instead of guessing the dimensions on first fetch. Use &lt;strong&gt;1200 by 630 pixels&lt;/strong&gt;, the size every major platform treats as the full-width preview. &lt;code&gt;og:image:alt&lt;/code&gt; is the accessibility description, read by screen readers on platforms that expose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The optional tags worth setting
&lt;/h2&gt;

&lt;p&gt;Beyond the core five, a handful of tags add polish and matter for specific &lt;code&gt;og:type&lt;/code&gt; values:&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;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your Brand"&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:locale"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"en_US"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;og:site_name&lt;/code&gt;&lt;/strong&gt; is the name of the overall site, shown as a small label on some cards (LinkedIn, for instance). Set it once to your brand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;og:locale&lt;/code&gt;&lt;/strong&gt; declares the language and region, like &lt;code&gt;en_US&lt;/code&gt; or &lt;code&gt;fr_FR&lt;/code&gt;. Useful if your pages are localized.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When &lt;code&gt;og:type&lt;/code&gt; is &lt;code&gt;article&lt;/code&gt;, these structured tags become relevant and show up on news and blog cards:&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;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&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;"article:published_time"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"2026-08-08T09:00:00Z"&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;"article:author"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/authors/jane"&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;"article:section"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Engineering"&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;"article:tag"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"open graph"&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;You only need the &lt;code&gt;article:*&lt;/code&gt; tags on pages that are genuinely articles. For a marketing page or a landing page, &lt;code&gt;og:type&lt;/code&gt; of &lt;code&gt;website&lt;/code&gt; and the core five are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  X (Twitter) tags: what you actually need
&lt;/h2&gt;

&lt;p&gt;X reads its own &lt;code&gt;twitter:&lt;/code&gt; tags but falls back to your &lt;code&gt;og:&lt;/code&gt; tags when they are absent. The one tag you should always set is &lt;code&gt;twitter:card&lt;/code&gt;:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;summary_large_image&lt;/code&gt;, X renders a small square thumbnail beside the text instead of the big image card. Note the attribute difference: &lt;code&gt;og:&lt;/code&gt; tags use &lt;code&gt;property=&lt;/code&gt;, while &lt;code&gt;twitter:&lt;/code&gt; tags use &lt;code&gt;name=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can stop there. X will pull the title, description, and image from &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, and &lt;code&gt;og:image&lt;/code&gt;. Only add these if you want a &lt;em&gt;different&lt;/em&gt; headline or image on X than everywhere else:&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:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"A headline tuned for X"&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 summary tuned for X."&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://yoursite.com/og/your-page.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A complete, copy-paste example
&lt;/h2&gt;

&lt;p&gt;Here is the full &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; block for a typical article page, combining everything above:&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;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Open Graph Meta Tags: The Complete Reference&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Open Graph --&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;"Open Graph Meta Tags: The Complete Reference"&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;"Every og: tag worth setting, with examples."&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://yoursite.com/og/reference.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:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&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:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&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:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yoursite.com/blog/og-meta-tags"&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:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&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:site_name"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your Brand"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- X (Twitter) --&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: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;/head&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to generate the og:image from a URL
&lt;/h2&gt;

&lt;p&gt;Writing the tags is the easy part. The hard part is producing a unique, on-brand &lt;code&gt;og:image&lt;/code&gt; for every page without designing one by hand. The pattern that scales: build &lt;strong&gt;one HTML template&lt;/strong&gt; that renders the page title and your branding, then &lt;strong&gt;capture that template as an image&lt;/strong&gt; at 1200 by 630.&lt;/p&gt;

&lt;p&gt;A screenshot API turns that into a single request. Point it at your template URL with a fixed viewport, and get back a hosted image you can drop straight into &lt;code&gt;og: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 &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-template?title=Your+Post+Title",
    "width": 1200,
    "height": 630,
    "format": "png"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes the hosted image URL. Set &lt;code&gt;og:image&lt;/code&gt; to it, and every page gets its own preview card generated from real HTML, no design tool required. This is the same approach behind &lt;a href="https://www.grabbit.live/blog/next-js-og-image" rel="noopener noreferrer"&gt;dynamic OG images in Next.js&lt;/a&gt; and any other framework: render a URL, screenshot it, ship the result.&lt;/p&gt;

&lt;p&gt;For the deeper background on why the image matters and how platforms crop it, see &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;. Once your tags are live, run the page through an &lt;a href="https://www.grabbit.live/blog/og-image-checker" rel="noopener noreferrer"&gt;OG image checker&lt;/a&gt; to confirm the crawler sees what you expect, and if a preview looks wrong, an &lt;a href="https://www.grabbit.live/blog/open-graph-debugger" rel="noopener noreferrer"&gt;Open Graph debugger&lt;/a&gt; shows you the exact tags each platform scraped.&lt;/p&gt;

&lt;p&gt;Grabbit is a screenshots-as-a-service API built for exactly this job: point it at a URL, get back a hosted image, no headless browser to run. Live grabs are a flat $0.002 each on prepaid credits that never expire, and a test key renders free placeholders so you can wire up your OG pipeline before adding a card. See the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; for the full reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Injecting og: tags with client-side JavaScript.&lt;/strong&gt; Crawlers run little to no JS, so they see an empty &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Server-render your meta tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A relative &lt;code&gt;og:image&lt;/code&gt; path.&lt;/strong&gt; It must be an absolute &lt;code&gt;https://&lt;/code&gt; URL, or the image will not resolve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;twitter:card&lt;/code&gt;.&lt;/strong&gt; Without &lt;code&gt;summary_large_image&lt;/code&gt;, X shows a small thumbnail instead of the large card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not clearing the cache after a fix.&lt;/strong&gt; Platforms cache the scrape. Use each platform's debugger to force a re-scrape once you have corrected a tag.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/og-meta-tags" 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>Selenium vs Playwright: Which Should You Use in 2026?</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Fri, 07 Aug 2026 11:42:57 +0000</pubDate>
      <link>https://dev.to/grabbit/selenium-vs-playwright-which-should-you-use-in-2026-2idn</link>
      <guid>https://dev.to/grabbit/selenium-vs-playwright-which-should-you-use-in-2026-2idn</guid>
      <description>&lt;p&gt;Playwright and Selenium are the two tools most teams weigh when they automate a browser, and in 2026 the momentum is clearly with Playwright: it just passed Selenium in adoption surveys for the first time. But "which is winning" and "which should you use" are different questions. The right answer depends on your language, your existing suite, and whether you are automating tests or just need an image back from a URL. Here is the honest comparison.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New project, JavaScript or TypeScript or Python:&lt;/strong&gt; Playwright. Auto-waiting, a smaller API, and three bundled browser engines make it the faster path to reliable automation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing enterprise Selenium suite, or a non-JS ecosystem (Java, C#, Ruby):&lt;/strong&gt; Selenium is still a reasonable default. The WebDriver standard, the language breadth, and the grid ecosystem are hard to walk away from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need cross-browser tests with the least flakiness:&lt;/strong&gt; Playwright. One API drives Chromium, Firefox, and WebKit, and auto-waiting removes most timing bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You just need a screenshot of a URL, not a test framework:&lt;/strong&gt; neither. A &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;hosted screenshot API&lt;/a&gt; returns a hosted image from one request, with no browser to run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this post shows where the two actually differ so you can decide with the details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: WebDriver vs direct control
&lt;/h2&gt;

&lt;p&gt;The core difference is how each tool talks to the browser.&lt;/p&gt;

&lt;p&gt;Selenium drives browsers through the &lt;strong&gt;W3C WebDriver&lt;/strong&gt; protocol. Your script sends commands to a driver (chromedriver, geckodriver), which relays them to the browser over HTTP. That standardization is Selenium's superpower and its tax: it works with almost any browser and language, but every command is a separate round trip.&lt;/p&gt;

&lt;p&gt;Playwright talks to the browser over a &lt;strong&gt;single persistent connection&lt;/strong&gt; using the browser's own debugging protocol. Fewer round trips, and it can observe the page state directly, which is what makes auto-waiting possible.&lt;/p&gt;

&lt;p&gt;This one design choice explains most of the practical differences below: speed, waiting, and reliability all trace back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language and browser support
&lt;/h2&gt;

&lt;p&gt;This is where Selenium still leads.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Selenium&lt;/th&gt;
&lt;th&gt;Playwright&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;Java, Python, C#, Ruby, JavaScript, and more&lt;/td&gt;
&lt;td&gt;JavaScript/TypeScript, Python, Java, C#&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers&lt;/td&gt;
&lt;td&gt;Chrome, Firefox, Edge, Safari (real installs) + huge grid/cloud ecosystem&lt;/td&gt;
&lt;td&gt;Bundled Chromium, Firefox, WebKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;W3C WebDriver&lt;/td&gt;
&lt;td&gt;Browser debug protocol (not a standard)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your team is on Ruby, or you must drive a specific real browser build on a specific OS through a cloud grid, Selenium's breadth wins. If you want three engines that cover the matrix most apps care about, with zero driver management, Playwright's bundled browsers are simpler.&lt;/p&gt;

&lt;p&gt;Note that "WebKit" in Playwright is the engine behind Safari, not Safari itself, so it is a very close approximation rather than a byte-for-byte Safari render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-waiting: Playwright's biggest day-to-day win
&lt;/h2&gt;

&lt;p&gt;Flaky tests are almost always timing bugs. An element is not clickable yet, or the network has not settled, and a fixed &lt;code&gt;sleep&lt;/code&gt; either wastes time or fails intermittently.&lt;/p&gt;

&lt;p&gt;Selenium makes you manage this. The recommended pattern is an explicit wait:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;By&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;until&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;selenium-webdriver&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;driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forBrowser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chrome&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&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;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/login&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;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;until&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;elementLocated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;5000&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;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;click&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;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Playwright waits for you. Its locators auto-wait for the element to be attached, visible, and actionable before acting, so the same flow needs no explicit wait:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&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;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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/login&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&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;Across a full suite, deleting hundreds of &lt;code&gt;wait&lt;/code&gt; and &lt;code&gt;sleep&lt;/code&gt; calls is the single biggest reliability and maintenance improvement teams report after migrating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed
&lt;/h2&gt;

&lt;p&gt;Playwright is generally faster, and the architecture explains why. Selenium's per-command HTTP round trips add up over a long test, and explicit waits are often padded to be safe. Playwright's single connection and auto-waiting mean it acts as soon as the page is ready, not after a fixed delay.&lt;/p&gt;

&lt;p&gt;The gap is real on multi-step flows. On a single screenshot it is mostly noise, because page load time dominates and both tools spend that time the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locators: role and text over XPath
&lt;/h2&gt;

&lt;p&gt;Selenium's world is CSS and XPath selectors tied to the DOM structure. That works, but a renamed class or an extra wrapper &lt;code&gt;div&lt;/code&gt; breaks the locator.&lt;/p&gt;

&lt;p&gt;Playwright still supports CSS and XPath, but pushes you toward user-facing locators that read the accessibility tree:&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;// Playwright: resilient, user-facing locators&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;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user@example.com&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Welcome back&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These survive DOM refactors because they find the button labelled "Submit" rather than its position in the markup. It is a small API difference with a big effect on how often tests break when the frontend changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating from Selenium to Playwright
&lt;/h2&gt;

&lt;p&gt;Migration is worth it when flaky tests and explicit-wait upkeep are costing you real time, and your app is JavaScript-heavy. The wait-code deletion alone often pays for the move.&lt;/p&gt;

&lt;p&gt;It is not worth it when your Selenium suite is stable, your team lives in a language Playwright serves poorly, or your grid, reporting, and CI are deeply wired into WebDriver. A working suite is an asset; do not rewrite it for fashion.&lt;/p&gt;

&lt;p&gt;If you do migrate, do it incrementally: run new tests in Playwright while the Selenium suite keeps guarding old ground, and port flaky tests first, because those are exactly the ones auto-waiting fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshots: where both tools are overkill
&lt;/h2&gt;

&lt;p&gt;Both frameworks take screenshots, and the code is nearly identical. In Selenium:&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;image&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;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takeScreenshot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;page.png&lt;/span&gt;&lt;span class="dl"&gt;'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if a screenshot is the &lt;em&gt;only&lt;/em&gt; thing you need, either tool is a lot of moving parts. You are installing a driver or a bundled browser, running headless Chromium in your own infrastructure, and owning the parts nobody enjoys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provisioning.&lt;/strong&gt; Headless browsers need a long list of system libraries. Slim containers and serverless functions hit missing-dependency errors before the first pixel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory and zombie processes.&lt;/strong&gt; A browser not closed on every error path leaks memory and orphans processes until the box falls over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patching.&lt;/strong&gt; Browser engines ship security updates constantly, so a long-lived capture service becomes a browser fleet you keep current.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency.&lt;/strong&gt; One browser does one job at a time well. Thousands of captures a day means a pool, a queue, and back-pressure to build and operate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is test code, and it is the same whether you picked Selenium or Playwright.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same capture as one API call
&lt;/h2&gt;

&lt;p&gt;When screenshots are a feature you ship rather than a step inside a test, a hosted &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; skips the browser entirely. Here is a full-page capture as a single request to Grabbit:&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 response includes a hosted &lt;code&gt;image_url&lt;/code&gt; you can use directly:&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 options you reach for in either framework map onto request parameters: &lt;code&gt;fullPage&lt;/code&gt; becomes &lt;code&gt;full_page&lt;/code&gt;, a locator or selector becomes the &lt;code&gt;selector&lt;/code&gt; field, and a manual wait becomes &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000). Width accepts 320 to 1920, height 240 to 1080, and &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;. Pricing is a flat $0.002 per capture on prepaid credits that never reset, so a screenshot you run once a week costs the same per image as one you run a thousand times a day.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greenfield project, want the least flakiness:&lt;/strong&gt; Playwright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing enterprise suite, or a non-JS stack, or a specific real-browser grid:&lt;/strong&gt; Selenium.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning automation from scratch in 2026:&lt;/strong&gt; start with Playwright, add Selenium for enterprise roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You just need an image from a URL, not a test framework:&lt;/strong&gt; skip the browser and call an API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the framework-specific deep dives, see &lt;a href="https://www.grabbit.live/blog/playwright-screenshot" rel="noopener noreferrer"&gt;taking screenshots in Playwright&lt;/a&gt; and &lt;a href="https://www.grabbit.live/blog/selenium-screenshot" rel="noopener noreferrer"&gt;taking screenshots in Selenium&lt;/a&gt;. If you are weighing hosted options, 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, and &lt;a href="https://www.grabbit.live/blog/puppeteer-vs-playwright" rel="noopener noreferrer"&gt;Puppeteer vs Playwright&lt;/a&gt; compares the two Chromium-first libraries head to head.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/selenium-vs-playwright" 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 Run Headless Chrome in Docker for Screenshots (and the Maintenance Tax It Adds)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Thu, 06 Aug 2026 11:41:20 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-run-headless-chrome-in-docker-for-screenshots-and-the-maintenance-tax-it-adds-2pl0</link>
      <guid>https://dev.to/grabbit/how-to-run-headless-chrome-in-docker-for-screenshots-and-the-maintenance-tax-it-adds-2pl0</guid>
      <description>&lt;p&gt;You can run headless Chrome in Docker, and for a controlled workload it is the right call. The catch is that a bare &lt;code&gt;apt install chromium&lt;/code&gt; plus &lt;code&gt;chrome --headless&lt;/code&gt; does not work: Chrome refuses to start, crashes on big pages, renders text as empty boxes, and leaves zombie processes behind. This guide gives you a Dockerfile that actually runs, explains each flag that makes it work, and names the ongoing maintenance so you can decide whether to run Chrome yourself or hand the render off to an API.&lt;/p&gt;

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

&lt;p&gt;To run headless Chrome in a container you need four things the base image does not give you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chromium plus its shared libraries&lt;/strong&gt; (a slim base ships neither).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The right launch flags&lt;/strong&gt;: &lt;code&gt;--no-sandbox&lt;/code&gt;, &lt;code&gt;--disable-gpu&lt;/code&gt;, &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts&lt;/strong&gt;, or every screenshot renders text as boxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An init process&lt;/strong&gt; to reap the zombie Chrome children, plus a &lt;strong&gt;non-root user&lt;/strong&gt; so &lt;code&gt;--no-sandbox&lt;/code&gt; is not a gaping hole.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss any one and it fails in a way whose error message does not name the real cause. That is the whole reason this problem has a dozen Stack Overflow answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Dockerfile that works
&lt;/h2&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; node:20-slim&lt;/span&gt;

&lt;span class="c"&gt;# Chromium + the shared libs a slim base is missing, fonts, and an init.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;      chromium &lt;span class="se"&gt;\
&lt;/span&gt;      fonts-liberation fonts-noto-color-emoji &lt;span class="se"&gt;\
&lt;/span&gt;      libnss3 libatk-bridge2.0-0 libgbm1 libasound2 &lt;span class="se"&gt;\
&lt;/span&gt;      tini &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Run as a non-root user, because we launch with --no-sandbox below.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; chrome
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; chrome&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /home/chrome&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; CHROME_BIN=/usr/bin/chromium&lt;/span&gt;

&lt;span class="c"&gt;# tini (PID 1) reaps the zombie Chrome children a long-running renderer leaves.&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["tini", "--"]&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["chromium", \&lt;/span&gt;
     "--headless=new", \
     "--no-sandbox", \
     "--disable-gpu", \
     "--disable-dev-shm-usage", \
     "--screenshot=/home/chrome/out.png", \
     "--window-size=1280,720", \
     "https://example.com"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build and run it, then copy the screenshot out:&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; chrome-shot &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;:/out"&lt;/span&gt; chrome-shot &lt;span class="se"&gt;\&lt;/span&gt;
  chromium &lt;span class="nt"&gt;--headless&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;new &lt;span class="nt"&gt;--no-sandbox&lt;/span&gt; &lt;span class="nt"&gt;--disable-dev-shm-usage&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--screenshot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/out/out.png &lt;span class="nt"&gt;--window-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1280,720 https://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after &lt;code&gt;chrome-shot&lt;/code&gt; overrides the &lt;code&gt;CMD&lt;/code&gt;, so you can point it at any URL without rebuilding.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each flag is actually fixing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--no-sandbox&lt;/code&gt;&lt;/strong&gt; is the flag everyone hits first. Chrome's sandbox needs Linux user namespaces and kernel calls that Docker's default seccomp and AppArmor profiles block, so Chrome cannot fork its sandboxed children and exits with a terse &lt;code&gt;Failed to move to new namespace&lt;/code&gt;. &lt;code&gt;--no-sandbox&lt;/code&gt; skips the sandbox. It is a real security boundary you are removing, which is exactly why the Dockerfile above runs as a non-root &lt;code&gt;chrome&lt;/code&gt; user: trusted URLs plus a non-root user keeps the blast radius small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;&lt;/strong&gt; is the one that bites later, in production, on a page you did not test with. Docker mounts &lt;code&gt;/dev/shm&lt;/code&gt; at 64MB by default. Chrome uses that shared-memory region for its render process, and a large or image-heavy page overruns it, crashing the tab with &lt;code&gt;session deleted because of page crash&lt;/code&gt;. This flag tells Chrome to write to &lt;code&gt;/tmp&lt;/code&gt; instead. The alternative is &lt;code&gt;docker run --shm-size=1gb&lt;/code&gt;, but the flag travels with the image, so prefer it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--disable-gpu&lt;/code&gt;&lt;/strong&gt; avoids the container trying to reach a GPU that is not there. Harmless to keep even where it is no longer strictly required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--headless=new&lt;/code&gt;&lt;/strong&gt; opts into the modern headless mode (the default implementation since Chrome 112), which is the same browser as headful Chrome, so what you capture matches what a user sees. Older tutorials use bare &lt;code&gt;--headless&lt;/code&gt;, which on current Chrome maps to the new mode anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts the tutorials skip
&lt;/h2&gt;

&lt;p&gt;The Dockerfile runs. That is not the same as being done, and the gap is where the maintenance tax lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fonts.&lt;/strong&gt; A slim base image ships no fonts, so text renders as tofu boxes and emoji disappear. You install font packages by hand (&lt;code&gt;fonts-liberation&lt;/code&gt;, &lt;code&gt;fonts-noto-color-emoji&lt;/code&gt;, and CJK packs if you capture non-Latin pages). Every new script you need to render is another package to remember.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zombie processes.&lt;/strong&gt; Chrome spawns child processes, and a long-running container without a real &lt;code&gt;init&lt;/code&gt; as PID 1 accumulates defunct entries until the process table is a mess. That is what &lt;code&gt;tini&lt;/code&gt; (or &lt;code&gt;dumb-init&lt;/code&gt;, or &lt;code&gt;docker run --init&lt;/code&gt;) is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory growth.&lt;/strong&gt; Chrome's memory climbs over a long-lived process, and under load it does not always come back down. The common production workaround is to restart the renderer on a schedule or after N captures, which is its own moving part to build and watch. This is the exact pain developers describe: Chromium memory leaks forcing you to constantly kill and restart pods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patching.&lt;/strong&gt; A pinned Chromium is a frozen attack surface. Keeping it current means rebuilding the image on Chrome's release cadence, then re-testing that none of the flags above regressed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is hard once. All of it is forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to hand the render to an API instead
&lt;/h2&gt;

&lt;p&gt;Running Chrome in Docker earns its keep when you want full control of the browser, capture at high volume where per-request cost matters, or need the container for more than screenshots. If all you need is "give me an image of this URL," the container is a lot of standing infrastructure for one HTTP call.&lt;/p&gt;

&lt;p&gt;A hosted screenshot API removes the image, the flags, the fonts, the &lt;code&gt;/dev/shm&lt;/code&gt; sizing, the zombie reaping, and the restart cron. You send a URL and get a hosted image back:&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,
    "height": 720,
    "full_page": true,
    "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; you can cache or embed. The same request handles the things the container makes you handle yourself: it waits for the page, sizes the viewport, and captures the full scrolling page when you pass &lt;code&gt;full_page: true&lt;/code&gt;. Add &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) to wait for late-loading content, &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;) to pick the output, or &lt;code&gt;selector&lt;/code&gt; to crop to a single element.&lt;/p&gt;

&lt;p&gt;Grabbit is prepaid at a flat $0.002 per live grab, and the credits do not reset or expire monthly, so a spiky screenshot workload does not pay for a subscription tier it never fills. Test-environment keys return a placeholder for free, so you can wire it up before adding a card. This is not the cheapest per-grab rate on the market, but it is one HTTP call with no container to keep alive. See the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; for the full parameter reference.&lt;/p&gt;

&lt;p&gt;The honest trade is control versus operations. If you want the browser under your own roof, the Dockerfile above is a solid start, and the &lt;a href="https://www.grabbit.live/blog/puppeteer-memory-leak" rel="noopener noreferrer"&gt;maintenance failure modes are worth reading up front&lt;/a&gt;. If you would rather not run Chrome at all, &lt;a href="https://www.grabbit.live/blog/playwright-docker" rel="noopener noreferrer"&gt;skipping the container&lt;/a&gt; is the point of the API.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/chrome-headless-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>Playwright vs Cypress: Which Should You Use in 2026?</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:41:05 +0000</pubDate>
      <link>https://dev.to/grabbit/playwright-vs-cypress-which-should-you-use-in-2026-17ik</link>
      <guid>https://dev.to/grabbit/playwright-vs-cypress-which-should-you-use-in-2026-17ik</guid>
      <description>&lt;p&gt;Playwright and Cypress are both strong end-to-end testing frameworks, and for many projects either one works. The short version: &lt;strong&gt;choose Playwright&lt;/strong&gt; if you need cross-browser coverage (including Safari/WebKit), free parallel execution, or multi-tab and cross-origin flows; &lt;strong&gt;choose Cypress&lt;/strong&gt; if you are a JavaScript-only team building a single-page app and you value its interactive time-travel runner and fast local feedback. This guide walks the real differences, gives a recommendation per project type, and covers the part both tools handle the same way in principle but differently in practice: screenshots.&lt;/p&gt;

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

&lt;p&gt;If you want a one-line decision rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New project, cross-browser matters, CI-heavy suite&lt;/strong&gt; → Playwright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-page app, Chromium-only, you want the smoothest local debugging&lt;/strong&gt; → Cypress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team already fluent in one of them, no hard blocker&lt;/strong&gt; → stay where you are; both are actively maintained.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below is the reasoning behind that rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser coverage
&lt;/h2&gt;

&lt;p&gt;This is the biggest single difference. &lt;strong&gt;Playwright drives Chromium, Firefox, and WebKit natively&lt;/strong&gt;, so you can run the same test across all three engines, WebKit being the closest practical stand-in for Safari. &lt;strong&gt;Cypress is Chrome-family first&lt;/strong&gt;; it runs in Chromium-based browsers and Firefox, but it has no real WebKit/Safari support.&lt;/p&gt;

&lt;p&gt;If "does it work in Safari?" is a question your team has to answer, Playwright answers it and Cypress does not. If you only ship to Chromium users, this difference does not affect you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed and parallelization
&lt;/h2&gt;

&lt;p&gt;Playwright controls the browser out-of-process over the DevTools protocol, while Cypress runs inside the browser's execution loop alongside your app. In practice Playwright tends to run large suites faster, and independent benchmarks put it ahead on raw execution.&lt;/p&gt;

&lt;p&gt;The bigger practical gap is parallelization. &lt;strong&gt;Playwright shards tests across workers for free, out of the box.&lt;/strong&gt; Cypress can parallelize too, but its optimal parallel execution and the run dashboard have historically been a paid Cypress Cloud subscription. For a large CI suite, that is a real cost line, not a footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language support
&lt;/h2&gt;

&lt;p&gt;Cypress is JavaScript and TypeScript only. Playwright supports JavaScript, TypeScript, Python, Java, and .NET. If your backend team writes tests in Python or your org standardizes on C#, Playwright lets them share one tool. For a pure JS/TS shop this is a non-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tab, multiple origins, and iframes
&lt;/h2&gt;

&lt;p&gt;Cypress runs in a single-origin browser sandbox. That design gives it a lot of its ergonomics, but it makes multi-tab flows, cross-origin navigation, and some iframe interactions awkward or unsupported. Playwright handles multiple tabs, multiple browser contexts, and cross-origin navigation natively.&lt;/p&gt;

&lt;p&gt;If your tests cross domains, open new tabs (OAuth popups, payment redirects), or juggle several user sessions at once, Playwright removes a class of friction here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developer experience and debugging
&lt;/h2&gt;

&lt;p&gt;This is where Cypress earns its loyalty. The &lt;strong&gt;interactive time-travel runner&lt;/strong&gt; lets you step back through each command and see the app's state at every point, and automatic waiting makes tests read cleanly without manual sleeps. For local development on a single-page app, many developers still find Cypress the more pleasant tool.&lt;/p&gt;

&lt;p&gt;Playwright has closed most of the gap with its Trace Viewer, UI mode, and codegen, and its &lt;code&gt;expect&lt;/code&gt; auto-retries similarly to Cypress. The debugging experience is now excellent on both; Cypress's edge here is narrower than it was two years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;Both frameworks are open source and free to run yourself. The costs show up at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cypress&lt;/strong&gt;: parallel test orchestration and the dashboard are a paid Cypress Cloud plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright&lt;/strong&gt;: parallelization is free, but you still pay for CI minutes and for maintaining the browser binaries your runners download and update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither cost is the framework license. Both are the operational tax of running real browsers in CI, which is worth keeping in mind for the screenshot section below.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Playwright&lt;/th&gt;
&lt;th&gt;Cypress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chromium, Firefox, WebKit/Safari&lt;/td&gt;
&lt;td&gt;Yes, all three&lt;/td&gt;
&lt;td&gt;Chromium-family, no WebKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free native parallelization&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Paid (Cypress Cloud)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;JS, TS, Python, Java, .NET&lt;/td&gt;
&lt;td&gt;JS, TS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple tabs / cross-origin&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive time-travel runner&lt;/td&gt;
&lt;td&gt;Trace Viewer / UI mode&lt;/td&gt;
&lt;td&gt;Yes, its signature feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Cross-browser, CI-heavy, polyglot teams&lt;/td&gt;
&lt;td&gt;JS single-page apps, local-first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where both frameworks agree: screenshots for UI verification
&lt;/h2&gt;

&lt;p&gt;Both Playwright and Cypress can take screenshots, and both do it well for the app under test. &lt;code&gt;cy.screenshot()&lt;/code&gt; in Cypress and &lt;code&gt;page.screenshot()&lt;/code&gt; in Playwright each capture what the runner controls, and both auto-capture on failure so you get evidence when a test breaks. For an in-suite capture of your own app, use the framework you already picked. For the Cypress specifics see &lt;a href="https://www.grabbit.live/blog/cypress-screenshot" rel="noopener noreferrer"&gt;how to take screenshots in Cypress&lt;/a&gt;; for Playwright see &lt;a href="https://www.grabbit.live/blog/playwright-screenshot" rel="noopener noreferrer"&gt;how to take screenshots in Playwright&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two capture jobs sit outside what either framework is built for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capturing a URL your test suite does not control.&lt;/strong&gt; A third-party page, a partner's site, or your own production URL. Cypress restricts cross-origin navigation, and pointing a whole Playwright run at an external site just to grab one image is heavy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent renders across environments.&lt;/strong&gt; A screenshot's fonts, emoji, and anti-aliasing depend on the OS the browser runs on. A shot from your macOS laptop and one from a Linux CI runner differ, which produces false diffs in &lt;a href="https://www.grabbit.live/blog/playwright-visual-regression-testing" rel="noopener noreferrer"&gt;visual regression testing&lt;/a&gt;. Baselines need one fixed rendering environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For both jobs, a hosted screenshot API captures a URL from a fixed environment and returns an image, so you skip provisioning and maintaining a browser for that one task. Here is the same "capture a page" idea as a single request to Grabbit:&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/pricing",
    "width": 1280,
    "height": 720,
    "full_page": true,
    "format": "webp"
  }'&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 can archive or diff:&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;52340&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;1240&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 options you know from either framework map across directly. A full-page capture becomes &lt;code&gt;"full_page": true&lt;/code&gt;, an element capture becomes a &lt;code&gt;"selector"&lt;/code&gt; field, and any wait you would add before a shot becomes &lt;code&gt;"delay_ms"&lt;/code&gt; (0 to 10000). Width accepts 320 to 1920, height 240 to 1080, and &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;.&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;# capture one component from a live URL, after it settles&lt;/span&gt;
&lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "url": "https://example.com/dashboard",
  "selector": "[data-testid=chart]",
  "delay_ms": 800,
  "format": "png",
  "width": 1280,
  "height": 720
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grabbit is prepaid at $0.002 per live grab, and credits do not reset or expire monthly, so an occasional external capture in CI does not carry a subscription's worth of overhead. Test-environment keys return free placeholder images, so you can wire it up before adding a card.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Playwright has the momentum, and for cross-browser, CI-heavy, or polyglot projects it is the clearer pick in 2026. Cypress is far from obsolete: for a JavaScript single-page app where local debugging experience matters most, it is still an excellent choice. Pick on your actual requirements, browser targets, parallel scale, languages, and multi-origin needs, not on which framework is trending.&lt;/p&gt;

&lt;p&gt;Whichever you choose, when you need to screenshot a URL your suite does not control or produce a consistent baseline from a fixed environment, reach for a &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; instead of bending your test runner to do it. For choosing a hosted service honestly, the &lt;a href="https://www.grabbit.live/blog/best-screenshot-api" rel="noopener noreferrer"&gt;screenshot API comparison&lt;/a&gt; weighs the trade-offs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/playwright-vs-cypress" 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>HTML to PNG: How to Render HTML to a PNG Image (URL to PNG via API)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Tue, 04 Aug 2026 11:41:53 +0000</pubDate>
      <link>https://dev.to/grabbit/html-to-png-how-to-render-html-to-a-png-image-url-to-png-via-api-4eed</link>
      <guid>https://dev.to/grabbit/html-to-png-how-to-render-html-to-a-png-image-url-to-png-via-api-4eed</guid>
      <description>&lt;p&gt;The reliable way to render HTML to a PNG is to host the HTML at a URL, then capture that URL with a screenshot API and &lt;code&gt;format&lt;/code&gt; set to &lt;code&gt;png&lt;/code&gt;. The API loads the page in a real headless browser and returns a hosted PNG image. This is the same pattern behind a dynamic OG image or a generated card: your HTML lives at a route, and one API call turns it into a pixel-accurate image.&lt;/p&gt;

&lt;p&gt;The reason to lead with the URL is that most "HTML to PNG" jobs are really "render this page and give me a PNG." The top converters agree: PDFCrowd's own answer to "can I convert HTML to image" is to open its web-page tab and enter the URL, and cdkm and Convertio both take a URL as the input. If you have HTML you control, publishing it at a URL first is what makes the capture repeatable from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Render an HTML page to PNG with one API call
&lt;/h2&gt;

&lt;p&gt;Host your HTML at a URL (a public page, a template route in your app, or a local server), then POST that URL. PNG is the default &lt;code&gt;format&lt;/code&gt;, so a plain capture already returns a PNG:&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://example.com/card",
    "width": 1200,
    "height": 630,
    "format": "png"
  }'&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/card"&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;630&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;41280&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;910&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-08-04T09: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 in a page, or hand it to another service. Because PNG is the default, leaving &lt;code&gt;format&lt;/code&gt; off entirely still returns a PNG.&lt;/p&gt;

&lt;p&gt;The parameters you will reach for most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;width&lt;/code&gt; (320 to 1920) and &lt;code&gt;height&lt;/code&gt; (240 to 1080) set the canvas. For a social card, 1200 by 630 matches the standard OG size.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;full_page: true&lt;/code&gt; captures the entire scrollable document instead of stopping at the viewport height, which is what you want for a long template or a document-style page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) waits before the capture, useful when your template renders a chart or fetches data after load.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;selector&lt;/code&gt; crops the capture to a single element, so you can point the API at one card in a larger page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The honest limit: a URL, not a raw HTML string
&lt;/h2&gt;

&lt;p&gt;A URL-based screenshot API renders a URL. It does not accept a raw HTML string in the POST body. If your HTML only exists as a string in memory, the reliable pattern is to host it at a route first, then capture that route.&lt;/p&gt;

&lt;p&gt;In practice this is less work than it sounds, and it is the same approach that powers dynamic OG images:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a template route in your app, for example &lt;code&gt;/card&lt;/code&gt;, that renders the HTML you want as an image.&lt;/li&gt;
&lt;li&gt;Pass the dynamic parts as query parameters, like &lt;code&gt;/card?title=Launch&amp;amp;user=nico&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Point the screenshot API at that URL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One template route then produces unlimited images, and the HTML stays versioned in your codebase instead of living as a pasted blob in a converter. If your goal is a chart or a card that is already rendered in the browser in front of the user, that is a different job. A client-side library such as &lt;code&gt;html-to-image&lt;/code&gt; rasterizes a DOM node directly with canvas, no server involved. It cannot render a URL on a server, so it does not help in a cron job or a CI pipeline, but for exporting what the user is looking at right now it is the right tool. See &lt;a href="https://www.grabbit.live/blog/html-to-image" rel="noopener noreferrer"&gt;HTML to Image: How to Render HTML to PNG, JPG, or WebP&lt;/a&gt; for that side-by-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PNG (and when to pick JPG or WebP instead)
&lt;/h2&gt;

&lt;p&gt;PNG earns its place when the output has to be exact. It is lossless, so text stays sharp and edges stay crisp with no compression artifacts, and it supports transparency, which matters when a card or badge needs to sit on a colored background. Those two properties also make PNG the standard baseline format for visual regression testing: a lossless render compares byte-for-byte against the previous run, so a real UI change is not drowned out by JPG compression noise.&lt;/p&gt;

&lt;p&gt;The tradeoff is file size. A full-page PNG of a long document can get large. When size matters more than a perfect pixel, JPG produces the smallest file for photographic content and WebP produces the smallest file overall at similar quality. Because the API takes &lt;code&gt;format&lt;/code&gt; per request, you can render the same URL as a PNG for your test baselines and a WebP for the version you ship to users, changing one word. For a JPG-specific walkthrough, see &lt;a href="https://www.grabbit.live/blog/webpage-to-jpg" rel="noopener noreferrer"&gt;How to Convert a Webpage to JPG&lt;/a&gt;; for the general URL-to-image path, &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 same call without the format focus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an API over a local converter
&lt;/h2&gt;

&lt;p&gt;Running the conversion yourself means installing headless Chromium, keeping it patched, and tearing down a browser session after every render. That is a real maintenance tax for what is ultimately one HTTP call. A hosted screenshot API moves the rendering machinery somewhere else: you send a URL, you get back a hosted PNG, and there is no browser binary in your deploy.&lt;/p&gt;

&lt;p&gt;Grabbit prices this at a flat $0.002 per live grab, with prepaid credits that never reset or expire monthly, so a $50 annual plan is 25,000 renders you can spend whenever you need them. Test-environment keys return free placeholder images, so you can wire the whole flow up before adding a card. See the &lt;a href="https://www.grabbit.live/screenshot-api" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; for the full parameter reference.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.grabbit.live/blog/html-to-png" 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 Convert a Webpage to JPG (URL to JPG Image via API)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:45:35 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-convert-a-webpage-to-jpg-url-to-jpg-image-via-api-4boa</link>
      <guid>https://dev.to/grabbit/how-to-convert-a-webpage-to-jpg-url-to-jpg-image-via-api-4boa</guid>
      <description>&lt;p&gt;To convert a webpage to a JPG, send the page URL to a screenshot API with the format set to &lt;code&gt;jpeg&lt;/code&gt;, and it returns a hosted JPG image. One HTTP request in, one image URL out. The headless browser that renders the page 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 is the whole request, an important distinction the search results blur, when JPG is actually the right format, and how to get full-page and dimension 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 a JPG 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": "jpeg"
  }'&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....jpeg"&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;"jpeg"&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;61240&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;910&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-08-03T09: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. Note the format value is &lt;code&gt;jpeg&lt;/code&gt;, not &lt;code&gt;jpg&lt;/code&gt;. They mean the same file, but the API takes the spelled-out &lt;code&gt;jpeg&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the distinction that matters
&lt;/h2&gt;

&lt;p&gt;Search for "webpage to jpg" and the results split into two jobs that sound alike but are opposites. Getting the wrong one wastes your time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File conversion (not this).&lt;/strong&gt; Most of the top results, like iLoveIMG, CloudConvert, Adobe Express, and Canva, are WebP-to-JPG converters. You upload an image file you already have, and they change its format. The input is a picture. If you already own a &lt;code&gt;.webp&lt;/code&gt; and want a &lt;code&gt;.jpg&lt;/code&gt;, that is your tool, and you do not need an API for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL to image (this).&lt;/strong&gt; You have a web address, not a file, and you want a picture of the live page as it renders. FreeConvert's "Web Page to JPG Converter" and the "convert HTML webpage to JPG" how-tos are this job. The input is a URL. The page has to be loaded in a browser, laid out, and captured, and that is what a screenshot API does.&lt;/p&gt;

&lt;p&gt;This guide is about the second job: URL in, JPG out. If you have an image file and just need a format change, use one of the file converters above and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Converting a webpage URL to JPG 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 couple of 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;jpeg&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;jpeg&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. The &lt;a href="https://www.grabbit.live/blog/screenshot-from-url" rel="noopener noreferrer"&gt;screenshot from URL guide&lt;/a&gt; walks through the same call in more detail if you are wiring it up for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When JPG is the right format (and when it is not)
&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 &lt;code&gt;jpeg&lt;/code&gt; is a deliberate choice. JPG earns its place in two situations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File size is the priority.&lt;/strong&gt; JPG produces the smallest file of the three formats, which matters when you are storing thousands of captures or when a full-page shot of a long page would otherwise be several megabytes. The tradeoff is lossy compression, so fine text can soften slightly at low quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need universal support.&lt;/strong&gt; Every browser, CMS, email client, and image pipeline handles JPG without a second thought. It is the safe default when you do not control where the image ends up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for a different format when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You need lossless output or transparency.&lt;/strong&gt; Use PNG. For a &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;visual regression baseline&lt;/a&gt; the image has to be byte-stable so a real UI change is the only thing that produces a diff, and JPG's compression can shift a few pixels between renders. The &lt;a href="https://www.grabbit.live/blog/website-to-png" rel="noopener noreferrer"&gt;website to PNG guide&lt;/a&gt; covers that case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want small files without the JPG artifacts.&lt;/strong&gt; Use WebP. It compresses better than JPG at similar visual quality, which is why it is a good default for large libraries of screenshots. The &lt;a href="https://www.grabbit.live/blog/webpage-to-image" rel="noopener noreferrer"&gt;webpage to image guide&lt;/a&gt; compares all three formats side by side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switching is a one-word change in the request body, so you can render the same page as &lt;code&gt;jpeg&lt;/code&gt;, &lt;code&gt;png&lt;/code&gt;, and &lt;code&gt;webp&lt;/code&gt; and compare the file sizes on your own pages before committing.&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 JPG, 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": "jpeg"
  }'&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 where JPG pulls ahead: a full-page capture of a long landing page or article can be very tall, and JPG keeps that file small where PNG would balloon. 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 like lazy loading and sticky headers.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;width&lt;/code&gt; must be between 320 and 1920 pixels, and &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 is the better call
&lt;/h2&gt;

&lt;p&gt;An API is not always the answer. Render locally instead 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 local &lt;code&gt;page.screenshot()&lt;/code&gt; in Puppeteer 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 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; 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," then save the result.&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 JPGs in a pipeline. For the full format comparison, see &lt;a href="https://www.grabbit.live/blog/webpage-to-image" rel="noopener noreferrer"&gt;webpage to image&lt;/a&gt;, and for the lossless alternative, &lt;a href="https://www.grabbit.live/blog/website-to-png" rel="noopener noreferrer"&gt;website to PNG&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-jpg" 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 Percy Visual Testing Works (and When a Screenshot API Fits Your Workflow)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Thu, 30 Jul 2026 11:43:43 +0000</pubDate>
      <link>https://dev.to/grabbit/how-percy-visual-testing-works-and-when-a-screenshot-api-fits-your-workflow-1kpb</link>
      <guid>https://dev.to/grabbit/how-percy-visual-testing-works-and-when-a-screenshot-api-fits-your-workflow-1kpb</guid>
      <description>&lt;p&gt;Percy is a hosted visual testing platform, now part of BrowserStack, that catches UI changes you did not mean to ship. You add a snapshot call to your existing tests, Percy renders each snapshot in its own browser fleet, diffs it against a stored baseline, and shows the visual differences in a review dashboard where someone approves or rejects the change. This guide walks through how that pipeline works, where it shines, and the narrower job where a screenshot API is the simpler fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Percy visual testing works
&lt;/h2&gt;

&lt;p&gt;Percy is visual regression testing with the hard parts managed for you: baseline storage, consistent rendering, and a review UI. The pipeline has four stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Snapshot.&lt;/strong&gt; In your test, you call Percy's snapshot function at the moment you want to capture the page. With the Playwright or Cypress SDK it looks like one extra line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;percySnapshot&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@percy/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;homepage renders correctly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;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="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;percySnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Homepage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The snapshot call does not diff anything locally. It serializes the page's DOM and assets and uploads them to Percy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Render.&lt;/strong&gt; Percy takes that serialized DOM and renders it in its own browser fleet, across the browser and viewport-width combinations you configure. Rendering server-side is the point: it removes the "passes on my Mac, fails in CI" problem that plagues locally captured baselines, because every snapshot is rendered in the same controlled environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Diff against the baseline.&lt;/strong&gt; Percy compares each freshly rendered snapshot against the stored baseline for that page (the last approved version). It highlights the changed pixels. The first time a snapshot runs there is no baseline, so that render becomes the baseline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Review and approve.&lt;/strong&gt; Changed snapshots land in a Percy dashboard. A reviewer looks at the highlighted diff and either approves it (an intentional change, which becomes the new baseline) or rejects it (a regression to fix). Approval can gate the pull request, so a visual change cannot merge until a human has signed off.&lt;/p&gt;

&lt;p&gt;The value is the loop: your existing tests feed snapshots in, and Percy handles rendering, baselines, and the human review step so you are not eyeballing every page after every commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Percy is good at
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reviewing intended vs. unintended change.&lt;/strong&gt; The dashboard and approve/reject flow are the real product. Percy is built around a human confirming that a visual change was on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-browser and responsive diffs.&lt;/strong&gt; Because it renders server-side, one snapshot can be checked across several browsers and viewport widths without you running each locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Component and page coverage inside a test suite.&lt;/strong&gt; If you already have Playwright, Cypress, or Storybook tests, adding snapshots is incremental.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the cost adds up
&lt;/h2&gt;

&lt;p&gt;Percy prices per snapshot, per browser, per commit. That number grows fast: pages times viewport widths times browsers times how often your suite runs. A free tier covers small projects, but a busy team testing many pages across several browsers on every push can move through an allotment quickly. That is the tradeoff for a managed review UI, and it is worth it when reviewing visual change is the job. It is overkill when all you need is to capture and store an image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a screenshot API fits instead
&lt;/h2&gt;

&lt;p&gt;Percy diffs and reviews. A &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt; captures. Those are different jobs, and plenty of "visual" tasks are pure capture with no diff or review step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capturing pages outside your test suite.&lt;/strong&gt; A live production page, a marketing page, or a third-party page you do not control is a capture job, not a test assertion. There is no baseline to approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stable baseline or reference image.&lt;/strong&gt; If you want a consistent render of your deployed page to compare against, capturing it through a hosted browser gives you one image that does not depend on any developer's machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled snapshots for records or monitoring.&lt;/strong&gt; Grabbing the same set of URLs on a schedule to archive or watch them is capture, not per-commit regression testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those, you do not need a snapshot SDK wired into a test runner or a per-browser review dashboard. You need one request that returns 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://example.com",
    "width": 1280,
    "full_page": true,
    "format": "png"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you a hosted image URL you can store as a reference or feed into your own diffing:&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;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;"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;96420&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;1240&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, &lt;code&gt;full_page&lt;/code&gt; captures the whole scroll height, &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;, and &lt;code&gt;delay_ms&lt;/code&gt; (0 to 10000) lets late-loading content settle before the shot. To be clear about scope: Grabbit captures the images. The diffing, the approve/reject review, and the pass/fail belong in Percy or your VRT tool. Pricing is flat at $0.002 per capture with credits that do not reset monthly, so a scheduled capture job has a predictable cost rather than a per-snapshot meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Percy or a screenshot API: which to reach for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reach for Percy&lt;/strong&gt; when the job is reviewing visual change: you want a managed baseline, cross-browser diffs, and a human approving each change before it merges, driven from your existing tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reach for a screenshot API&lt;/strong&gt; when the job is capture: pages outside your suite, a stable reference image, or scheduled snapshots, where there is no baseline to approve and you just need the image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many teams use both. Percy guards the components and pages inside the test suite; a screenshot API captures everything that lives outside it.&lt;/p&gt;

&lt;p&gt;For the fuller picture, see the &lt;a href="https://www.grabbit.live/blog/visual-regression-testing" rel="noopener noreferrer"&gt;practical guide to visual regression testing&lt;/a&gt; and the &lt;a href="https://www.grabbit.live/blog/visual-regression-testing-tools" rel="noopener noreferrer"&gt;roundup of visual regression testing tools&lt;/a&gt;. If your stack is Playwright, &lt;a href="https://www.grabbit.live/blog/playwright-visual-regression-testing" rel="noopener noreferrer"&gt;Playwright visual regression testing&lt;/a&gt; shows the built-in, no-service version of the same loop.&lt;/p&gt;




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

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>devops</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Build Rich Link Previews Like Slack and iMessage</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Wed, 29 Jul 2026 11:40:38 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-build-rich-link-previews-like-slack-and-imessage-3m5</link>
      <guid>https://dev.to/grabbit/how-to-build-rich-link-previews-like-slack-and-imessage-3m5</guid>
      <description>&lt;p&gt;When you paste a link into Slack, iMessage, or Discord, it usually expands into a card with a title, a short description, and an image. That card is a rich link preview, and when yours shows up as a bare URL instead, the cause is almost always the same across every platform: the page is missing the Open Graph tags those apps read, or its &lt;code&gt;og:image&lt;/code&gt; points at something they cannot fetch. This guide covers how the previews work, the exact tags to add, and how to give the card a real image of your page instead of a generic logo.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a rich link preview works
&lt;/h2&gt;

&lt;p&gt;Slack, iMessage, Discord, and the social platforms do not screenshot your page on demand or guess at its content. When a URL is posted, the app fetches your page's raw HTML and looks for &lt;a href="https://www.grabbit.live/blog/what-is-an-og-image" rel="noopener noreferrer"&gt;Open Graph&lt;/a&gt; meta tags in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. Four tags drive the entire card:&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;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;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:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yourdomain.com/page"&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;&lt;code&gt;og:title&lt;/code&gt; and &lt;code&gt;og:description&lt;/code&gt; become the text. &lt;code&gt;og:image&lt;/code&gt; becomes the picture. &lt;code&gt;og:url&lt;/code&gt; is the canonical link the card points at. If a page has no Open Graph tags at all, the app falls back to the bare URL with no card. That is what a "broken" preview almost always is: missing or unreachable tags, not a bug in Slack or iMessage.&lt;/p&gt;

&lt;p&gt;Two details trip people up on every platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The fetcher reads the raw HTML, not the rendered page.&lt;/strong&gt; It does not run your JavaScript. If your meta tags are injected client-side (a single-page app that sets them after load), the fetcher 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;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. Use the full &lt;code&gt;https://yourdomain.com/og.png&lt;/code&gt;, and make sure that URL is reachable without a login or a redirect the app cannot follow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tags every platform reads
&lt;/h2&gt;

&lt;p&gt;The good news for anyone building link previews into an app: you do not write a different set of tags per platform. Open Graph is the shared standard, so one correct set of tags produces a rich card in Slack, iMessage, Discord, Facebook, and LinkedIn at once. Add one X-specific tag and you cover X (Twitter) too:&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;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;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:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://yourdomain.com/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: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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;twitter:card&lt;/code&gt; set to &lt;code&gt;summary_large_image&lt;/code&gt; tells X to render the wide card instead of a small thumbnail; X falls back to your &lt;code&gt;og:image&lt;/code&gt; for the picture, so you do not need a separate &lt;code&gt;twitter:image&lt;/code&gt;. That is the whole tag set. The work that remains is producing the image, and making sure the tags actually reach the fetcher.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;og:title&lt;/code&gt; and &lt;code&gt;og:description&lt;/code&gt; are just strings. The &lt;code&gt;og:image&lt;/code&gt; is the part that makes a preview feel rich, and it is also the part most people get wrong by shipping a single generic logo for every URL. For a marketing page, a docs page, or a product page, the best preview image 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, and use that image as your &lt;code&gt;og: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": 630,
    "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;630&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;71240&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;1240&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 630 card is comfortably 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 tag:&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;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 630, 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;, and the per-platform fix-it details are in &lt;a href="https://www.grabbit.live/blog/discord-link-preview" rel="noopener noreferrer"&gt;how to make your Discord links unfurl&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 1200 by 630 covers every platform
&lt;/h2&gt;

&lt;p&gt;Slack, iMessage, Discord, Facebook, X, and LinkedIn all render the same 1.91 to 1 aspect ratio as a large card, and 1200 by 630 pixels is the size that satisfies all of them. That is why one &lt;code&gt;og:image&lt;/code&gt; fixes the preview everywhere at once: you are not producing a Slack image and an iMessage image and a Discord image, you are producing one Open Graph image that every reader parses.&lt;/p&gt;

&lt;p&gt;Smaller images still work, but many apps fall back to a small thumbnail beside the text below roughly 300 pixels wide, which reads as a weaker preview. Stay at 1200 by 630 and the card is full-width on every platform.&lt;/p&gt;

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

&lt;p&gt;If the tags are present and the image URL works in a browser but a platform still shows nothing, work through these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The platform cached the old version.&lt;/strong&gt; Slack, iMessage, Discord, and Facebook all cache the metadata they fetched the first time a URL was posted, so editing your tags will not update a preview that already rendered. Append a unique query string, such as &lt;code&gt;?nocache=1&lt;/code&gt;, and post the link again so the app treats it as a new URL and re-fetches. Facebook and X also offer manual re-scrape tools in their sharing debuggers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image is behind auth or a redirect.&lt;/strong&gt; If &lt;code&gt;og:image&lt;/code&gt; requires a login, sits behind a paywall, or redirects in a way the fetcher will not follow, the card renders without an image. Test the raw image URL in a private browser window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tags are set by JavaScript.&lt;/strong&gt; The fetchers read static HTML and do not execute scripts. Server-render the meta tags so they are in the initial response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image is too small.&lt;/strong&gt; Below roughly 300 pixels wide, many apps render a small thumbnail instead of a full card. Use 1200 by 630.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Re-fetch first, because a stale cache is the single most common reason a correct tag set still shows nothing.&lt;/p&gt;

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

&lt;p&gt;A rich link preview is your page's Open Graph tags rendered as a card, and every platform reads the same four tags. Add &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, and &lt;code&gt;og:url&lt;/code&gt; server-side, plus &lt;code&gt;twitter:card&lt;/code&gt; for X; make &lt;code&gt;og:image&lt;/code&gt; an absolute, public, 1200 by 630 URL; and append &lt;code&gt;?nocache=1&lt;/code&gt; to force a refresh after changes. 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 &lt;code&gt;og:image&lt;/code&gt; at. 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/rich-link-preview" 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 Screenshot Every Page of a Website (Build a Visual Site Audit)</title>
      <dc:creator>Nico Acosta</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:39:49 +0000</pubDate>
      <link>https://dev.to/grabbit/how-to-screenshot-every-page-of-a-website-build-a-visual-site-audit-4pkg</link>
      <guid>https://dev.to/grabbit/how-to-screenshot-every-page-of-a-website-build-a-visual-site-audit-4pkg</guid>
      <description>&lt;p&gt;You are redesigning a site, migrating a CMS, or auditing a competitor, and you need a screenshot of every page, not just the one you happen to have open. The head query for this reads two ways. One is manual: capture the single long page in front of you. The other is the job most developers actually mean here: turn a whole site into one image per page for a visual inventory. This guide is about the second one.&lt;/p&gt;

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

&lt;p&gt;To screenshot every page of a website, do two things in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enumerate the routes.&lt;/strong&gt; Get a flat list of the site's URLs, usually from its &lt;code&gt;sitemap.xml&lt;/code&gt; or a small crawl step you run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loop the list through a capture.&lt;/strong&gt; Send each URL to a screenshot API one at a time and collect the hosted image for every page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no magic "shoot the whole site in one call" primitive. Every tool does the same thing underneath: it visits each page in turn and renders it. The real work is building the route list and driving the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the manual tools stop at one page
&lt;/h2&gt;

&lt;p&gt;The top of this SERP is full of single-page capture tools: the Chrome command menu's "Capture full size screenshot," Edge's Web capture, and extensions like GoFullPage. They are excellent at one thing, capturing the entire scrollable length of the page you are currently on. None of them screenshot a &lt;em&gt;site&lt;/em&gt;. You would have to open every page yourself and press the capture key on each, which is exactly the tedium you are trying to avoid on a fifty-page audit.&lt;/p&gt;

&lt;p&gt;For a true whole-site job you need the route list and a loop, which means code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: enumerate the site's routes
&lt;/h2&gt;

&lt;p&gt;Before you can capture every page, you need to know every page. Two reliable sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sitemap.&lt;/strong&gt; Most sites publish &lt;code&gt;https://example.com/sitemap.xml&lt;/code&gt;, an XML file listing their canonical URLs. Fetch it, parse the &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; entries, and you have your list. This is the cleanest source because the site owner already curated it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A crawl step.&lt;/strong&gt; No sitemap? Run a small crawler that starts at the homepage, follows internal links, and collects unique routes. Keep it scoped to the site's own domain so you do not wander off.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way you end up with a plain array of URLs. That array is the only input the capture loop needs.&lt;/p&gt;

&lt;p&gt;One honest note up front: a screenshot API captures the URLs you hand it. It does not crawl your site or follow links on its own. The enumeration in this step is a job you run; the API's job starts once you have the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: loop the routes through a screenshot API
&lt;/h2&gt;

&lt;p&gt;With the list in hand, each capture is a single HTTP request. You send a URL, you get back a hosted image. No browser to install, nothing to keep patched. Here is the full pattern in Node: read the routes, capture each one full-page, and collect the results.&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;// routes.json is your enumerated list, e.g. parsed from sitemap.xml&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&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;routes&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;routes.json&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;utf8&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;audit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;routes&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;res&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="nx"&gt;url&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;full_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;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="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;res&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;audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="na"&gt;image_url&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;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;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each response includes a hosted &lt;code&gt;image_url&lt;/code&gt;, so you can drop the results straight into a CSV, a database, or a static gallery page. Because every request is independent, one page that 404s or hangs never sinks the rest of the audit. This is the same repeatable, scriptable job &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;Grabbit's automated screenshots&lt;/a&gt; are built for, and you can wire it up with a free test key before adding a card. For the mechanics of driving a flat list of URLs, see &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;; a whole-site audit is that same loop, fed by the routes you enumerated in step 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options that matter for a whole-site audit
&lt;/h2&gt;

&lt;p&gt;Each call in the loop is configured on its own, so a mixed set of pages is no problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;full_page: true&lt;/code&gt;&lt;/strong&gt; captures the entire scrollable page, not just the viewport. Essential for an audit where you want the whole page on record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;width&lt;/code&gt;&lt;/strong&gt; sets the viewport (320 to 1920). Use one consistent width across the whole site so the pages line up when you compare them side by side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;format&lt;/code&gt;&lt;/strong&gt; can be &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;jpeg&lt;/code&gt;, or &lt;code&gt;webp&lt;/code&gt;. WebP keeps a large audit small on disk; PNG is worth it when you want lossless baselines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;delay_ms&lt;/code&gt;&lt;/strong&gt; waits up to 10 seconds before the capture, which helps on pages that fade content in after load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selector&lt;/code&gt;&lt;/strong&gt; captures just one element by CSS selector, useful if your audit only cares about, say, the header or a pricing table on every page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a large site where you do not want to hold a connection open per request, the same endpoint accepts &lt;code&gt;Prefer: respond-async&lt;/code&gt; (or &lt;code&gt;?async=true&lt;/code&gt;) and returns immediately with a job you poll later. That keeps a several-hundred-page run from blocking your script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the captures into an audit
&lt;/h2&gt;

&lt;p&gt;The reason to screenshot every page is rarely the images themselves, it is what you do with them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redesign inventory.&lt;/strong&gt; Before a rebuild, capture the current site so you have a visual record of every page you are replacing. Nothing gets forgotten in the migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visual QA after a deploy.&lt;/strong&gt; Re-run the same route list on staging and production and eyeball the pages that changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competitor teardown.&lt;/strong&gt; Snapshot a competitor's key pages, then re-run the list next month. Save each &lt;code&gt;image_url&lt;/code&gt; next to its source URL and a timestamp and the diff over time tells a story, which is the foundation of &lt;a href="https://www.grabbit.live/blog/website-change-monitoring" rel="noopener noreferrer"&gt;website change monitoring&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the loop is deterministic and hands-off, you can run it &lt;a href="https://www.grabbit.live/blog/screenshot-automation" rel="noopener noreferrer"&gt;on a schedule&lt;/a&gt; with cron or a CI job and have a fresh visual snapshot of the whole site whenever you need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Screenshotting every page of a website is always two steps: enumerate the routes, then loop them through a capture. Consumer extensions cover the single page in front of you; a whole-site audit needs the route list (from a sitemap or a crawl you run) and a loop that turns each URL into a hosted image. Point the loop at your list, collect the image URLs, and you have a repeatable visual audit that runs anywhere, including CI and serverless. To automate it end to end, see &lt;a href="https://www.grabbit.live/automated-screenshots" rel="noopener noreferrer"&gt;Grabbit's automated screenshots&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-every-page-website" 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>
  </channel>
</rss>
