An image upload route should refuse bad files before it decodes a single pixel. Five checks do it in Fastify: cap the bytes, allow one file, sniff the magic bytes, read the header's dimensions, and only then decode with a pixel cap and a time cap. On my machine the route refused a 400-megapixel PNG bomb in 1.7 ms. The same route took 130.4 ms to decode and re-encode a normal 1920 × 1280 photo.

Why check before decode?

Decoding is where an image costs you. A compressed file says nothing about its decoded size. I made a 20000 × 20000 PNG of one flat colour with sharp. It is 1,211,460 bytes on disk. Decoded as RGB, it is 400 million pixels, 1.2 GB of raw samples.

Every check below runs on bytes you already hold, or on the file header. None of them allocates the pixel buffer.

What does the endpoint look like?

The whole route, runnable with fastify, @fastify/multipart and sharp:

import Fastify from 'fastify'; import multipart from '@fastify/multipart'; import sharp from 'sharp'; const MAX_BYTES = 30_000_000; // check 1: bytes on the wire const MAX_PIXELS = 100_000_000; // check 4: pixels the header declares const app = Fastify(); await app.register(multipart, { limits: { fileSize: MAX_BYTES, files: 1, fields: 0 }, // check 2: one file, nothing else }); function sniff(buf) { if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg'; if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'png'; if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'webp'; if (buf.length >= 12 && buf.toString('latin1', 4, 8) === 'ftyp') { const brand = buf.toString('latin1', 8, 12); if (brand === 'avif' || brand === 'avis') return 'avif'; if (['heic', 'heix', 'mif1', 'msf1'].includes(brand)) return 'heic'; } return null; } app.post('/upload', async (req, reply) => { const file = await req.file(); if (!file) return reply.code(400).send({ error: 'no_file' }); const buf = await file.toBuffer(); // throws 413 past fileSize const format = sniff(buf); // check 3: magic bytes, not the name or Content-Type if (!format) return reply.code(415).send({ error: 'unsupported_format', claimed: file.mimetype }); const meta = await sharp(buf, { limitInputPixels: false }).metadata(); // check 4: header only const pixels = (meta.width ?? 0) * (meta.height ?? 0); if (pixels > MAX_PIXELS) { return reply.code(422).send({ error: 'too_many_pixels', width: meta.width, height: meta.height }); } // check 5: the decode itself, with a pixel cap and a time cap as the backstop const out = await sharp(buf, { limitInputPixels: MAX_PIXELS }) .timeout({ seconds: 10 }) .resize({ width: 1600, withoutEnlargement: true }) .webp({ quality: 75 }) .toBuffer(); return { format, claimed: file.mimetype, width: meta.width, height: meta.height, in: buf.length, out: out.length }; }); app.setErrorHandler((err, req, reply) => { reply.code(err.statusCode ?? 500).send({ error: err.code ?? 'error', message: err.message }); }); await app.listen({ port: 3999 }); 

What did each test file get back?

I sent six files, each with a .jpg name and type=image/jpeg, the way a careless or hostile client would:

for f in photo.jpg graphic.jpg notes.jpg wide.png bomb.png big.jpg; do curl -s -F "file=@$f;type=image/jpeg" http://127.0.0.1:3999/upload; echo done 
{"format":"jpeg","claimed":"image/jpeg","width":1920,"height":1280,"in":576828,"out":170268} {"format":"png","claimed":"image/jpeg","width":640,"height":480,"in":7395,"out":1202} {"error":"unsupported_format","claimed":"image/jpeg"} {"error":"too_many_pixels","width":12500,"height":12000} {"error":"too_many_pixels","width":20000,"height":20000} {"error":"FST_REQ_FILE_TOO_LARGE","message":"request file too large"} 
File What it is Status Median time
photo.jpg 1920 × 1280 JPEG, 576,828 B 200 130.4 ms
graphic.jpg a 640 × 480 PNG with a .jpg name 200 11.6 ms
notes.jpg 40 bytes of text 415 0.8 ms
wide.png 12500 × 12000 flat PNG, 462,998 B 422 1.3 ms
bomb.png 20000 × 20000 flat PNG, 1,211,460 B 422 1.7 ms
big.jpg 31,000,000 random bytes 413 15.6 ms

Time to answer each upload: 130.4 ms for the real photo, 1.7 ms for the 400 MP bomb, 0.8 ms for the text file

The PNG named .jpg went through, and that is correct. The sniffer said png, so the route treats it as a PNG. The claimed field shows what the client said; you log it, you never route on it.

Why read the header with limitInputPixels: false?

This surprised me. My first version called sharp(buf).metadata() with no options. The bomb came back as a 500 with Input image exceeds pixel limit. sharp applies its default limitInputPixels, 268,402,689 pixels, even to a metadata read, and the constructor docs list that default.

That default saved me from the bomb, but in the wrong place. The client got a 500, not a clear 422, and the error looked like a crash in the logs. Worse, the 150-megapixel wide.png sits under the default. Without my own check, it would have been decoded.

So the header read turns the default off and the route applies its own cap. metadata() reads the header only, so turning the limit off there allocates nothing large. The decode in check 5 then sets limitInputPixels again, as a second line in case a header lies.

Note: limitInputPixels counts width × height from the header. It does not cap bytes, and it does not cap time. A small, deeply nested or slow-to-decode file still needs the timeout() in check 5. The output docs define it as a timeout for processing, in seconds, off by default.

What does skipping check 4 cost?

I decoded wide.png without the cap: resize to 1600 px wide, WebP q75. Over 5 runs the median was 320 ms and the median peak resident memory was 227,164,160 bytes, about 227.2 MB, for one request. The 422 cost 1.3 ms.

A flat PNG is the kind case: libvips streams it and the output is tiny. A photo at that size takes longer. Ten such uploads at once would hold gigabytes on a small server.

Where does the byte cap belong?

@fastify/multipart stops reading at limits.fileSize and toBuffer() throws. In my run the error carried statusCode 413 and code FST_REQ_FILE_TOO_LARGE, which the error handler passes through. The plugin's README documents fileSize, files and fields as the limits.

Put the cap in your reverse proxy too. The plugin protects the process; the proxy protects the socket.

What would I add for production?

  • Run the decode off the API process, in a queue worker. A 130 ms encode is fine once; a hundred at once is not.
  • Rate limit per key and per IP before any of this runs.
  • Stream to object storage instead of toBuffer() when files are large, and sniff the first chunk.

For the bigger picture on very large images, I wrote up what a 400-megapixel JPEG does to a small server.

How I measured

  • Apple Silicon Mac (M1 Pro), 2026-10-17.
  • Node 24.22.0, Fastify 5.12.5, @fastify/multipart 10.1.4, sharp 0.35.4 with libvips 8.18.6.
  • Each time is the median of 5 curl runs (%{time_total}) against the local server.
  • photo.jpg is the site's 1920 × 1280 sample (jpg-sample-photo-original.jpg), graphic.jpg is the engine's graphic.png fixture renamed. The PNGs and the random file were made by script.
  • Memory: /usr/bin/time -l, maximum resident set size, median of 5.

What I did not test

I did not test a real malformed file that hangs a decoder, so the 10-second timeout never fired. I did not test HEIC or AVIF headers through check 4.

I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.