The short answer
Four moves cover almost everything: resize images to the largest size you will ever display before storing them, convert to WebP at quality 80 to 85, store them somewhere with zero egress fees (like Cloudflare R2 instead of S3), and serve them with long-lived immutable cache headers behind a CDN. Do this on upload, once, so every later view is cheap. A typical 4MB phone photo becomes 80 to 150KB with no visible quality loss.
Step-by-step
- 1
Resize at upload time, to the size you actually display
A phone camera produces 4000-pixel images; your recipe card, avatar, or product shot displays at 800. Storing originals and shrinking in the browser means paying to store and ship pixels no one ever sees. Resize server-side the moment the file arrives, before it touches storage. Sharp is the standard tool in Node and it is fast enough to do this inline in the upload request.
import sharp from 'sharp'; const optimized = await sharp(uploadBuffer) .rotate() // respect EXIF orientation .resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 82 }) .toBuffer(); - 2
Convert everything to WebP (and strip the metadata)
WebP lands 25 to 35 percent smaller than a comparable JPEG and is supported by effectively every browser now, so you no longer need JPEG fallbacks. Quality 80 to 85 is the sweet spot where photos of food, faces, and products look identical to the original at normal viewing sizes. Sharp also strips EXIF by default when re-encoding, which quietly removes GPS coordinates your users never intended to publish, and that alone is worth the change.
- 3
Generate a thumbnail variant at the same time
List views, grids, and search results do not need the full-size image. Create a small variant (say 400 pixels) alongside the display size in the same upload pass and choose per context. A grid of 30 recipes loading 30 thumbnails at 15KB each is a 450KB page; the same grid loading display-size images is 4MB and noticeably slower on a phone. This is a bandwidth optimization and a felt performance one at once.
const thumb = await sharp(uploadBuffer) .resize(400, 400, { fit: 'cover' }) .webp({ quality: 78 }) .toBuffer(); // store as recipes/<id>/image.webp and recipes/<id>/thumb.webp - 4
Store on zero-egress object storage
Storage pricing has two components, and egress is the one that scales with success: on S3 you pay roughly nine cents per gigabyte every time an image is viewed. Cloudflare R2 charges zero for egress with an S3-compatible API, so popular images stop costing more each month. Culinary Card runs on R2 for exactly this reason: every AI-generated recipe image gets viewed many times, and paying for storage once instead of per view changed the economics. Backblaze B2 and Bunny are similar options.
- 5
Cache aggressively with immutable URLs
Give every stored image a URL that never changes content (hash or ID in the path), then serve it with a one-year immutable cache header behind a CDN. Browsers and edge nodes absorb repeat views, your origin sees each image roughly once per region, and "bandwidth" mostly stops being your problem. When an image changes, upload under a new key rather than overwriting, so you never need cache invalidation.
Cache-Control: public, max-age=31536000, immutable - 6
Lazy-load below the fold and measure what you did
Add loading="lazy" to every image that is not immediately visible; it is one attribute and it eliminates the bandwidth of images nobody scrolled to. Then actually check the result, because this is the step everybody skips: total image weight per page in DevTools, storage growth per month, and the egress line on your bill. Without a before and after number you have no idea which of these five changes did the work, and on most products one of them did nearly all of it.
Common mistakes to avoid
- ✕
Optimizing on read instead of on write
Resizing at request time means paying CPU on your hottest path and storing full originals forever. Do the work once at upload, when latency is expected and the file is already in memory. On-the-fly transformation services are convenient, and they also bill per transformation forever.
- ✕
Keeping originals "just in case" for everything
For most products the 1200-pixel version is the product, and originals are pure storage cost. If you have a genuine reason to keep them (re-cropping, print export), park them in a cold or infrequent-access tier, separate from the serving bucket.
- ✕
Ignoring EXIF orientation when you resize
This is the bug that reaches you as "why is my photo sideways", and it is confusing because the original looked fine in the phone gallery. Phone cameras frequently store the image in one orientation plus a rotation flag, and most resize operations drop the flag while keeping the pixels. Call rotate() before resize so the rotation is baked into the pixels, which is why it is the first line in the snippet above.
- ✕
Trusting client-side resizing
Resizing in the browser before upload is a nice bandwidth courtesy, and it is not enforcement. Anyone can hit your API directly with a 40MB file. The server-side pipeline is the guarantee; the client-side version is an optimization on top.
Why Culinary Card works for this
This guide is the Culinary Card image pipeline described generically. Every image that reaches storage passes one choke point: anything over the size budget gets resized and re-encoded to WebP, anything already under it is stored as-is, and if the re-encode does not actually come out smaller the original is kept. That applies equally to a photo of a handwritten card and to an AI-generated illustration. Files live in Cloudflare R2 with zero egress and are served with a one-year cache header. That pipeline is why a product built around generating an image for every recipe can charge $10 a year instead of $10 a month. The sample recipe book is public if you want to see what the output actually looks like.
Frequently asked questions
What quality setting is safe for WebP?
For photographs displayed at or below their stored resolution, quality 80 to 85 is visually transparent to almost everyone. Below 70 you start seeing smoothing in textured areas like bread crusts and fabric. Encode one representative image at 70, 80, and 90 and compare at real display size; most people cannot find the difference between 80 and 90 while the file size difference is large.
Is AVIF worth it over WebP?
AVIF typically saves another 20 to 30 percent and browser support is now broad, but encoding is much slower, which matters in an upload path. A reasonable strategy is WebP everywhere first, then AVIF later for your highest-traffic images. WebP-first captures most of the win for a fraction of the complexity.
How much does this actually save in practice?
The compounding is what surprises people. A 4MB upload resized to 1200px WebP is roughly 120KB, a 97 percent storage cut before egress enters the picture. Moving serving from S3 to a zero-egress store removes the per-view cost entirely, and CDN caching removes most origin reads. Image-heavy apps routinely cut the storage-plus-bandwidth line by 90 percent or more with exactly the steps above.
Should I compress AI-generated images too?
Especially those. Image models return large PNGs (often 2 to 4MB), and if your product generates images per user action, you are creating your storage problem at machine speed. Re-encode to WebP the moment the model responds, before the file is ever written. Culinary Card does this for every generated recipe illustration and it is one of the reasons the credit pricing works.