Skip to content

WebP thumbnails with sharp: LRU cache and database trade-offs

Storing images in the database works when the JSON never carries them: on-demand WebP variants with an LRU cache. Sharp design, limits and costs.

BySergio Jáuregui

8 min read

Cover on WebP thumbnails with sharp: a large image shrinking into a cyan cascade with a red database cylinder behind.

WebP thumbnails with sharp let you keep original images in the database without paying for them in every JSON response: small variants are served on demand, backed by an in-memory LRU cache. The JSON stays light and the heavy image arrives through its own cacheable URL. It is a decision with an explicit cost, not a trend.

This guide covers the full design: what to keep out of the JSON, how to build variants from parameters, what limits the in-memory cache has, what storing images in the database costs and when to move to object storage. The numbers that appear come from a reference implementation, not from a universal rule. An honest note: there are no general performance figures here, and at the end the guide explains how to measure your own.

Base64 out of the JSON: a listing should not pay for images

An image stored as a base64 data URL takes roughly a third more space than the original binary, and it travels with every response that includes it. If a listing carries those strings, every visit pays the weight of all its images even when nobody opens one. The usual fix is to separate them: the JSON carries a small thumbnail URL, and the full image is requested separately, only when needed.

That separation is often switched on with an explicit parameter, for example ?images=url. Without the parameter, the old contract keeps working; with it, the response carries a thumbnailUrl instead of the data URL. Here lies the trap: an old client keeps receiving base64 until it asks for the parameter. Migrate every client, then retire the old mode once none remain.

The same applies to admin panels. A media listing should leave out the heavy field and load it only on the detail view, so it never gets dragged along on every query.

Variants on demand: WebP thumbnails with sharp

Each thumbnail is served from a route such as /:slug/thumbnail with two parameters. w requests a width, and v carries a version mark, usually the date of the last edit. Two decisions prevent most problems.

The first is rounding the width to fixed buckets. If every requested pixel value produced a different variant, the cache would fill with near-identical copies. One reference implementation uses six widths and rounds up to the nearest one:

// Reference implementation: fixed widths, rounded up
export const THUMBNAIL_WIDTHS = [320, 480, 640, 960, 1280, 1920] as const;

export function pickThumbnailWidth(requested?: number): number {
  if (!requested || !Number.isFinite(requested) || requested <= 0) return 640;
  return THUMBNAIL_WIDTHS.find((w) => w >= requested) ?? THUMBNAIL_WIDTHS[THUMBNAIL_WIDTHS.length - 1];
}

The second is that v changes with every edit. The new URL invalidates the browser cache and any intermediary without touching the server, so the response can be marked immutable for a year. Without v, a short cache of a few minutes is enough:

// Reference implementation: with a version, cache immutably; without one, cache briefly
const version = typeof req.query.v === 'string' ? req.query.v : undefined;

res.set({
  'Content-Type': 'image/webp',
  'Cache-Control': version ? 'public, max-age=31536000, immutable' : 'public, max-age=300',
});

Generation runs on sharp in three steps: rotate according to the metadata, resize without enlarging, and convert to WebP. For reference, the portfolio at sergioja.com serves its thumbnails this way, and on the frontend the browser picks the width through srcset, so each screen downloads the variant it needs.

import sharp from 'sharp';

// Reference implementation: rotate, resize without enlarging, convert to WebP
export async function makeThumbnail(input: Buffer, width: number): Promise<Buffer> {
  return sharp(input, { limitInputPixels: 100_000_000 })
    .rotate()
    .resize({ width, withoutEnlargement: true })
    .webp({ quality: 80 })
    .toBuffer();
}

The in-memory LRU cache: how it works and what it does not do

Generating a variant costs CPU, so finished variants are kept in memory. An LRU (least recently used) cache evicts first whatever has gone longest without being read. A JavaScript Map is enough: its insertion order acts as the age queue. Each hit moves the entry to the back, and when the cap is exceeded, eviction starts from the front.

// Reference implementation: LRU cache with a Map (insertion order tracks age)
const MAX_BYTES = 64 * 1024 * 1024;
const cache = new Map<string, Buffer>();
let cacheBytes = 0;

