I didn't think about images seriously until I had to. For the longest time I treated them the way most people do when they're just trying to ship a feature: drop the file in a folder, reference it with an <img> tag, move on. It works, right up until it doesn't - until your Lighthouse score craters, someone on a slow connection sends you a screenshot of a half-loaded page, and you realize the hero image on your homepage is 4.8MB because that's just the size the camera spat out. That's roughly when I actually sat down and learned this stuff properly, mostly while rebuilding the image pipeline on a travel app I was working on. This is that write-up - storage, CDNs, optimizing with Gumlet, responsive images with srcset, caching, and the handful of gotchas that cost me actual debugging time.
Why This Is Worth Caring About
Here's the thing that surprised me most: on a typical website, images aren't just a big chunk of the page weight, they're usually the biggest chunk - somewhere between half and four-fifths of everything the browser downloads. So a single bloated 5MB photo isn't a minor inefficiency, it can single-handedly dominate your homepage's data usage. Do the math on a 4G connection (roughly 15 Mbps) and that's about 3 seconds spent on one image alone. Drop down to 3G (closer to 1 Mbps) and you're looking at 40 seconds. Compress that same image down to something sane, say 80KB, and it loads in under 50 milliseconds even on that same 3G connection. That gap is the difference between someone staying on your page and someone closing the tab.
It's not just about patience, either. Google actually scores you on this. Largest Contentful Paint (LCP) gets dragged down when your hero image takes forever to show up. Cumulative Layout Shift (CLS) happens when the browser doesn't know an image's dimensions ahead of time, so the page jumps around as things load in. Even First Input Delay can suffer when the browser is busy wrestling with a heavy image instead of responding to a click. All three of these are ranking signals, so bad image handling isn't just a UX problem, it's an SEO problem too.
Step 1: Deciding Where Your Images Actually Live
Before any optimization can happen, you need somewhere to put the raw files in the first place. In practice this comes down to two real options, and I've now built with both, so here's how I'd actually frame the decision rather than just listing bullet points at you.
Cloudinary is the managed route. You sign up, grab an SDK, upload a file, and you immediately get back a public URL that already sits behind a CDN. No servers to configure, no buckets to reason about. What I like about it is that the transformation logic lives in the URL itself - resize, convert format, compress - so a lot of what would otherwise be backend work just disappears. It also quietly does the right thing by serving WebP to browsers that support it and falling back to JPEG for the ones that don't, without you writing a single line of detection logic. If you're prototyping something or you're a small team that doesn't want to own infrastructure, this is genuinely the sensible default.
The catch is that convenience has a price tag, literally. Storage, bandwidth, and every transformation you run all add to your bill, and that adds up fast once you're not a toy project anymore. You're also tying your image URLs to Cloudinary's domain, so if you ever decide to leave, you're migrating every single reference in your database. And because you're working within whatever transformations Cloudinary has decided to expose, you lose some control the moment you need something slightly unusual.
// Uploading to Cloudinary from a Next.js component
import { CldUploadWidget } from 'next-cloudinary';
export function ImageUploader() {
return (
<CldUploadWidget
uploadPreset="my_unsigned_preset"
onSuccess={(result: any) => {
console.log('Public ID:', result.event.public_id);
console.log('Secure URL:', result.event.secure_url);
}}
>
{({ open }) => (
<button onClick={() => open()}>Upload Image</button>
)}
</CldUploadWidget>
);
}The other route is AWS S3, and this is what I ended up going with when I rebuilt SyncTrip's image pipeline, mostly because I wanted full control over how uploads and transformations were structured rather than working inside someone else's rules. With S3 you're managing the bucket yourself - permissions, versioning, lifecycle rules, all of it. The pattern that actually matters here is using presigned URLs: instead of the file passing through your own server on its way to storage, your backend just hands the client a short-lived signed URL, and the browser uploads straight to S3. Your server never touches the bytes. That one decision alone saved a surprising amount of load on the API once user uploads picked up.
The tradeoff is that S3 gives you none of the batteries Cloudinary includes. It doesn't resize anything, it doesn't convert formats, it's just a very reliable place to put files. You'll need to pair it with something else - Lambda, or a service like Gumlet - to actually do anything useful with those images at request time. And you own all the operational stuff too: CORS headaches, IAM policies that are wrong until they're suddenly very wrong, backup strategy, cost creep if you're not careful with lifecycle rules. It's more setup, but once it's running, it's yours and it's flexible.
// Generating a presigned S3 upload URL from a Next.js API route
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
export async function POST(req: Request) {
const { fileName, contentType } = await req.json();
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: `uploads/${Date.now()}-${fileName}`,
ContentType: contentType,
Metadata: {
'uploaded-by': 'web-app',
'upload-timestamp': new Date().toISOString(),
},
});
// Give the client a decent window to actually complete the upload
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return Response.json({ uploadUrl });
}
// Client side: get the URL, then upload directly to S3
async function uploadImage(file: File) {
const res = await fetch('/api/upload', {
method: 'POST',
body: JSON.stringify({ fileName: file.name, contentType: file.type }),
});
const { uploadUrl } = await res.json();
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// Public URL now looks something like:
// https://mybucket.s3.amazonaws.com/uploads/1234567890-filename.jpg
}Honestly, if you're early and just want to move fast, Cloudinary is the less painful choice. I only reached for S3 because I wanted the flexibility of chaining it with a separate optimization layer, and because at scale the pricing math worked out better for us. There's no universally correct answer here, it depends on how much infrastructure you're willing to own.
Step 2: What a CDN Is Actually Doing
This part took me a while to build an intuition for, so let me try to explain it the way I eventually understood it, rather than the textbook version. Say your image lives on a server in Virginia. Every single person who visits your site, no matter where they are, is fetching that file from Virginia. If someone in Tokyo loads your page, their request has to physically travel there and back - roughly 5,600 miles one way. Light in fiber optic cable moves at about two-thirds the speed it does in a vacuum, so that trip alone costs around 37 milliseconds, before the server has even done any work. Add processing and the return trip, and you're comfortably past 100ms just to fetch one file.
A CDN exists to make that distance problem go away. It's a network of servers scattered around the world - people usually call them edge servers or Points of Presence - and the idea is that a user's request gets served from whichever one happens to be geographically closest to them. So the Tokyo user hits a Tokyo edge server. If that edge server already has the image cached, it just hands it over, no trip back to Virginia required. If it doesn't have it yet, it fetches it from your origin server exactly once, caches it, and every subsequent request from that region is served locally from then on. That's how you go from 100ms+ down to something like 10-20ms.
- A user in Tokyo requests your image for the first time
- The request lands on a Tokyo edge server
- That server checks its local cache and finds nothing (this is the "cache miss")
- It fetches the image from your origin server in Virginia, just this once
- It stores a copy locally and finally serves it to the user
- Every other Tokyo-region visitor after that gets served instantly from the local cache
- Meanwhile, a completely separate user in London goes through the exact same process independently on the London edge server
- Each region ends up with its own local copy, optimized for whoever is closest to it
None of this caching happens by magic - CDNs are just respecting the Cache-Control header you send along with the response. Set Cache-Control: public, max-age=31536000 and you're telling every edge server to hold onto that file for a full year before checking back with the origin. Set max-age=3600 instead, and it'll go check for a fresh version every hour. Getting this number right matters more than people expect - too short and you're barely benefiting from the CDN at all, too long and you risk serving stale content after an update.
export async function GET(req: Request) {
const response = new Response(imageBuffer);
// Immutable, hash-named images can be cached basically forever
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
// For content that might change, a shorter window is safer
// response.headers.set('Cache-Control', 'public, max-age=86400');
return response;
}Step 3: Actually Optimizing Images with Gumlet
Storage and a CDN solve where your images live and how fast they travel, but neither one solves the more basic problem: are you even sending the right image in the first place? This is the part I underestimated. Imagine product photography shot at 5000x5000px, which is completely normal for a photographer to hand you, and then imagine displaying that as a 200x200 thumbnail on a mobile grid. If you just shrink it with CSS, the browser still downloads the full 5000x5000 file and throws away 98% of those pixels after the fact. That's the exact mistake I was making before I found Gumlet.
Gumlet sits between your storage (S3, in my case) and the person viewing your site, and it does the resizing, format conversion, and compression on the fly, purely through URL parameters. There's no backend endpoint to write, no image-processing library to install and maintain - you just change the URL and get a different image back.
This was the part that clicked for me and made the whole thing feel less like a "service" and more like a tool I actually understood. You take your base image URL and just append query parameters:
https://myapp.gumlet.io/products/red-shoe-raw.jpg?w=800&h=800&format=webp&q=85
w=800 -> resize to 800px wide (keeps aspect ratio unless you also crop)
h=800 -> constrain height to 800px
format=webp -> convert to WebP on the fly
q=85 -> compress to 85% quality
ar=1:1 -> force a specific aspect ratio
c=thumb -> smart-crop toward the actual subject, not just the center- w / h - set exact pixel dimensions
- ar - enforce an aspect ratio like 16:9 instead of fixed dimensions
- format - convert to webp, avif, jpg, and so on
- q - quality from 1-100 (75 is the default, and honestly it's rarely worth going above 85)
- c - crop mode: smart, thumb, face - useful when the subject isn't centered
- bg - a fill color for when you force an aspect ratio that doesn't match the source
- auto=format,compress - let Gumlet decide the best format/compression for whichever browser is asking
// Thumbnail for a product grid - small, aggressively compressed
https://myapp.gumlet.io/products/shoe.jpg?w=200&h=200&c=thumb&format=webp&q=70
// Desktop hero image - bigger, let Gumlet pick the best format
https://myapp.gumlet.io/hero.jpg?w=1200&format=auto&q=80
// Mobile product image
https://myapp.gumlet.io/products/shoe.jpg?w=600&format=webp&q=75
// Avatar with a face-aware crop
https://myapp.gumlet.io/users/avatar.jpg?w=128&h=128&c=face&format=webp&q=85The part that took me a bit to appreciate is what happens after that first request. The very first time a browser asks for a specific URL - say, ?w=800&format=webp&q=85 - Gumlet pulls the raw image from S3 and processes it right then: resizes, converts, compresses. Then it caches that exact processed result on the CDN edge closest to whoever asked for it. Every request after that, for that exact combination of parameters, gets served instantly from cache. Your origin bucket never gets touched again for that size/format pairing, and users in the same region all benefit from each other's first request. It's a genuinely different model from resizing with CSS - you only ever ship the bytes someone actually needs, and after the very first hit anywhere in a region, every following request is essentially free.
Step 4: Responsive Images with srcset
Once you can generate any size you want through Gumlet, the next question is how the browser knows which one to actually request. This is what srcset is for, and it's one of those HTML attributes that quietly does a lot of work most people never notice. You give the browser a list of image variants at different widths, tell it roughly how big the image will actually be rendered at different viewport sizes, and it works out on its own which file to download. A phone doesn't need the same file a 4K monitor does, and srcset is how you stop sending it that file.
<img
src="https://myapp.gumlet.io/article.jpg?w=800&format=webp&q=80"
srcset="
https://myapp.gumlet.io/article.jpg?w=480&format=webp&q=80 480w,
https://myapp.gumlet.io/article.jpg?w=800&format=webp&q=80 800w,
https://myapp.gumlet.io/article.jpg?w=1200&format=webp&q=80 1200w,
https://myapp.gumlet.io/article.jpg?w=1600&format=webp&q=80 1600w
"
sizes="
(max-width: 600px) 100vw,
(max-width: 1024px) 90vw,
1200px
"
alt="Article cover image"
loading="lazy"
/>The src attribute is just a fallback for anything that doesn't understand srcset. The srcset list is the menu of options, each tagged with its actual pixel width. And sizes is you telling the browser, in plain CSS-media-query terms, how wide the image will actually be rendered at different screen widths - not the image's own size, but its rendered size in your layout. loading="lazy" is a nice free addition too: it tells the browser not to bother fetching the image at all until it's about to enter the viewport, which matters a lot on long pages.
It's worth walking through this once because it demystifies the whole mechanism. The browser reads your sizes attribute and learns that, say, this image will render at full viewport width on mobile. It checks the device's actual viewport width and pixel density - a fairly typical phone might report 390px wide at 2x pixel density. Multiply those and you get an effective 780px of image data actually needed. The browser then scans your srcset list and picks the smallest variant that's still >= 780px, which in this example would be the 800w file. It downloads exactly that one. Nothing bigger, nothing smaller than necessary.
// components/ResponsiveImage.tsx
interface ResponsiveImageProps {
src: string;
alt: string;
basePath: string; // e.g. 'article-title'
lazy?: boolean;
}
export function ResponsiveImage({ alt, basePath, lazy = true }: ResponsiveImageProps) {
const gumletBase = 'https://myapp.gumlet.io';
return (
<img
src={`${gumletBase}/${basePath}?w=800&format=webp&q=80`}
srcSet={`
${gumletBase}/${basePath}?w=480&format=webp&q=80 480w,
${gumletBase}/${basePath}?w=800&format=webp&q=80 800w,
${gumletBase}/${basePath}?w=1200&format=webp&q=80 1200w
`}
sizes="(max-width: 768px) 100vw, 800px"
alt={alt}
loading={lazy ? 'lazy' : 'eager'}
/>
);
}Once I had this component, I stopped thinking about individual image sizes at all - every image on the site just goes through it, and the right variant gets picked automatically depending on wherever it's actually rendered.
Step 5: Stop Hardcoding Pixel Values Everywhere
This one isn't glamorous, but it saved me a lot of pain later. Early on I had w=800 and w=480 scattered across a dozen different components, and the moment I wanted to change a layout, I had to go hunting for every place that particular size had been typed by hand. The fix is boring but effective: centralize it, the same way you'd centralize a design system's spacing scale.
// lib/imageConfig.ts
export const IMAGE_SIZES = {
avatar: { xs: 40, sm: 64, md: 96 },
thumbnail: { xs: 120, sm: 200, md: 300 },
card: { xs: 280, sm: 400, md: 500 },
articleHero: { xs: 480, sm: 800, md: 1200 },
fullWidth: { xs: 480, sm: 768, md: 1024, lg: 1280, xl: 1600 },
} as const;
export const GUMLET_BASE = 'https://myapp.gumlet.io';
export const QUALITY_PRESET = {
low: 60,
medium: 75,
high: 85,
max: 90,
} as const;
export function generateSrcSet(
path: string,
sizes: number[],
format: 'webp' | 'auto' = 'webp',
quality: number = 75
) {
return sizes
.map((size) => `${GUMLET_BASE}/${path}?w=${size}&format=${format}&q=${quality} ${size}w`)
.join(',');
}
export function generateSizes(breakpoints: Record<string, string>) {
return Object.entries(breakpoints)
.map(([media, size]) => `(max-width: ${media}) ${size}`)
.join(',');
}
// e.g.
const heroSrcSet = generateSrcSet('hero.jpg', [480, 800, 1200, 1600], 'webp', 85);
const heroSizes = generateSizes({
'640px': '100vw',
'1024px': '80vw',
'9999px': '1200px',
});Once this existed, adding a new breakpoint or nudging a thumbnail size became a one-line change instead of a search-and-replace across the whole codebase.
Step 6: Getting Caching Right
There are really three layers of caching happening simultaneously, and it helps to think of them separately even though they all get controlled by the same header. Your browser caches images locally on the user's device. The CDN caches them on edge servers around the world. And if you're using something like CloudFront in front of your origin, there's often another caching layer right there too. All three respect Cache-Control, just at different points in the chain.
// Content-addressed images (hash baked into the filename) never change,
// so it's safe to cache them essentially forever
Cache-Control: public, max-age=31536000, immutable
// Gumlet-processed variants are regenerated on demand, but still stable
// enough to cache for a good while
Cache-Control: public, max-age=2592000 // 30 days
// Anything that changes more often
Cache-Control: public, max-age=3600 // 1 hour
// Anything user-specific or sensitive shouldn't sit on a shared CDN at all
Cache-Control: private, max-age=0, must-revalidateThis is the mistake I actually made once: I cached an image aggressively, then updated the file in place without changing its name, and then spent a confused twenty minutes wondering why nobody was seeing the new version. The browser and CDN were both doing exactly what I told them to. The fix is to never reuse a filename for different content - bake a content hash into the name instead, so a changed file automatically gets a new URL and there's nothing to invalidate.
// Risky: same URL, different content, cached for a year -> nobody sees the update
myapp.gumlet.io/hero.jpg
// Better: the filename itself encodes the content
myapp.gumlet.io/hero-abc123def456.jpg
// change the file, the hash changes, the URL changes, cache is naturally busted
myapp.gumlet.io/hero-xyz789abc123.jpg
// Next.js's built-in Image component does exactly this under the hood
import Image from 'next/image';
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} />Step 7: Actually Checking Whether Any of This Worked
It's easy to assume everything you set up is doing what you think, so it's worth actually measuring it. The metrics that matter most here are Largest Contentful Paint, ideally under 2.5 seconds, and Cumulative Layout Shift, ideally under 0.1 - both of which are usually caused by image problems specifically, not JavaScript. Beyond those, I keep an eye on the average file size being served (it should trend down over time, not up), the CDN cache hit ratio (a low hit ratio usually means your cache headers are too aggressive or your URLs are too varied), and Time to First Byte, which should stay under 200ms if your CDN is doing its job.
- Google PageSpeed Insights - free, and pulls real Chrome user data, not just synthetic tests
- WebPageTest - gives you full waterfall charts and lets you test from specific regions
- Gumlet's own analytics - shows cache hit ratio and bandwidth actually saved
- Cloudinary's dashboard, if you went that route - upload stats and transformation usage
- Vercel Analytics, if you're on Next.js - real user monitoring without extra setup
Layout shift specifically comes from the browser not knowing an image's size before it loads, so it reserves zero space and then everything jumps once the image arrives. The fix is almost embarrassingly simple: always give the browser the dimensions up front, either explicitly or through CSS aspect-ratio.
<!-- No dimensions -> browser reserves no space -> layout jumps when it loads -->
<img src="image.jpg" alt="Photo" />
<!-- Explicit dimensions -> space reserved ahead of time -->
<img src="image.jpg" alt="Photo" width="800" height="600" />
<!-- Or the modern CSS approach -->
<img src="image.jpg" alt="Photo" style="aspect-ratio: 4 / 3" />
<!-- Next.js Image handles this automatically -->
import Image from 'next/image';
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />The Checklist I Actually Use Now
- Always set width/height or aspect-ratio, no exceptions
- Lazy-load anything below the fold
- Serve WebP/AVIF where supported, fall back gracefully otherwise
- Generate a proper srcset and let the browser make the size decision
- Cache hashed/immutable files for a year, transformed variants for around 30 days
- Compress to roughly 70-85% quality - the difference above that is rarely visible
- Never single-region host images anymore, a CDN is table stakes at this point
- Check Core Web Vitals regularly, not just once at launch
- Centralize image sizing config instead of hardcoding numbers everywhere
- Set sane Cache-Control headers at the origin, not just at the CDN
- Write real alt text, both for accessibility and because it genuinely helps SEO
- Consider AVIF where you can - it beats WebP by another 20-30% in most cases
The Whole Pipeline, Strung Together
If I had to compress everything above into one mental model, it'd look like this: a user picks a file, your frontend requests a presigned URL, and the raw file goes straight to S3 without ever touching your server. S3 holds it permanently as the source of truth, ideally under a content-addressed filename. Whenever that image actually needs to be displayed, your frontend builds a Gumlet URL with the right size and format baked into the query string. The first time that specific URL is requested anywhere in a region, Gumlet pulls the raw file from S3, processes it, and caches the result on the nearest edge server. Every request after that, from anyone nearby, gets served in well under 50ms, and the browser itself caches it locally too, so repeat visits don't even hit the network.
It's worth pausing on the actual economics of this for a second, because it's easy to treat it as a nice-to-have. A 5MB unoptimized image costs you real bandwidth, real server capacity, and real user patience. The same image at 80KB costs roughly sixty times less to serve and loads roughly sixty times faster - and it ranks better too, because Google is quite literally measuring the thing you just fixed. The infrastructure to get this right is genuinely small compared to what you get back from it.
A Few Things That Tripped Me Up
Almost always this is a caching problem, not a code problem. If you set max-age=31536000 and then reused the exact same filename for a new version of the image, the browser and CDN are both being obedient - they were told a year, so they're waiting a year.Content - addressed filenames sidestep this entirely.
If you switch a URL from ?format=jpg to ?format=webp and still see JPEGs coming back, it's usually a stale edge cache for that exact URL. A quick workaround is appending a version bump like &v=2, which effectively forces a fresh cache entry.
Double check you're setting the dimensions on the <img> element itself and not just its wrapping container. The browser needs the intrinsic ratio on the image tag to reserve the right amount of space before the file has actually loaded.
If someone takes their time picking a file and your presigned URL was only valid for 10 minutes, the upload just fails silently from their perspective. I bumped mine up to an hour (expiresIn: 3600) and haven't had a complaint since.
Closing Thoughts
None of this is glamorous work. Nobody's going to compliment your srcset attribute. But it's foundational in a way that compounds - a site that handles images properly just quietly beats the sites that don't, on every metric that actually matters: search rankings, how long people stay, whether they convert. The tooling here (S3 or Cloudinary, a CDN, Gumlet, srcset, sane cache headers) is standard enough now that there's really no excuse to skip it. Set it up once, early, and you stop thinking about it - which is exactly how infrastructure should feel.
This post draws on my own experience rebuilding an image pipeline, written up with some AI assistance to help structure and expand on it. If anything here looks conceptually off, I'd genuinely appreciate a heads up - feel free to reach out.Contact
