DEV Community

Cover image for How to Build Rich Link Previews Without Maintaining a Browser Instance
Vitalii Holben
Vitalii Holben

Posted on

How to Build Rich Link Previews Without Maintaining a Browser Instance

The problem with link previews

You paste a URL in Slack and get a nice preview card. Title, description, maybe a thumbnail. Users expect the same thing in your app.

The "standard" approach is parsing Open Graph tags. Fetch the page, grab og:title, og:image, done. Works for maybe 60% of URLs. The rest either don't have OG tags, have broken ones, or the image URL is a relative path that resolves to nothing.

So you think: I'll just screenshot the page and use that. Spin up Puppeteer, navigate to the URL, take a screenshot, crop it, serve it. Now you're maintaining a headless browser in production.

Why self-hosted Puppeteer gets painful

I ran this setup for about four months. Here's what went wrong:

Memory leaks. Chromium tabs that don't close properly. After a few hundred screenshots, the container would eat 2GB+ of RAM and start timing out. Had to add a cron job that restarted the service every 6 hours.

Font rendering. Pages with custom fonts looked wrong because the server didn't have them installed. You can install common font packages but you'll never have everything.

SPA timing. React and Vue apps need you to wait for JS to finish rendering. waitUntil: 'networkidle0' works sometimes. Other times you need custom wait logic per site.

Scaling. Each screenshot blocks a browser tab for 2-10 seconds. Want to handle 50 concurrent requests? That's 50 Chromium instances. Good luck with your cloud bill.

The API approach

After the third OOM incident, I moved to ScreenshotRun and the implementation got boring — in a good way. One HTTP call, get back an image.

async function getLinkPreview(url) {
  const apiUrl = `https://api.screenshotrun.com/capture`;

  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.SCREENSHOT_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      url: url,
      viewport: { width: 1200, height: 630 },
      format: 'webp',
      wait_for: 'networkidle',
      clip: { x: 0, y: 0, width: 1200, height: 630 }
    })
  });

  return response.url; // CDN URL to the screenshot
}
Enter fullscreen mode Exit fullscreen mode

That's it. No browser pool, no memory management, no font issues.

Caching strategy

You don't want to screenshot the same URL every time someone loads a page. I cache the preview image URL in Redis with a 24-hour TTL:

async function getCachedPreview(url) {
  const cacheKey = `preview:${hashUrl(url)}`;
  const cached = await redis.get(cacheKey);

  if (cached) return cached;

  const imageUrl = await getLinkPreview(url);
  await redis.setex(cacheKey, 86400, imageUrl);

  return imageUrl;
}
Enter fullscreen mode Exit fullscreen mode

For most apps, a 24-hour cache is fine. Users rarely share the same URL twice within minutes, and sites don't change their layout that often.

OG tags as fallback

I still parse OG tags first. If the page has a decent og:image (actual URL, not a relative path, responds with 200), I use that. Screenshots are the fallback for pages without good metadata.

async function getPreview(url) {
  const og = await parseOGTags(url);

  if (og.image && await isValidImage(og.image)) {
    return {
      title: og.title,
      description: og.description,
      image: og.image,
      source: 'og'
    };
  }

  // Fall back to screenshot
  const screenshot = await getCachedPreview(url);
  return {
    title: og.title || new URL(url).hostname,
    description: og.description || '',
    image: screenshot,
    source: 'screenshot'
  };
}
Enter fullscreen mode Exit fullscreen mode

Numbers after switching

  • Response time: ~800ms for cache miss (API screenshot), ~5ms for cache hit
  • Memory: from 2GB+ (self-hosted Chromium) down to baseline Node.js (~150MB)
  • Reliability: zero OOM crashes in 3 months vs. weekly restarts before
  • Cost: roughly $15/month for our volume vs. $80/month for the beefy container

Not every link needs a screenshot. But for the ones that do, offloading the browser to an API saved us from a maintenance headache that kept growing.

Top comments (0)