export function getVariant(key: string): Buffer | undefined {
  const hit = cache.get(key);
  if (hit) {
    cache.delete(key); // move to the back: most recent
    cache.set(key, hit);
  }
  return hit;
}

export function putVariant(key: string, buffer: Buffer): void {
  if (buffer.length > MAX_BYTES) return;
  cache.set(key, buffer);
  cacheBytes += buffer.length;
  for (const [k, v] of cache) {
    if (cacheBytes <= MAX_BYTES) break;
    cache.delete(k); // evict from the front: oldest
    cacheBytes -= v.length;
  }
}

In the reference implementation the cap is 64 MB, and a variant larger than that cap is never stored. Generation is estimated at 50–150 ms of CPU, with sharp concurrency limited to 2. A map of in-flight promises keeps the same variant from being generated twice when two requests arrive together, a detail that is easy to forget and that multiplies work under load.

Three limits are worth keeping in view. The cache lives in the process: each instance has its own, and a cold start finds it empty. It does not survive a restart and is not shared between replicas. And under memory pressure, old variants regenerate on demand, so the cost of an overflow is CPU, not an error for the visitor.

What storing images in the database really costs

Keeping images in the database simplifies operations: there is a single copy to back up and no bucket to keep in sync. The cost is that every image adds bytes to every backup, and reading one is one more query. Cached variants soften the second problem; the first is accepted while the volume stays manageable.

Ingestion deserves control too. A sensible pattern combines a size cap per upload, a maximum width that does not enlarge small images, WebP output at a fixed quality, and an input pixel limit that stops pixel bombs, tiny images that decompress to enormous dimensions. The format should be detected from the file content, not from the declared MIME type, and only the bitmap formats you need should pass. An SVG or a corrupt file should fail with its own error.

A note of judgement: backups grow with images, and restoring a database full of gigabytes of binaries takes longer. Watch that size from the start, not when the restore already hurts.

When to move to object storage or a CDN

This design stops paying off in four situations. First, when database copies grow so heavy that moving or restoring them becomes a dreaded chore. Second, when there are several replicas and each in-memory cache duplicates work while sharing nothing. Third, when traffic turns regeneration into constant CPU instead of occasional spikes. Fourth, when you need edge variants, new formats, or assets that are not images, such as video.

There is no universal threshold, because it depends on the machine, the traffic and the tolerance for heavy copies. What to watch is the size of the media tables, the duration of backups and the regeneration rate. While those three curves stay flat, the database remains a reasonable store. When they stop, migrating means changing where the bytes come from, not the variant-and-cache design, which works the same over a bucket.

I would not do it for ephemeral content or uncontrolled third-party uploads either. Every upload adds permanent bytes, and no validation can tell a useful image from a forgotten test.

How to measure what your images cost, honestly

This guide brings no benchmarks of its own: the figures above belong to the reference implementation, and your system will have its own. What you can do is measure with a simple procedure before claiming anything.

First, compare the JSON response size with and without the URL parameter: that gap is what each listing used to pay. Second, time the first request for a variant, which generates, against the second, which comes from memory. Third, measure how much the image tables occupy and how much they add to each backup. Fourth, check the logs: generation failures should leave a warning, and a healthy system shows few.

With those four measurements you know whether the images need more optimisation or whether the bottleneck sits elsewhere. Measuring before touching avoids the classic mistake: redesigning the image system when the real problem was an unpaginated listing.

Get the images out of your JSON this week

The work splits into small steps. Keep the original wherever you like, serve small variants by URL with width and version parameters, mark as immutable the ones whose URL changes on edit, and measure the JSON before and after. Each step works on its own; together they turn images into a controlled cost.

This pattern pairs with frontend caching. The Next.js revalidateTag guide explains how to invalidate only what changed in the ISR cache, and the 3D website SEO guide covers the other heavy front, the 3D bundle. If you want to see a real project built this way, look at the reference implementation, and if you want this design on your site, write to me through the contact page.

Have a project in mind?

I'm Sergio Jáuregui, a Full Stack developer. If you enjoyed this article, take a look at my work or drop me a line.

Keep reading

Updated on Oct 15, 2026

Back to top