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:
- Accepts a URL
- Returns a thumbnail image (cached)
- 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
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"));
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
In your frontend, just use it as an image source:
<img
src="/thumb?url=https://example.com"
alt="example.com preview"
loading="lazy"
/>
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 }));
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();
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)