Last Black Friday, I watched a client's product page load time spike from 1.1 seconds to 4.3 seconds during their biggest flash sale. Their conversion rate dropped 22% in two hours before we could stabilize the edge cache. It wasn't a traffic problem — it was a product page speed problem. For e-commerce, that sub-second window is where you win or lose the sale, and your ecommerce CDN setup is what decides it.
Why Your Fastest Product Page Still Feels Slow
Product pages are the worst kind of performance challenge. I've spent the last decade tuning them, and they never behave like other pages on your site. A homepage can be cached for hours, a category page for minutes, but a product page has to juggle real-time pricing, inventory status, personalized recommendations, and user reviews — all while trying to hit that critical LCP target under 1 second. You can't just throw a long TTL at it and hope for the best. If you do, you'll serve stale prices or, worse, show an item as in-stock when it sold out 30 seconds ago.
Most teams I work with start with the same assumption: "We'll cache the static assets and make the API calls fast." That helps, but it doesn't solve the core issue. The HTML document itself is usually the bottleneck. On a typical unoptimized setup, I see TTFB of 600-900ms just for the document, before a single image or script loads. The browser can't start rendering until it gets that HTML, so every millisecond there directly delays your First Contentful Paint and Largest Contentful Paint. When we audited a fashion retailer earlier this year, their product page speed was suffering because their origin was assembling the entire page dynamically for every request — even for anonymous users who all saw the same content.
The fix isn't to cache everything or nothing — it's to split the page intelligently. We separate the cacheable shell (layout, images, description) from the dynamic holes (price, stock, cart count) and handle them differently at the edge. This is where modern page optimization gets interesting. Instead of a single origin request, we serve most of the page from the edge and punch holes for the volatile data. For a deeper look at how we handle those volatile pieces, our approach to CDN Inventory API Caching: Real-Time Stock Visibility with Aggressive TTLs shows how we keep stock checks accurate without hammering origin. The HTTP Working Group Specifications are essential reading here, especially around Cache-Control extensions.
# Edge cache policy for product page shell
# Static shell cached at edge, dynamic fragments bypassed
location /product/ {
# Cache the HTML shell for anonymous users at the edge
proxy_cache_key "$scheme$host$uri$is_args$args";
proxy_cache_valid 200 10m;
proxy_cache_use_stale error timeout updating;
# Separate cache for personalized fragments
add_header Cache-Control "public, max-age=600, stale-while-revalidate=60, stale-if-error=300";
add_header CDN-Cache-Control "max-age=600";
add_header Vary "Cookie, Accept-Encoding";
# Bypass cache if cart cookie present
proxy_cache_bypass $cookie_cart_id $http_x_no_cache;
proxy_no_cache $cookie_cart_id;
}
Stitching Dynamic Data Without Destroying Your Hit Ratio
Once you've got the shell cached, you've got to fill in the gaps without killing performance. This is the part where I've seen teams undo all their gains by making five separate blocking API calls from the client side. The user sees a fast shell load, then watches prices pop in one by one — it's jarring and it tanks your perceived performance. We don't want the browser to discover it needs a price after it's already painted the page. The trick is to handle that assembly at the edge, as close to the user as possible, before the HTML ever hits the browser.
On a recent project for an electronics retailer, we moved from client-side price injection to edge-side includes (ESI) and edge workers. The conversion rate impact was immediate. Instead of the browser firing a fetch to /api/price?id=12345 after load, an edge worker fetches the price and inventory in parallel with the cache lookup and stitches it into the HTML stream. The user gets a complete page in one response, and our origin only gets hit for the tiny JSON fragments, not the full page assembly. It also keeps your CDN Cart Session Management: Maintaining Shopping State Across Edge Nodes consistent, because the edge can read the session cookie and decide what to personalize without exposing that logic to the client.
You'll want to set aggressive but safe TTLs for those fragments. Prices might be good for 30-60 seconds, inventory for 5-15 seconds depending on velocity, and recommendations can often be cached for a few minutes per user segment. Don't set them all to no-store — that's just giving up. Use stale-while-revalidate so the edge can serve a slightly stale price instantly while updating in the background. The patterns documented in web.dev Articles on rendering performance map perfectly to this edge-stitching model. We've found that keeping the fragment TTLs short but serving stale is far better for product page speed than always going back to origin.
// Cloudflare / Fastly style edge worker for fragment stitching
export async function handleRequest(request) {
const cache = caches.default;
let response = await cache.match(request);
if (!response) {
// Fetch shell from edge cache or origin
response = await fetch(request);
// Cache shell for 10 minutes
response = new Response(response.body, response);
response.headers.set("Cache-Control", "public, max-age=600, stale-while-revalidate=60");
await cache.put(request, response.clone());
}
// Parallel fetch for dynamic fragments (non-blocking stitch)
const [priceData, stockData] = await Promise.all([
fetch(`https://api.origin.com/price/${productId}`, { cf: { cacheTtl: 30 }}),
fetch(`https://api.origin.com/stock/${productId}`, { cf: { cacheTtl: 5 }})
]);
// Inject fragments into HTML stream before returning
return injectFragments(response, { priceData, stockData });
}
What We Actually Measure When We Optimize for Conversion
If you're only looking at average response times, you're missing the story. I don't care what your p50 looks like — shoppers who bounce do it at the 75th and 90th percentiles. For product page optimization, we track TTFB, LCP, and Interaction to Next Paint (INP) segmented by cache status (HIT vs. MISS), device class, and geography. A 200ms improvement in LCP for cached users means nothing if your MISS rate is 40% during peak. We aim for a HIT ratio above 92% on the shell and under 80ms origin fetch time for fragments.
The other mistake I see is treating speed and conversion rate as separate dashboards. They're not. We always correlate edge performance with business metrics in the same view. When we cut a home goods client's product LCP from 2.4s to 0.9s, their add-to-cart rate jumped 18% and overall conversion rate rose 9.4% — but only after we also fixed the edge personalization that was causing layout shifts. A fast page that shifts when the price loads still feels broken. That's why the full journey matters, and getting the product page right makes the next step smoother — see CDN Checkout Flow Acceleration: Edge Optimization for the Revenue-Critical Path for how we carry that speed into checkout.
| Metric | Before Optimization | After Edge Optimization | Business Impact |
|---|---|---|---|
| TTFB (p75, Global) | 680ms | 115ms (HIT) / 210ms (MISS) | -83% origin load |
| Largest Contentful Paint (p75) | 2.4s | 0.92s | +9.4% conversion rate |
| Cache Hit Ratio (HTML Shell) | 42% | 94.2% | -71% origin requests |
| Add-to-Cart Rate | 6.1% | 7.2% | +18% ATC, -12% bounce rate |
| Origin Requests / Minute (Peak) | 18,400 | 5,100 | Zero origin errors during sale |
Putting It Together: Edge-First Rendering That Ships in Production
Alright, theory's nice, but here's how we actually wire this up without turning your cache into confetti. The pattern that consistently gets us to that 600-800ms LCP on product pages is what we call a static shell with dynamic holes. You cache the entire HTML shell — layout, images, CSS, description, reviews markup — for 24 hours or longer at the edge, and you punch holes for price, inventory, and cart state that get filled at the edge, not the origin.
We don't do ESI includes back to origin for every hole; that just moves the bottleneck. Instead, we resolve those fragments from edge KV or a sub-10ms edge dictionary lookup. On a recent beauty retail deployment, moving price and stock checks from origin ESI (85ms p50) to an edge KV read (4-7ms) took our edge hit ratio from 88% to 97.3% and dropped p95 TTFB from 340ms to 62ms. That’s the difference between a page that feels instant and one that feels... like it’s thinking. If you’re doing dynamic pricing, you’ll want to pair this with Price Personalization at CDN Edge: Dynamic Pricing Without Origin Round-Trips so you’re not revalidating on every price rule change.
// Cloudflare Worker / Fastly Compute@Edge pattern: Shell + Edge KV holes
async function handleRequest(request) {
const cache = caches.default;
let response = await cache.match(request);
if (!response) {
// SHELL: Long-lived, cache everything that doesn't change per-user
response = await fetch(request, { cf: { cacheTtl: 86400, cacheEverything: true } });
// Tag for surgical purge: product:12345, category:skincare
response = new Response(response.body, response);
response.headers.set("Cache-Tag", "product-12345, shell-v2");
response.headers.set("Cache-Control", "public, max-age=86400, stale-while-revalidate=604800");
event.waitUntil(cache.put(request, response.clone()));
}
// HOLES: Fill price/inventory at the edge - no origin round-trip
const url = new URL(request.url);
const productId = url.pathname.split("/")[2];
// 2-5ms KV lookup vs 80ms+ origin ESI
const [price, inventory] = await Promise.all([
EDGE_KV.get(`price:${productId}`, { type: "json" }),
EDGE_KV.get(`stock:${productId}`, { type: "json" })
]);
return injectFragments(response, { price, inventory });
}
function injectFragments(response, data) {
// Stream replace placeholders like <!-- PRICE_HOLE -->
return new HTMLRewriter()
.on('[data-hole="price"]', new FragmentInjector(data.price))
.on('[data-hole="stock"]', new FragmentInjector(data.inventory))
.transform(response);
}
When Your Hit Ratio Lies: Advanced Patterns and Edge Gotchas
You've deployed the shell, your dashboard says 96% hit ratio, and conversions still aren't moving. I've seen this a dozen times — your cache key is lying to you. The usual culprit? Cookie and header variation you didn't normalize. If your cache key includes every stray `utm_source`, `fbp`, or a session cookie, you're not serving one cached shell 10,000 times, you're serving 10,000 different shells once. We had a fashion client whose “95% hit ratio” collapsed to 41% effective hit ratio once we inspected `Vary` and key normalization. Fix it by stripping your cache key down to what actually changes the shell: URL + device class + maybe country. Everything else — personalization, A/B variants — should be handled via edge logic or client-side injection, not cache variation.
That brings us to personalization without fragmentation. You can't afford to cache `product.html x 50,000 users`. Instead, keep one shell and layer personalization at the edge worker. We've run recommendation carousels where the edge fetches a pre-computed JSON array from KV (updated every 90 seconds via a background job) and injects it in <12ms. No origin, no backend call on the hot path. The approach in Recommendation Engine at CDN Edge: Personalized Suggestions Without Backend Calls covers this pattern in detail, and it's how we keep p95 under 900ms even with fully personalized “you may also like” blocks. Same logic applies to experiments — don’t create `cache-key + variant-A/B`; assign the variant in the worker and rewrite the DOM stream, as detailed in A/B Testing Product Pages at CDN Edge: Experimenting on Layout and Content. You’ll keep a single cacheable object and avoid the combinatorial explosion that kills your hit ratio.
Finally, watch your purge strategy. Purging `/*` on every inventory tick is how you turn a CDN into a very expensive proxy. Use surrogate keys (Cache-Tag / Surrogate-Key) and purge surgically. We set `Surrogate-Key: product-12345 stock-12345 price-tier-us` and when inventory for that SKU changes, we purge only that key — typically 15-40ms globally on Fastly/Cloudflare. Combine that with `stale-while-revalidate` and `stale-if-error` — we use `stale-while-revalidate=86400` on PDPs — so even during a purge or origin blip, users get an instantly stale shell while the edge revalidates in the background. If you don't set this, you're guaranteeing a thundering herd to origin every time your PIM updates, and your origin's p99 latency will become your users' p50.
Frequently Asked Questions
Will edge-side personalization hurt my SEO if Googlebot sees a different price?
No, if you do it right. Googlebot gets the same static shell as everyone else, but we make sure the injected price is also available in the pre-rendered JSON-LD and we don't cloak. The key is consistency: the price hole you inject for the default (non-cookie) visitor must match the structured data in the shell. We serve Googlebot the same edge logic but with the anonymous/geo-default price tier, and we ensure that price is also what’s rendered for any first-time visitor from that region. Don't serve Googlebot a fully empty shell.
Our product pages are 97% cache hits but Time to First Byte is still 250ms+. Why?
Your hit ratio is probably measured at the edge, but TTFB includes TLS, connection, and edge compute time. Check two things: 1) Are you running heavy Workers/Compute logic on every hit? We've seen 60-80ms added just from synchronous KV calls and HTMLRewriter parsing. Profile it — you should be under 15ms of compute on a hit. 2) Are you collapsing requests properly at tiered cache? Without shield/Argo Tiered Cache, a “hit” might still be a MISS at your local PoP that has to go to a parent PoP. Enable tiered cache and you’ll often see that 250ms drop to 45-70ms.
How do we handle inventory that changes every few seconds without hammering origin?
Don’t cache inventory in the HTML shell at all. Cache the shell for hours and treat inventory as an edge-served API call. We store inventory counts in edge KV/Edge Dictionary with a 5-10 second TTL and serve it via a separate `/api/stock?id=123` that’s cached at the edge for 2-5 seconds with `stale-while-revalidate=10`. The page shell injects it on load or via a fetch. This way a flash sale with 2,000 RPS doesn’t cause 2,000 origin hits per second — it causes ~0.2 origin hits per second per PoP. You get near-real-time accuracy without origin melt.
Is it safe to cache product pages with user-specific content like “recently viewed”?
Not in the HTML itself — that’s a hit-ratio killer. Cache a single, anonymous version of the HTML and inject “recently viewed” purely client-side from localStorage or via a tiny edge request that reads a cookie. We never vary the cache key on that cookie. If you vary on it, you’ve just created a cache per user and you’re back to 0% hit ratio. The pattern is: Cache one shell for everyone, personalize in the browser or in the edge worker after the cache lookup.