🖼️ Image Processing with sharp
A user just uploaded a 6 MB, 4000×3000 photo from their phone to be your app's avatar — a 96-pixel circle. Serving that original wastes bandwidth, slows your pages, and leaks the photo's GPS coordinates. sharp fixes all of that in a few lines: resize it, convert it to a modern format, shrink it to a few kilobytes, and strip the metadata, all before it ever reaches storage.
Week 10 · Wednesday (File Handling) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why sharp is fast and how it chains operations into a pipeline
- Read image metadata and resize with the right
fitfor the job - Convert images to WebP and AVIF and choose sensible quality settings
- Generate square thumbnails alongside a full-size version
- Strip EXIF metadata to protect user privacy and shrink files
- Order the pipeline and control concurrency for speed and low memory
Estimated Time: 70 minutes
Practice: Build an upload handler that produces an optimized WebP plus a thumbnail from one uploaded image, strips metadata, and reports the size saved.
In This Lesson
Why sharp?
sharp is the standard Node.js image library. Its speed comes from libvips, a C library that processes images in small streaming regions instead of loading the whole thing into memory — so it's several times faster than alternatives and uses a fraction of the RAM. For a web backend that resizes thousands of uploads, that difference is the difference between one server and ten.
The mental model is a pipeline: you create a sharp instance from an input, chain transformation methods onto it, and finish with an output. Nothing actually runs until you call toBuffer() or toFile() — the chain just describes the work, then executes it in one efficient pass.
// npm install sharp
const sharp = require('sharp');
// A pipeline: read → resize → convert → output. One efficient pass.
const output = await sharp('input.jpg')
.resize(800) // width 800, height auto to keep aspect ratio
.webp({ quality: 80 }) // convert to WebP at 80% quality
.toBuffer(); // nothing ran until this line
Because it fits so naturally after Multer's memory storage, sharp usually sits between "file arrived" and "store in S3": take req.file.buffer, transform it, upload the result.
Metadata & Resizing
Before transforming, you often want to know what you're dealing with. metadata() reads the header without decoding the whole image, so it's cheap.
const meta = await sharp(buffer).metadata();
console.log(meta.width, meta.height, meta.format);
// e.g. 4000 3000 "jpeg"
// Reject absurd inputs early — a "decompression bomb" defense.
if (meta.width > 10000 || meta.height > 10000) {
throw new Error('Image dimensions exceed the allowed maximum');
}
Resizing and the fit strategy
Resizing to an exact box raises a question: what happens when the source aspect ratio doesn't match the target? The fit option answers it. Picture fitting a rectangular photo into a square frame — you can crop the overflow, letterbox with padding, or squish it (never squish).
fit | What it does | Use for |
|---|---|---|
cover | Fills the box, cropping the overflow (default) | Avatars, card thumbnails |
contain | Fits inside the box, padding the rest | Logos on a fixed canvas |
inside | Shrinks to fit, keeps aspect ratio, no padding | "Max 800px" web images |
fill | Stretches to the exact box (distorts) | Rarely — it warps the image |
// Cap an image at 800px on its longest side without ever enlarging it.
await sharp(buffer)
.resize({
width: 800,
height: 800,
fit: 'inside', // keep aspect ratio, fit within 800x800
withoutEnlargement: true // don't upscale a small image (blurry + pointless)
})
.toBuffer();
// A perfectly square, center-cropped avatar.
await sharp(buffer)
.resize({ width: 256, height: 256, fit: 'cover', position: 'centre' })
.toBuffer();
💡 withoutEnlargement saves you from blur
Upscaling never adds real detail — it just interpolates, producing a soft, larger file. Setting withoutEnlargement: true tells sharp to leave already-small images alone, which is almost always what you want for user uploads.
Modern Formats & Quality
The format you output matters as much as the dimensions. WebP is typically 25–35% smaller than JPEG at the same quality and is supported by every current browser. AVIF goes further — often 50% smaller than JPEG — at the cost of slower encoding. Converting a JPEG upload to WebP is one of the highest-leverage optimizations you can make.
// WebP — the safe, fast default for photos on the web.
await sharp(buffer).webp({ quality: 80 }).toBuffer();
// AVIF — smallest files, slower to encode; great for hero images.
await sharp(buffer).avif({ quality: 50 }).toBuffer(); // AVIF quality scales differently
// JPEG with mozjpeg for better compression when you must stay JPEG.
await sharp(buffer).jpeg({ quality: 80, mozjpeg: true }).toBuffer();
// PNG — keep it only for images that need transparency or crisp edges.
await sharp(buffer).png({ compressionLevel: 9 }).toBuffer();
Output — a real conversion
Original avatar.jpg → 6,240,118 bytes
Resized 256px + WebP q80 → 11,904 bytes
Reduction → 99.8%
Why it matters: quality is a dial, not a switch. For photographic content, quality: 80 is nearly indistinguishable from the original to the eye while cutting size dramatically. Push it lower for thumbnails, where nobody inspects the pixels. Test with your own images — the right number depends on the content.
Thumbnails
Galleries and lists shouldn't load full-size images. The pattern is to produce a small thumbnail alongside the full version from the same upload, store both, and serve whichever the layout needs. Reuse the source buffer for each — start a fresh sharp instance per output so the pipelines don't interfere.
async function makeVariants(buffer) {
// Two independent pipelines from the same input buffer.
const [full, thumb] = await Promise.all([
sharp(buffer)
.resize({ width: 1200, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer(),
sharp(buffer)
.resize({ width: 300, height: 300, fit: 'cover', position: 'centre' })
.webp({ quality: 70 })
.toBuffer()
]);
return {
full: { buffer: full, contentType: 'image/webp' },
thumb: { buffer: thumb, contentType: 'image/webp' }
};
}
Wired into an upload route with S3 from the previous lesson, the full flow reads: validate, transform, store both keys.
const crypto = require('crypto');
const { PutObjectCommand } = require('@aws-sdk/client-s3');
const { s3, BUCKET } = require('./s3');
app.post('/api/upload/image', upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
const { full, thumb } = await makeVariants(req.file.buffer);
const id = crypto.randomUUID();
await Promise.all([
s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: `img/${id}.webp`,
Body: full.buffer, ContentType: full.contentType
})),
s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: `img/${id}-thumb.webp`,
Body: thumb.buffer, ContentType: thumb.contentType
}))
]);
res.status(201).json({ id, full: `img/${id}.webp`, thumb: `img/${id}-thumb.webp` });
});
Stripping Metadata
Photos carry EXIF metadata: camera model, timestamps, and — most sensitively — the exact GPS coordinates where the picture was taken. If you store and serve uploads unchanged, you may be broadcasting your users' home addresses. There's also a size benefit: metadata can add kilobytes of junk to every file.
By default, sharp drops all metadata when it processes an image — the safe behavior. Trouble only appears if you deliberately call .withMetadata(), which re-attaches it. So the rule is simple: don't opt back in unless you have a reason.
// ✅ Default behavior strips EXIF, including GPS — you get privacy for free.
await sharp(buffer).resize(800).webp().toBuffer();
// ⚠️ This RE-ADDS the original metadata. Only do this intentionally.
await sharp(buffer).resize(800).withMetadata().toBuffer();
// If you want to keep just the orientation (so the image isn't sideways)
// without leaking location, rotate to bake it in, then let metadata drop:
await sharp(buffer)
.rotate() // auto-rotates using EXIF orientation, then discards it
.resize(800)
.webp()
.toBuffer();
⚠️ The sideways-photo gotcha
Phone cameras often store the image "landscape" plus an orientation flag saying "rotate 90°." When you strip metadata, that flag goes too — and the image can appear rotated. Calling .rotate() with no arguments first applies the EXIF orientation to the actual pixels, so the result looks correct even after the metadata is gone.
Pipeline Order & Memory
Two habits keep an image service fast under real traffic: ordering operations well, and bounding how many run at once.
Resize first
Every operation after a resize works on fewer pixels. Filters, color adjustments, and format encoding are all cheaper on an 800px image than on a 4000px one. So resize as early as possible — it shrinks the data that everything downstream must chew through.
// 🐢 Slower: the blur processes millions of full-size pixels first.
await sharp(buffer).blur(8).resize(800).webp().toBuffer();
// 🚀 Faster: resize shrinks the data, then blur works on far less.
await sharp(buffer).resize(800).blur(8).webp().toBuffer();
Bound concurrency
Processing an unlimited number of images simultaneously will exhaust memory and crash the process. It's like a kitchen with limited counter space — start every dish at once and you run out of room. Process in controlled batches, or cap concurrency with a small limiter.
// npm install p-limit
const pLimit = require('p-limit');
const limit = pLimit(4); // at most 4 images processed at a time
async function processMany(buffers) {
return Promise.all(
buffers.map((buf) =>
limit(() => sharp(buf).resize(800).webp({ quality: 80 }).toBuffer())
)
);
}
// Memory now stays bounded no matter how many images arrive at once.
✅ Do the heavy work off the request path
For big batches or slow AVIF encoding, don't make the user wait. Accept the upload, store the original, and process asynchronously in a background job or queue (the topic of the Redis lessons ahead). The user gets an instant response; the optimized variants appear moments later.
Practice & Quiz
🏋️ Exercise 1: Optimize and report the savings
Goal: Write optimize(buffer) that caps the image at 1000px on its longest side, converts it to WebP at quality 80, strips metadata, and returns both the output buffer and the percentage size saved versus the input.
💡 Hint
Use fit: 'inside' with withoutEnlargement: true. Compare buffer.length (input) with output.length. Remember sharp strips metadata by default — don't call withMetadata().
✅ Solution
const sharp = require('sharp');
async function optimize(buffer) {
const output = await sharp(buffer)
.rotate() // bake in EXIF orientation before metadata is dropped
.resize({ width: 1000, height: 1000, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer(); // metadata stripped by default
const savedPct = Math.round((1 - output.length / buffer.length) * 100);
return { output, savedPct };
}
// const { output, savedPct } = await optimize(req.file.buffer);
// console.log(`Saved ${savedPct}%`);
🏋️ Exercise 2: Square avatar generator
Goal: Write makeAvatar(buffer, size) that returns a center-cropped square WebP of the given size, never enlarging beyond the source.
✅ Solution
async function makeAvatar(buffer, size = 256) {
return sharp(buffer)
.rotate()
.resize({
width: size,
height: size,
fit: 'cover', // fill the square, crop the overflow
position: 'centre',
withoutEnlargement: true
})
.webp({ quality: 82 })
.toBuffer();
}
🎯 Quick Quiz
Question 1: Why should resize() usually come early in a sharp pipeline?
Question 2: What does sharp do with EXIF metadata by default?
Question 3: Which format is generally the best default for optimizing web photos with broad browser support?
Best Practices & Pitfalls
✅ Do
- Resize early and convert to WebP (or AVIF) to slash file size
- Call
.rotate()with no args to honor EXIF orientation before metadata drops - Use
withoutEnlargement: trueso small uploads aren't blurrily upscaled - Generate a thumbnail alongside the full image for lists and galleries
- Cap input dimensions and bound concurrency to protect memory
❌ Don't
- Serve the user's raw upload — it's oversized and leaks EXIF/GPS
- Call
.withMetadata()unless you truly need the metadata kept - Use
fit: 'fill'for photos — it distorts them - Process an unbounded number of images at once
- Block slow AVIF encoding on the request; push it to a background job
⚠️ Reusing one sharp instance for two outputs
const img = sharp(buffer);
// ❌ Calling toBuffer() twice on the same instance is unreliable.
const a = await img.resize(800).toBuffer();
const b = await img.resize(300).toBuffer();
// ✅ Start a fresh instance from the source buffer for each output.
const a2 = await sharp(buffer).resize(800).toBuffer();
const b2 = await sharp(buffer).resize(300).toBuffer();
Each distinct output should begin from sharp(buffer) again, so the pipelines stay independent.
Summary
🎉 Key Takeaways
- sharp (backed by libvips) is a fast, low-memory pipeline: read → transform → output, executed on
toBuffer()/toFile() - Resize with the right
fit(coverfor avatars,insidefor web caps) and never enlarge - Convert to WebP or AVIF for major size wins with good quality
- Produce thumbnails next to full images; start a fresh pipeline per output
- sharp strips EXIF by default — good for privacy;
.rotate()keeps orientation correct - Resize first and bound concurrency to keep the service fast and memory-safe
📚 Additional Resources
- sharp — official documentation
- sharp — resize API and fit options
- sharp — output formats (WebP, AVIF, JPEG, PNG)
- MDN — Image file type and format guide
🚀 What's Next?
You've now completed the full file-handling journey: accept an upload safely, store it in the cloud, and transform it on the way. Next the course shifts gears to performance infrastructure with Redis Fundamentals — the in-memory data store that powers caching, sessions, and the background job queues where heavy image work belongs.
🎉 File handling complete!
Uploads, cloud storage, and image processing — you can now build the media pipeline behind any modern app, from profile photos to full galleries.