The platform I work on is a consumer website and mobile app with millions of visits a month. The stack is a Next.js 16 site, an Express API, and MySQL on AWS. When I started looking at the AWS bill, most of what we paid went into doing the same work again and again.
Most of our visitors are guests browsing listing and detail pages. A logged-out visitor on /explore sees the same HTML as every other logged-out visitor, but our servers were rendering that page fresh for every single one of them.
Today the bill is up to 50% lower. There was no single fix. I went page by page and decided what was safe to cache and for how long, then made the servers smaller once they had less to do.
1. Decide what can be cached, per page type
Not every page can be cached the same way. We ended up with a few tiers:
| Page type | CDN cache (s-maxage) | Why |
|---|---|---|
| Home, listing, detail | 6 hours | Purged on publish |
| FAQ, policies, about | 24 hours | Rarely changes |
| Filtered or sorted listings | 5 minutes | Too many combinations |
| Search | not cached | Almost every query is different |
| Account, payments, API | not cached | Personal or operational |
Every page we cache sends the same kind of header:
Cache-Control: public, max-age=0, s-maxage=21600, must-revalidate
max-age=0 makes the browser check back every time, so a visitor never sees an outdated page from their own cache. s-maxage lets the CDN keep the page. Because we purge on publish (more on that below), a long CDN lifetime doesn't mean old content.
All of this lives in one small function that runs for guest requests. A simplified version of the idea:
function cacheControlFor(pathname: string, params: URLSearchParams) { if (isSearch(pathname)) return "no-store"; if (isFilteredOrSorted(params)) return cdnFor(5 * 60); if (isInfoPage(pathname)) return cdnFor(24 * 60 * 60); return cdnFor(6 * 60 * 60); }
Keeping it in one tested function, not spread across route configs, made it easy to review and easy to change.
2. The cache key is where the real work is
Setting cache headers is the easy part. The hard part is getting a good hit rate without ever showing someone the wrong page, and that comes down to the cache key.
Our CloudFront HTML policy puts these in the key:
- The path
- Only the query params that change what's on the page, like filters and sort order. Everything else is ignored.
- The Next.js RSC headers (
RSC,Next-Router-*), so a browser that expects HTML never gets a React Server Components payload, or the other way around - CloudFront's device headers (
CloudFront-Is-Mobile-Viewerand the tablet and desktop ones), because our mobile and desktop HTML are different
And it leaves these out on purpose:
- Cookies. The session token must never be part of the key, and logged-in requests skip the cache completely.
- Tracking params like
utm_*,gclid, andfbclid. Every ad click brings a unique one, so keeping them in the key would turn every campaign visit into a cache miss.
The origin sends a matching Vary header, so the CDN and the browser agree on what makes two responses different. I learned this one the hard way. Before I added the device headers to Vary, a browser could reuse the mobile HTML after the window was resized to desktop.
The home page was already above 90% hits. The goal was to bring listing pages close to that.
3. Purge instead of waiting
Long cache times only work if you can remove content the moment it changes. When someone publishes in the CMS, only the affected paths are purged, not the whole distribution. For anything the automation misses, the team can also purge a group of paths by hand.
Path purges are fast and cheap, and the rest of the cache stays warm. Clearing everything after each publish would have thrown away most of the savings.
4. Keep API caching at the origin
Most API responses depend on who is asking, so the JSON can't be cached at the edge. Instead, the Express API caches responses in Redis through a small middleware:
- Every key has a known prefix, so we can clear one key or a whole group.
- Responses for logged-in users are cached only when the key includes the user ID. Otherwise they're skipped, so one user's data can never reach another.
- Endpoints that return the same thing for everyone can share one entry.
One detour I should mention: I also built a Redis cache handler for Next.js itself, about 800 lines. We deleted it a week later. Once guest HTML was coming from the edge, it was one more cache to keep in sync for very little gain. Redis stayed where it clearly pays off, which is API responses.
5. Stop resizing images on the web servers
Images are the heaviest thing we serve. The built-in Next.js image optimizer resizes images on the same servers that render pages. So I gave next/image a custom loader that sends image requests to our own resizer instead, an AWS Lambda behind CloudFront. Now the web servers don't touch images at all.
For each request, the Lambda:
- Checks S3 for that exact size and returns it if it already exists.
- If not, it pulls the original, resizes it with Sharp, and saves it as WebP.
- It sends the image back right away and saves the new size to S3 in the background, so the visitor never waits for the upload.
SVGs, GIFs, and images that are already AVIF are passed through as they are. Every response has public, max-age=31536000, immutable, so after the first request CloudFront serves that size from the edge without running anything.
Three decisions mattered more than the resizing code itself:
- Only allow known sizes. The Lambda accepts about 30 width and height pairs, the sizes the site actually shows, and rounds any other request to the nearest one. Without this, every slightly different width becomes a new image to create, store, and cache, and the image hit rate breaks the same way HTML did with tracking params.
- WebP only. At first the Lambda chose between WebP and AVIF based on the browser's
Acceptheader. That meant two files for every size and one more thing for the cache to vary on. Serving only WebP removed both. - Size for the screen, within limits. Image URLs ask for the displayed width times a device pixel ratio of 1.5, up to 1920px. On a phone with a 3x screen the image is a little softer than it could be, but much lighter, because a 3x image has four times the pixels of a 1.5x one.
const requestedWidth = Math.min(Math.round(displayWidth * 1.5), 1920);
Static files like /_next/static, fonts, and icons get the same year-long, immutable cache.
6. Take traffic that can't be cached off the CDN
The API requests from section 4 can never be cached at the edge, but they still went through the same CloudFront distribution as the pages, with caching turned off. I moved them to their own hostname. The website's server rendering and the mobile app now call that.
Server-side API calls lost an extra hop. And CloudFront's hit-rate numbers stopped being pulled down by requests that could never be hits, so the pages with a real cache problem became easy to spot.
7. Then make the servers smaller
Caching alone doesn't lower the bill. CloudFront costs money too. The savings come when the servers behind it have less work to do and you pay for less of them.
Once guest pages came from the edge, images came from the Lambda, and API responses came from Redis, our servers had much less to do. So I moved them to smaller instances and resized the database to match the load we actually had, not the load we used to have. That step is what brought the bill down.
What I'd tell someone starting this
- Start from your traffic, not the technology. Our win came from noticing that most requests were from guests and returned the same page.
- Spend your time on the cache key. Every extra thing in the key lowers your hit rate. Every missing thing risks showing someone the wrong page.
- Pair long cache times with targeted purges. Together they give you fresh content and a high hit rate.
- Limit the variants. Fixed image sizes, one image format, and a short list of allowed query params all do the same job: fewer different URLs, more hits.
- Keep traffic that can't be cached off the CDN. Then its numbers show you where the real misses are.
- Shrink what's behind the cache. Caching saves money only when you give back the servers you no longer need.
- Delete layers that stop paying for themselves. A cache you don't need is one more place for old data to hide.