Image Processing with Sharp: Build a Production-Grade Transformation API
Uploaded images straight from a phone camera are 4-8MB each, and they will crush your bandwidth, your storage costs, and your Lighthouse scores. Here is how to build a production image transformation pipeline with Sharp that resizes, optimizes, caches, and serves images at every breakpoint your layout needs.
A user uploads a headshot from their iPhone. The raw file is 6.2MB, 4032x3024 pixels. Your server saves it to S3 byte-for-byte. Your blog hero renders it at 1200px wide scaled down in the browser with width: 100% CSS and the browser still downloads all 6.2MB because the <img> has no srcset and no server-side resize.
Then a hundred users do this. Then a thousand. Your S3 bill doubles, your page load times go from “fine” to “why is this so slow,” and your CI pipeline starts failing Lighthouse budgets you set last month.
This is the problem: modern phone cameras produce absurdly large files, and by default your backend will treat every pixel like it matters. It does not. A 4000px-wide photo displayed in an 800px container is 95% wasted bytes. The fix is not “tell users to resize their photos.” The fix is a server-side image processing pipeline that resizes, reformats, compresses, and caches every upload into every size your application actually needs.
Sharp is the fastest, most memory-efficient image processing library for Node.js, and it lets you build this pipeline with about 200 lines of code. No extra services, no Lambda@Edge, no external CDN transforms. Just a Fastify route, a Sharp pipeline, and a caching layer.
Why Sharp instead of everything else
There are a lot of ways to process images in Node.js. Here is how they compare.
jimp is pure JavaScript. It works everywhere and needs no native dependencies, but it processes a single large image in seconds instead of milliseconds and consumes enormous amounts of memory. Fine for a CLI tool that resizes one avatar. Not fine for a production API that handles concurrent uploads.
gm (GraphicsMagick / ImageMagick) spawns a child process for every transformation. That means process overhead, disk I/O for temp files, and no streaming. On a server handling 50 req/s, spawning 50 convert processes will exhaust your process table and spike CPU to 100% before you finish reading this sentence.
sharp uses the libvips C library under the hood and runs in the main Node.js process with zero subprocess overhead. It streams pixels through a pipeline, uses a memory pool that reuses allocations, and handles most operations without decompressing the entire image. A 4000x3000 JPEG resize to 800px takes about 40ms and uses under 30MB of RAM. Sharp is not the easy option. It is the only option that scales.
Library | Lib/approach | Time (resize 6MB JPEG to 800px) | Peak memory
------------+--------------+----------------------------------+-------------
jimp | Pure JS | 3200ms | 180MB
gm | Child proc | 1200ms (plus process overhead) | 120MB + subproc
sharp | libvips | 38ms | 28MB
The numbers speak for themselves. Use Sharp.
A simple transformation pipeline
Here is the core of the pipeline: an Express or Fastify route that accepts an image, processes it through Sharp, and returns the result. This example generates three sizes for every uploaded image.
import sharp from 'sharp';
import { randomUUID } from 'node:crypto';
import { writeFile, mkdir } from 'node:fs/promises';
interface ProcessedImage {
original: string;
sizes: { key: string; path: string; width: number }[];
}
const SIZES = [
{ key: 'thumbnail', width: 150, height: 150, fit: 'cover' as const },
{ key: 'medium', width: 600, height: 600, fit: 'inside' as const },
{ key: 'large', width: 1200, height: 1200, fit: 'inside' as const },
];
export async function processImage(
inputBuffer: Buffer,
uploadDir: string
): Promise<ProcessedImage> {
const id = randomUUID();
const dir = `${uploadDir}/${id}`;
await mkdir(dir, { recursive: true });
const metadata = await sharp(inputBuffer).metadata();
const ext = metadata.format === 'png' ? 'png' : 'jpg';
// Save the original
const originalPath = `${dir}/original.${ext}`;
await writeFile(originalPath, inputBuffer);
const sizes: ProcessedImage['sizes'] = [];
for (const size of SIZES) {
const outputPath = `${dir}/${size.key}.jpg`;
const pipeline = sharp(inputBuffer)
.resize(size.width, size.height, { fit: size.fit, withoutEnlargement: true })
.jpeg({ quality: 80, mozjpeg: true });
await pipeline.toFile(outputPath);
sizes.push({ key: size.key, path: outputPath, width: size.width });
}
return { original: originalPath, sizes };
}
A few details worth calling out:
withoutEnlargement: truestops Sharp from upscaling small source images to the requested dimensions. If someone uploads a 100px avatar, you do not want to stretch it to 1200px and ship a blurry mess.fit: 'cover'crops the image to exactly fill the requested dimensions (great for square thumbnails).fit: 'inside'preserves the aspect ratio and fits the image within the bounding box (good for responsive content images).mozjpeg: trueenables Mozilla’s JPEG encoder, which produces 10-15% smaller files at identical quality settings compared to baseline libjpeg. It is slightly slower but the bandwidth savings are worth it for stored outputs.
Streaming the input instead of buffering
The example above reads the entire input buffer into memory before processing. For a few hundred kilobytes that is fine. For a 20MB panorama from an iPhone, you just allocated memory for the buffer, Sharp’s internal copy, and the output buffer. Three copies of a 20MB file in memory at once.
The fix is to stream the input directly into Sharp so it processes pixels as they arrive and never holds the whole file in memory at once.
import { createWriteStream, createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import sharp from 'sharp';
async function processImageStreamed(
inputStream: Readable,
uploadDir: string,
id: string
): Promise<void> {
const dir = `${uploadDir}/${id}`;
await mkdir(dir, { recursive: true });
// Pipeline: input stream -> sharp -> output file
const transform = sharp()
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80, mozjpeg: true });
const output = createWriteStream(`${dir}/large.jpg`);
await new Promise<void>((resolve, reject) => {
inputStream
.pipe(transform)
.pipe(output)
.on('finish', resolve)
.on('error', reject);
});
}
Sharp is a transform stream that accepts a readable stream as input and produces a readable stream of the processed output. This means you can pipe an HTTP request body, an S3 download stream, or a database read stream directly through Sharp without ever writing the raw bytes to disk first.
Format detection and WebP/AVIF conversion
The format you save matters more than any other optimization choice. JPEG at quality 80 is a good baseline, but WebP at quality 75 is typically 25-30% smaller at the same perceptual quality, and AVIF at quality 60 can cut file size in half compared to JPEG.
Sharp supports all three formats. Here is a format-aware variant that picks the best output format based on the Accept header from the client.
type OutputFormat = 'jpeg' | 'webp' | 'avif';
function pickFormat(acceptHeader: string | undefined): OutputFormat {
if (!acceptHeader) return 'jpeg';
if (acceptHeader.includes('image/avif')) return 'avif';
if (acceptHeader.includes('image/webp')) return 'webp';
return 'jpeg';
}
Then apply the chosen format to the Sharp pipeline:
async function transformImage(
input: Buffer,
width: number,
acceptHeader?: string
): Promise<{ data: Buffer; format: OutputFormat }> {
const format = pickFormat(acceptHeader);
const pipeline = sharp(input)
.resize(width, width, { fit: 'inside', withoutEnlargement: true });
switch (format) {
case 'avif':
pipeline.avif({ quality: 60, effort: 4 });
break;
case 'webp':
pipeline.webp({ quality: 75 });
break;
default:
pipeline.jpeg({ quality: 80, mozjpeg: true });
}
const data = await pipeline.toBuffer();
return { data, format };
}
Now the caller sets Content-Type based on the returned format and the browser gets the smallest possible file for its supported formats.
Caching transformed images
Generating every transformation on every request is wasteful. If the same 1200px JPEG of the same source image was already generated once, it should be served from a cache until the source changes.
The simplest production caching strategy for image transforms is content-addressable keys. Hash the source image identifier and the transform parameters, and use that hash as the cache key.
import { createHash } from 'node:crypto';
function cacheKey(imageId: string, width: number, format: string): string {
return createHash('md5')
.update(`${imageId}:${width}:${format}`)
.digest('hex');
}
Store the result in S3 with that key as the object path. On the next request, check S3 first. If the object exists, redirect the client directly to the S3 URL with a long Cache-Control header. Skip the Sharp pipeline entirely.
const OBJECT_CACHE = '86400'; // 24 hours in seconds
async function getOrCreateImage(
sourceId: string,
width: number,
acceptHeader?: string
): Promise<{ url: string; cacheHit: boolean }> {
const format = pickFormat(acceptHeader);
const key = `processed/${cacheKey(sourceId, width, format)}.${format}`;
try {
// Check S3 for an existing transform
await s3.headObject({ Bucket: BUCKET, Key: key }).promise();
const url = s3.getSignedUrl('getObject', {
Bucket: BUCKET,
Key: key,
Expires: 86400,
});
return { url, cacheHit: true };
} catch {
// Not cached -- generate the transform
const sourceBuffer = await loadSourceImage(sourceId);
const { data } = await transformImage(sourceBuffer, width, acceptHeader);
await s3
.putObject({
Bucket: BUCKET,
Key: key,
Body: data,
ContentType: `image/${format}`,
CacheControl: `public, max-age=${OBJECT_CACHE}`,
})
.promise();
const url = s3.getSignedUrl('getObject', {
Bucket: BUCKET,
Key: key,
Expires: 86400,
});
return { url, cacheHit: false };
}
}
A CDN (CloudFront, Cloudflare, Fastly) sits in front of the S3 bucket and caches the response at the edge. After the first request for a given transform, every subsequent request anywhere in the world hits the edge cache. The Sharp pipeline runs exactly once per unique transform.
Two edge cases to plan for:
- Cache invalidation. If the source image changes, you need to delete all cached transforms derived from it. Store a manifest file mapping source IDs to cache keys, or use the source image’s
lastModifiedtimestamp as part of the cache key so a new upload produces a new hash automatically. - Thundering herd on cache miss. If fifty clients request the same uncached transform simultaneously, your Sharp worker could get hammered. Use a mutex based on the cache key (Redis
SET NXor a Postgres advisory lock) so only one worker generates the transform and the rest wait for the cached result.
Rate limiting expensive operations
Not all transformations are equal. Resizing a 20MB TIFF to 2000px uses 100x more CPU and memory than converting a 200KB JPEG to a 150px thumbnail. Use a resource-based rate limiter that accounts for input dimensions.
interface TransformRequest {
inputWidth: number;
inputHeight: number;
outputWidth: number;
}
function estimateCost(req: TransformRequest): number {
const inputMegapixels = (req.inputWidth * req.inputHeight) / 1_000_000;
const reductionFactor = Math.max(
1,
(req.inputWidth * req.inputHeight) /
(req.outputWidth * req.outputWidth)
);
return Math.round(inputMegapixels * reductionFactor);
}
Then use a token bucket or sliding window that deducts the estimated cost from the user’s budget per-second instead of counting flat requests.
Putting it all together — a complete Fastify route
Here is a full route that ties everything together: parse the multipart upload, validate dimensions and format, process through Sharp, cache the result, and return a URL.
import Fastify from 'fastify';
import multipart from '@fastify/multipart';
import sharp from 'sharp';
import { randomUUID } from 'node:crypto';
const app = Fastify({ logger: true });
app.register(multipart, { limits: { fileSize: 20 * 1024 * 1024 } });
app.post('/images', async (request, reply) => {
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: 'No file uploaded' });
}
// Validate content type before processing
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/tiff'];
if (!allowedTypes.includes(data.mimetype)) {
return reply.status(415).send({ error: 'Unsupported image format' });
}
// Buffer the file for metadata extraction
const buffer = await data.toBuffer();
const metadata = await sharp(buffer).metadata();
if (!metadata.width || !metadata.height) {
return reply.status(400).send({ error: 'Could not read image dimensions' });
}
// Reject images that are too large to process
const megapixels = (metadata.width * metadata.height) / 1_000_000;
if (megapixels > 50) {
return reply
.status(413)
.send({ error: 'Image too large. Maximum 50 megapixels.' });
}
const imageId = randomUUID();
// Generate all required sizes
const results = await Promise.all(
SIZES.map(async (size) => {
const pipeline = sharp(buffer)
.resize(size.width, size.height, {
fit: size.fit as keyof sharp.FitEnum,
withoutEnlargement: true,
})
.jpeg({ quality: 80, mozjpeg: true });
const outputBuffer = await pipeline.toBuffer();
const s3Key = `images/${imageId}/${size.key}.jpg`;
await s3
.putObject({
Bucket: BUCKET,
Key: s3Key,
Body: outputBuffer,
ContentType: 'image/jpeg',
CacheControl: 'public, max-age=31536000, immutable',
})
.promise();
return { key: size.key, width: size.width, s3Key };
})
);
return reply.status(201).send({
id: imageId,
sizes: results,
urls: results.map(
(r) => `https://cdn.example.com/${r.s3Key}`
),
});
});
app.listen({ port: 3000 });
The Cache-Control: public, max-age=31536000, immutable on the stored object tells both CDNs and browsers that this URL will never change. If you regenerate the image (different quality, new format), write it to a new path so the old cache entry is never incorrectly reused.
Error handling patterns for image processing
Image processing has failure modes that normal request handlers do not. Here are the three most common and how to handle them:
Corrupted or truncated input. A user uploads half a JPEG before their connection drops. Sharp will throw a SharpException when it tries to parse the stream. Catch it specifically and return a 422 with a clear message so the client knows to retransmit.
try {
const result = await sharp(buffer).resize(800).jpeg().toBuffer();
} catch (err) {
if (err instanceof Error && err.message.includes('Input file')) {
return reply.status(422).send({ error: 'Corrupted image file' });
}
throw err;
}
Out of memory on large images. A 100MP TIFF can exhaust Node’s heap even with Sharp’s streaming. Set an upfront megapixel gate (the megapixels > 50 check above) and return a 413 before Sharp even starts. That check costs microseconds from the metadata call and prevents allocating buffers you cannot hold.
Filesystem full or permission error. The toFile call writes to disk. If the disk is full, Sharp throws. Log the error, send a 500, and monitor disk usage separately.
The practical takeaway
Server-side image processing is not optional for a production application that accepts user uploads or generates thumbnails. Serving raw phone-camera JPEGs through an unoptimized pipeline is the single largest wasted-bandwidth opportunity in most web applications.
Sharp gives you a streaming, memory-efficient, format-aware pipeline that handles every common case with minimal code. Pair it with an S3-backed content-addressable cache and a resource-based rate limiter, and you can serve images at every breakpoint your layout needs without burning CPU on redundant transformations or spending money on a dedicated image service.
The key decisions in order of impact: (1) use Sharp, not a subprocess-based library, (2) cache every transform by content-addressable key, (3) detect Accept and serve WebP/AVIF when the client supports it, and (4) gate on megapixels before processing to avoid OOM crashes.
If you are currently serving raw uploads through <img src="/uploads/IMG_1234.jpg"> with no server-side resize, your Lighthouse score is bleeding bytes on every page load. The fix is an afternoon of Sharp code and an S3 bucket.
A note from Yojji
Building a production-grade image pipeline means thinking about streaming, caching, format negotiation, resource limits, and error handling as a single system rather than a collection of one-off scripts. That kind of infrastructure thinking, where bandwidth and memory budgets are designed in from the start rather than patched after the bill arrives, separates teams that ship features from teams that ship reliable features. Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product delivery from discovery through DevOps and production operations.