# Generate OG images with Puppeteer yourself — and when not to

> Build your own OG image endpoint with Puppeteer: template, setContent, caching, escaping. Measured: 39 ms per warm JPEG, 0.7 s cold, ~470 MB RAM.

Published 2026-11-06 · https://shareglint.com/en/guides/og-image-with-puppeteer

Generating [Open Graph](https://ogp.me/) images with Puppeteer means rendering an HTML card at 1200×630 in headless Chrome and saving a screenshot of it. The core is ten lines; the work is around them: keeping Chrome alive, waiting for fonts, escaping titles, caching, and serving the file fast to crawlers. Below is a complete version, with numbers we measured on 2026-11-06 using Puppeteer 25.7.0 in the official Docker image (with an emoji font added) on 2 CPU cores, and an honest note on when an API is less work.

## The card

The template is plain HTML and CSS at the exact size. Flexbox, gradients, web fonts and emoji work as in any page — that is the main advantage over SVG-based generators:

```javascript
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));

const card = ({ title, site }) => `<!doctype html><html><head><style>
  html, body { margin: 0; width: 1200px; height: 630px; }
  body { display: flex; flex-direction: column; justify-content: space-between;
         padding: 72px; box-sizing: border-box; color: #fff;
         background: linear-gradient(135deg, #0f172a, #1e3a8a); font-family: "DejaVu Sans", sans-serif; }
  h1 { font-size: 64px; line-height: 1.1; margin: 0; }
  footer { font-size: 28px; opacity: .8; }
</style></head><body><h1>${escape(title)}</h1><footer>${escape(site)}</footer></body></html>`;
```

**Escape every value.** Titles come from your CMS and users. Without escaping, we put `<img src=x onerror="…">` into the title and the script in it **ran** inside the renderer. In a browser that can fetch URLs, that is a server-side request forgery waiting to happen; at the very least it breaks the card.

## The renderer

```javascript
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();           // once per process
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 1 });

let queue = Promise.resolve();                       // one render at a time on this page

export function renderOgImage(data) {
  const job = queue.then(async () => {
    await page.setContent(card(data), { waitUntil: 'load' });
    await page.evaluate(() => document.fonts.ready);
    return page.screenshot({ type: 'jpeg', quality: 85 });
  });
  queue = job.catch(() => {});
  return job;
}
```

Measured with a warm browser, twenty different titles:

| | Median per image | File size |
|---|---|---|
| JPEG, quality 85 | 39 ms | 39 KB |
| PNG | 163 ms | 322 KB |
| Launch, render and close Chrome for each image | about 740 ms | — |

JPEG was four times faster than PNG for this gradient card and eight times smaller. Use PNG for flat colours with sharp text, where it is often smaller than JPEG, and keep `deviceScaleFactor: 1` — link previews show the image well below 1200 px wide.

## Cache it, or it renders on every crawl

Crawlers fetch `og:image` again and again — every share on every platform. Render once per distinct card:

```javascript
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';

export async function ogImageFile(data) {
  const key = createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16);
  const file = `/var/cache/og/${key}.jpg`;
  if (!existsSync(file)) await writeFile(file, await renderOgImage(data));
  return file;
}
```

Serve the file with a long `Cache-Control` (the URL changes when the data changes), or upload it to object storage and put that URL into `og:image`. Rendering at build time for a static site is even simpler: loop over your posts and write the files next to the HTML.

## Production details

- **Memory.** Chrome with one page used about 470 MB summed over its processes in our container — an upper bound, because shared memory is counted in each process, but plan for at least 1 GB on the machine. A 512 MB serverless function is tight for Chrome.
- **Fonts and emoji.** Containers usually lack emoji and some scripts; the official Puppeteer image had no emoji font. Install the font packages, or embed your fonts as data URLs. Details in [emoji and fonts in Puppeteer](https://shareglint.com/en/guides/puppeteer-emoji-and-fonts).
- **Images in the card.** Remote images (author photos, covers) are fetched by Chrome during `setContent` ([Puppeteer docs](https://pptr.dev/api/puppeteer.page.setcontent)); a slow image host slows every render. Download and inline them, or set a short timeout.
- **Concurrency.** One page renders one card at a time; for more throughput open a small pool of pages — size it by measuring on your machine — not a browser per request.
- **Crashes.** Listen for `browser.on('disconnected')` and relaunch; a renderer that silently stops leaves every new share without an image.
- **Security.** Turn off JavaScript for the card if you do not need it (`page.setJavaScriptEnabled(false)`) and block network requests you do not expect with request interception.

## When not to do it yourself

Your own Puppeteer is a good choice when you already run a Node server with memory to spare, the cards are few, and you are happy to own Chrome updates and fonts. It is the wrong one when:

- **Your host is serverless or small** (Vercel, Netlify, Cloudflare, a 512 MB container): Chrome's size and memory are the problem, not the code.
- **The site is static or a no-code builder** with no server to run Chrome on.
- **You need many sizes and formats** — the link preview, a square post, a story — from the same design.

Shareglint is that renderer as a service: HTML templates (153 built in, or your own HTML and CSS), 6 size presets from one design, JPEG/PNG/WebP/PDF, caching of identical requests, and **signed URLs** you can put straight into `og:image` from a static site, with no API call at render time ([signed image URLs](https://shareglint.com/en/guides/signed-image-urls)). The Free plan has 100 renders a month with a watermark; Starter is $9 a month for 2,000 ([pricing](https://shareglint.com/en/pricing)). For framework-specific setups see the [OG image generator pages](https://shareglint.com/en/og-image-generator); for Next.js without `@vercel/og` limits, [this guide](https://shareglint.com/en/guides/nextjs-og-images-without-vercel-og-limits).

## FAQ

### How do I generate an OG image with Puppeteer?

Render an HTML card at 1200×630 with page.setContent, wait for fonts, and call page.screenshot. Keep one browser running, escape every value you put into the HTML, and cache the result by its inputs so each card renders once.

### How fast is it?

In our test with a warm browser, a 1200×630 card took a median 39 ms as JPEG and 163 ms as PNG, including setContent. Launching Chrome for each image added about 0.7 seconds.

### How much memory does it need?

Chrome with one page used about 470 MB summed over its processes in our container (an upper bound, as shared memory is counted more than once). Plan for at least 1 GB on the machine that renders.

### When is an API better than my own Puppeteer?

When you would rather not run Chrome in production — on serverless hosts, small servers or static sites — or when you need many sizes, signed URLs for og:image and caching without building them. For a site that already runs a Node server with memory to spare, your own Puppeteer is fine.
