If your site's main content is images, performance is not a nice-to-have — it's the product. On a photography portfolio, every gallery is a wall of large files, and each one competes for bandwidth, blocks rendering, and drags down your Core Web Vitals.
We ran into exactly this while building fotio.app, a marketplace for booking professional photographers across 20+ cities in Spain. Portfolio pages routinely load dozens of high-resolution photos, and early on our Largest Contentful Paint (LCP) was embarrassing. This post walks through what we changed, what actually helped, and — importantly — the CDN and caching details that most "just add a CDN" articles skip.
The starting point
A typical portfolio page looked like this:
- 30–60 images per page, many well over 1 MB each
- Original JPEGs served at full resolution regardless of the viewport
- A single origin server, so a user in Mexico City pulled every byte across the Atlantic
- No lazy loading — everything fetched on page load, including images far below the fold
The result was a heavy page, a slow first paint, and layout that jumped around as images popped in. Let's fix it in layers.
The single biggest win was switching away from unoptimized JPEG. AVIF and WebP produce dramatically smaller files at equivalent visual quality — AVIF frequently lands 40–50% smaller than a comparable JPEG.
The trick is to serve the best format the browser supports without forking your markup. The <picture> element handles this declaratively:
<picture>
<source srcset="/img/session.avif" type="image/avif" />
<source srcset="/img/session.webp" type="image/webp" />
<img src="/img/session.jpg" alt="Outdoor family session in Seville" />
</picture>
The browser walks the sources top to bottom and uses the first format it understands, falling back to the plain JPEG for anything ancient.
Layer 2: Stop shipping desktop images to phones
A 2400px-wide photo on a 390px phone screen is pure waste. Responsive srcset lets the browser pick an appropriately sized file:
<img
src="/img/session-800.jpg"
srcset="/img/session-400.jpg 400w,
/img/session-800.jpg 800w,
/img/session-1600.jpg 1600w"
sizes="(max-width: 600px) 90vw, 800px"
alt="Couple love story session in Barcelona"
loading="lazy"
decoding="async"
/>
Two cheap wins hide in there: loading="lazy" defers off-screen images until the user scrolls near them, and decoding="async" keeps image decoding off the main thread.
Layer 3: Reserve space so the layout doesn't jump
Lazy-loaded images that pop in without reserved space cause Cumulative Layout Shift (CLS) — content jumping around as photos arrive. Always give the browser the dimensions up front:
<img src="/img/session.jpg" width="1600" height="1067" alt="..." />
For a nicer perceived load, we also render a tiny blurred placeholder (an LQIP — low-quality image placeholder) that's swapped for the real image once it arrives. It's a few hundred bytes of inlined base64 that makes the page feel instant even before the full photo lands.
Layer 4: CDN and edge caching — the part people gloss over
Everything above shrinks the files. A CDN changes where they come from, and this is where a photo-heavy site lives or dies.
Why it matters
With a single origin, distance becomes latency. A user far from your server pays a round-trip penalty on every request, and images are your heaviest resource. A CDN copies your files to edge servers physically close to the user, so the bytes travel a short distance instead of across the world.
How the cache actually behaves
The first request for a file from a given region is a cache miss: the CDN fetches it from your origin, stores a copy on the nearest edge node, and serves it. Every subsequent request from that region is a cache hit — served straight from the edge, never touching your origin. Your job is to maximize hits and make each cached copy live as long as possible.
For images that never change, tell browsers and the CDN to hold onto them essentially forever:
Cache-Control: public, max-age=31536000, immutable
That's one year, and immutable tells the browser not to even bother revalidating. HTML is the opposite — it changes, so it gets a short TTL or no caching at all. Applying one blanket rule to everything is a classic mistake; split your static assets from your dynamic documents.
Cache busting with hashed filenames
Once a file is cached "forever," how do you ever update it? You don't change the content at the same URL — you change the URL. Embed a content hash in the filename:
session.a3f9b2.avif → session.7c1e04.avif (after re-export)
The old URL stays cached and harmless; the new one is a fresh miss that gets cached in turn. immutable plus hashed filenames is the standard pairing, and it means you never fight a stale cache again.
Many CDNs (Cloudflare Images, Fastly, bunny.net, imgix) can convert and resize on the fly at the edge, reading the browser's Accept header and returning AVIF or WebP from a single canonical URL:
/cdn/session.jpg?format=auto&width=800
This collapses the <picture>/srcset gymnastics into one source of truth and offloads the work from your origin entirely.
The metric this moves
Edge caching lands squarely on TTFB (time to first byte). A cache hit from a nearby edge answers in tens of milliseconds; a trip to a distant origin can be several hundred. Since your LCP element on a portfolio page is almost always an image, a faster TTFB on that image flows straight through to a faster LCP.
What the numbers looked like
Rough before/after on a representative gallery page (measured with WebPageTest from a mid-tier connection):
| Metric | Before | After |
| Page weight | ~9 MB | ~1.8 MB |
| LCP | ~4.8 s | ~1.6 s |
| TTFB (image, distant region) | ~380 ms | ~40 ms |
| CLS | 0.21 | 0.02 |
Most of the weight reduction came from format + responsive sizing; most of the latency reduction came from edge caching. They're complementary — you want both.
Takeaways
If you're optimizing a photo-heavy site, work in this order:
- Modern formats (AVIF/WebP with a JPEG fallback) — biggest byte savings.
- Responsive
srcset + lazy loading — stop over-serving and over-fetching.
- Explicit dimensions + LQIP — kill layout shift and improve perceived speed.
- CDN with aggressive, correct caching — long
immutable TTLs, hashed filenames, edge transforms.
None of these are exotic, but the caching layer is the one most teams under-configure, and it's the one that decides whether a user two continents away has a good time.
You can see the end result on a genuinely image-dense site at fotio.app — every city and portfolio page leans on exactly this pipeline.