DEV Community

Vitalii Holben
Vitalii Holben

Posted on

How to Build a Link Preview Thumbnail Service in Node.js

Ever built a link aggregator, bookmarking tool, or dashboard that shows URLs? At some point you need thumbnails for those links. Users paste a URL, your app should show a preview — like Slack, Twitter cards, or Notion bookmarks.

You could self-host a browser instance and manage Puppeteer yourself. I did that for about a year. Scaling it was painful, and the memory leaks were real.

Here's how I rebuilt it with a screenshot API instead.

What we're building

A simple Express service that:

  1. Accepts a URL
  2. Returns a thumbnail image (cached)
  3. Handles errors gracefully

The whole thing is under 80 lines.

Setup

mkdir link-thumbs && cd link-thumbs
npm init -y
npm install express node-cache axios sharp
Enter fullscreen mode Exit fullscreen mode

The service

const express = require("express");
const NodeCache = require("node-cache");
const axios = require("axios");
const sharp = require("sharp");
const crypto = require("crypto");

const app = express();
const cache = new NodeCache({ stdTTL: 86400 }); // 24h cache

const API_KEY = process.env.SCREENSHOT_API_KEY;
const API_URL = "https://screenshotrun.com/api/screenshot";

async function getThumbnail(url, width = 1280, thumbWidth = 400) {
  const cacheKey = crypto
    .createHash("md5")
    .update(url + "-" + width + "-" + thumbWidth)
    .digest("hex");

  const cached = cache.get(cacheKey);
  if (cached) return cached;

  const response = await axios.get(API_URL, {
    params: {
      url,
      width,
      format: "png",
      full_page: false,
    },
    headers: { Authorization: "Bearer " + API_KEY },
    responseType: "arraybuffer",
    timeout: 30000,
  });

  // resize to thumbnail
  const thumb = await sharp(response.data)
    .resize(thumbWidth, null, { fit: "inside" })
    .webp({ quality: 80 })
    .toBuffer();

  cache.set(cacheKey, thumb);
  return thumb;
}

app.get("/thumb", async (req, res) => {
  const { url, w } = req.query;

  if (!url) {
    return res.status(400).json({ error: "url parameter required" });
  }

  // basic URL validation
  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: "invalid url" });
  }

  try {
    const thumb = await getThumbnail(url, 1280, parseInt(w) || 400);
    res.set("Content-Type", "image/webp");
    res.set("Cache-Control", "public, max-age=86400");
    res.send(thumb);
  } catch (err) {
    console.error("Failed: " + url, err.message);
    res.status(502).json({ error: "screenshot failed" });
  }
});

app.listen(3000, () => console.log("Thumbnail service on :3000"));
Enter fullscreen mode Exit fullscreen mode

Using it

# get a thumbnail
curl "http://localhost:3000/thumb?url=https://github.com" -o github.webp

# custom width
curl "http://localhost:3000/thumb?url=https://dev.to&w=600" -o devto.webp
Enter fullscreen mode Exit fullscreen mode

In your frontend, just use it as an image source:

<img 
  src="/thumb?url=https://example.com" 
  alt="example.com preview"
  loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode

Why not self-host Puppeteer

I ran a self-hosted setup for a year. Here's what I dealt with:

Memory. Each Chromium instance eats 200-400MB. With 10 concurrent requests, you're looking at 4GB just for the browser processes. My 8GB droplet would OOM about once a week.

Zombie processes. Browsers crash. Tabs hang. Pages with infinite scroll or heavy JavaScript would occasionally lock up a tab. I wrote a watchdog script to kill stuck processes, which itself had bugs.

Font rendering. Missing fonts on Linux servers produce garbage screenshots. I installed a font pack, but CJK sites still looked wrong. Kept finding edge cases for months.

Timeouts. Some pages just take forever. A 30-second timeout seems generous until you hit a page that loads 47 third-party scripts and triggers a cookie consent modal that blocks rendering.

A screenshot API handles all of this. Browser pool management, font libraries, timeout handling, retries — someone else's problem. My service just makes HTTP calls and resizes images.

Improvements worth adding

Rate limiting. Without it, someone will feed your service 10,000 URLs and you'll burn through API credits. I use express-rate-limit — 10 requests per minute per IP is reasonable for most use cases.

const rateLimit = require("express-rate-limit");
app.use("/thumb", rateLimit({ windowMs: 60000, max: 10 }));
Enter fullscreen mode Exit fullscreen mode

Persistent cache. NodeCache is in-memory — restarts kill it. For production, swap to Redis or just write thumbnails to disk with the URL hash as filename. Disk cache is underrated for images.

Fallback image. When a screenshot fails (site is down, blocked by firewall, etc.), return a generic placeholder instead of an error. Your UI shouldn't break because one link preview failed.

const FALLBACK = await sharp({
  create: { width: 400, height: 300, channels: 4, background: "#f0f0f0" }
}).webp().toBuffer();
Enter fullscreen mode Exit fullscreen mode

Queue for bulk requests. If you're generating thumbnails for an import of 500 bookmarks, don't fire 500 concurrent API calls. Use a simple queue — bull or even just a promise pool with concurrency of 5.

What about Open Graph images?

OG images are great when they exist. But in practice:

  • ~30% of URLs have no OG image at all
  • Many OG images are generic company logos, not page-specific
  • Some are broken CDN links
  • Quality varies wildly — some are 100x100 pixels

I use OG images as a first try, falling back to a screenshot when OG is missing or low quality. Best of both worlds — fast when OG exists, accurate when it doesn't.

Production numbers

Running this for a bookmarking app with ~2K daily active users:

  • Average thumbnail generation: 2-3 seconds (cold),

Top comments (0)