How We Halved Our AWS Bill by Caching a Next.js Storefront at the Edge

· Harsh Gupta

Beyoung is a fashion e-commerce site with 5M+ monthly impressions. The stack is a Next.js 16 storefront, an Express API, and MySQL on AWS. When I started looking at our bill, it was around $3,000 a month, and most of it was paying for the same work over and over.

Most of our traffic is guests browsing categories and product pages. A logged-out visitor on /mens-shirts gets the same HTML as every other logged-out visitor, yet every one of those requests was being rendered at the origin.

Today the bill sits around $1,500–2,000 a month. No single trick got us there. It came from deciding, page by page, what is safe to cache and for how long.

1. Decide what is cacheable, per page type

Not every page can be cached the same way. We ended up with a handful of tiers:

Page typeCDN cache (s-maxage)Why
Home, category, product6 hoursPurged on publish
FAQ, policies, about24 hoursRarely changes
Filtered or sorted listings5 minutesToo many combinations
Searchnot cachedEffectively unique per query
Account, checkout, APInot cachedPersonal or operational

Every cacheable page sends the same shape of header:

Cache-Control: public, max-age=0, s-maxage=21600, must-revalidate

max-age=0 means browsers always revalidate, so a shopper never sees a stale price from their own cache. s-maxage lets the CDN hold the page. Because we purge on publish (more on that below), a long CDN lifetime doesn't mean stale content.

The policy lives in one small function the proxy calls for guest requests:

export function getGuestCacheControl( pathname: string, params: URLSearchParams ) { if (pathname.startsWith("/catalogsearch")) return NO_STORE; if (params.has("filter") || params.has("order")) return html(5 * 60); if (pathname === "/") return html(6 * 60 * 60); if (STATIC_INFO_PATHS.has(pathname)) return html(24 * 60 * 60); return html(6 * 60 * 60); }

Having it in one tested function, instead of scattered 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 easy. Getting a good hit rate without ever serving the wrong page is the hard part, and it all comes down to the cache key.

Our CloudFront HTML policy includes:

  • The path
  • Only the query params that change the page: filter and order
  • Next.js RSC headers (RSC, Next-Router-*), so a React Server Components payload is never served to a browser expecting HTML, or the other way around
  • CloudFront's device headers (CloudFront-Is-Mobile-Viewer and friends), because our mobile and desktop HTML differ

And it deliberately excludes:

  • Cookies. The session token must never be part of the key, and logged-in requests skip the cache entirely.
  • Tracking params like utm_*, gclid, and fbclid. Every ad click carries a unique one, so leaving 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. We learned that the hard way: before we added the device headers to Vary, a browser could reuse device-specific HTML after a viewport change.

Our target was for category pages to approach the home page's hit rate of 90%+.

3. Purge instead of waiting

Long cache lifetimes only work if you can take content down the moment it changes. When a merchandiser publishes in the CMS, a token-protected webhook purges the affected paths, not the whole distribution. For the cases automation misses, there's an internal purge page where the team can clear grouped paths and watch the edge invalidation finish.

Path purges are fast, cheap, and leave the rest of the cache warm. A full invalidation after every publish would have undone most of the savings.

4. Keep API caching at the origin

The storefront's API requests are signed per caller, so the JSON can't be cached at the edge. Instead, the Express API caches responses in Redis with a small middleware:

  • Every key has a known prefix, so a purge endpoint can invalidate one key or a whole group.
  • Authenticated responses are only cached when the key includes the user ID. Otherwise they're skipped, so one customer's data can never leak to another.
  • Endpoints whose content doesn't depend on who is asking can opt into one shared entry.

One honest detour: I also built a Redis-backed cache handler for Next.js itself. We deleted it about a week later, roughly 800 lines. Once guest HTML was served from the edge, it was one more cache layer to keep consistent for very little gain. Redis stayed where it clearly pays off: API responses.

5. Stop resizing images on the web servers

Product images are the heaviest thing we serve. They now go through an AWS Lambda behind CloudFront. On a cache miss, it pulls the original from S3, resizes it with Sharp, picks WebP or AVIF based on the browser's Accept header, and returns it with long-lived cache headers. After the first request, CloudFront serves that size from the edge with no compute at all.

Static assets and next/image responses share one policy: a year-long, immutable cache.

What I'd tell someone starting this

  1. Start from traffic, not technology. Our win came from noticing that most requests were anonymous and identical.
  2. Spend your time on the cache key. Every extra thing in the key lowers your hit rate. Every missing thing risks serving someone the wrong page.
  3. Pair long TTLs with targeted purges. That combination gives you freshness and a high hit rate at the same time.
  4. Delete layers that stop earning their keep. A cache you don't need is just another place for stale data to hide.
LinkedIn