MICROMARKETING Book Free Consult
← Web Development

Edge Caching an Astro Storefront on Cloudflare Without Breaking Attribution

Edge Caching an Astro Storefront on Cloudflare Without Breaking Attribution In March 2026 I watched a paid-search landing page for a 420-SKU apparel store go from a 1.9 second TTFB in Los Angeles to 96 ms after one Cloudflare rule change. Revenue did not magically double. That would be a fairy tale. But Google Ads […]

Edge Caching an Astro Storefront on Cloudflare Without Breaking Attribution

In March 2026 I watched a paid-search landing page for a 420-SKU apparel store go from a 1.9 second TTFB in Los Angeles to 96 ms after one Cloudflare rule change. Revenue did not magically double. That would be a fairy tale. But Google Ads Quality Score stopped wobbling, Klaviyo forms loaded before impatient thumbs bailed, and the founder quit asking why the “fast” Astro build still felt slow on product pages.

Astro is a good storefront framework because it does less work in the browser. Cloudflare is a good edge network because it puts that work close to the shopper. The trap is assuming those two facts automatically produce a fast commerce site. They don’t. An Astro storefront on Cloudflare Workers still needs a caching plan that knows the difference between a product grid, a cart, a UTM-tagged landing page, and a PDP with inventory changing every 90 seconds.

I’m writing this for marketing operators and founders, not for a platform team with six SREs. If you’re running Meta, Google Shopping, SEO, email, and a Shopify or headless commerce catalog, your caching strategy has one job: make public pages fast without hiding the truth from analytics, merchandising, and shoppers.

Start With The Routes That Make Money

For a typical Astro 7 storefront on Cloudflare Workers, I split routes into four buckets on day one. Home and campaign landing pages get the longest edge TTL, usually 30 minutes to 6 hours. Category pages get 5 to 15 minutes because filters, sort order, and merchandising change during a promo. Product detail pages get 60 to 300 seconds unless inventory is soft-reserved. Cart, checkout, account, and loyalty pages get no shared cache at all.

That split matters because Cloudflare’s default cache behavior is conservative around dynamic responses. The Cloudflare cache docs say a response with Set-Cookie, private, no-store, no-cache, or max-age=0 will not be cached by default, and only GET requests are eligible. That is exactly what you want for /cart and exactly what kills performance when your CMS adds a harmless preview cookie to /collections/summer-sale.

Astro 7, released in July 2026, made route caching stable after experimenting with it in Astro 6. The API is plain enough to use inside a storefront page: Astro.cache.set({ maxAge: 300, swr: 600, tags: ['pdp', 'sku-123'] }). I still treat that as application intent, not the whole cache system. Cloudflare Cache Rules, Workers, and response headers decide what actually happens at the edge.

Cache HTML, But Keep Query Strings On A Leash

Paid traffic creates ugly URLs. A clean PDP like /products/linen-shirt becomes /products/linen-shirt?utm_source=google&utm_medium=cpc&utm_campaign=brand&gclid=.... If Cloudflare varies the cache key on every query string, your expensive ad clicks become cold-cache probes. If you strip every query string before analytics sees it, your attribution breaks.

The fix I use is boring and reliable. Normalize the cache key for HTML, but preserve the original URL for the browser, analytics scripts, and server-side event capture. For storefront pages, keep only query parameters that change the rendered page: variant, color, size, page, sort, and sometimes currency. Drop utm_*, gclid, fbclid, msclkid, and klaviyo_id from the cache key. Meta and Google still read those values in the URL. Cloudflare just stops treating each click as a unique page.

On one Cloudflare Workers deployment in May 2026, this took a Google Shopping campaign page from a 38 percent cache-hit rate to 84 percent over 72 hours. The catalog had 612 products, 11 collection pages, and 4 active UTM-heavy campaigns. No platform rewrite. The change was a cache-key policy and a purge hook from Sanity.

A Worker-level sketch looks like this:

const url = new URL(request.url);
const cacheUrl = new URL(url);

for (const key of [...cacheUrl.searchParams.keys()]) {
  const keep = ['variant', 'color', 'size', 'page', 'sort', 'currency'].includes(key);
  if (!keep) cacheUrl.searchParams.delete(key);
}

const cacheKey = new Request(cacheUrl.toString(), request);

That code is not a full Worker. It is the part worth caring about. Your measurement people need the original request. Your cache needs a calmer one.

Give Static Assets A Year

Astro’s built assets under /_astro/ are content-hashed. When the file changes, the filename changes. That makes them safe to cache for a year with Cache-Control: public, max-age=31536000, immutable. Astro’s Node adapter docs call out that exact header for built assets, and the same logic applies when Cloudflare serves static assets from a Workers deployment.

Do this for JavaScript, CSS, fonts, and processed images that come out of the Astro build. Do not do it for files dumped into public/ unless you version the filename yourself. public/logo.png is copied as-is by Astro. If your designer replaces it before the Black Friday sale on November 20, 2026, a shopper’s browser may keep the old one until the browser TTL expires.

For marketing teams, the practical rule is simple. Anything campaign managers expect to swap by filename, like hero.jpg, gets a short browser TTL, maybe 10 minutes. Anything with a hash in the path gets a year. Your homepage can be edge-cached for 30 minutes while its hashed CSS sits in the browser for 365 days. Those are separate layers.

Use Stale-While-Revalidate For Merchandising Pages

Category pages are where caching arguments usually get emotional. The growth person wants speed. The merchandiser wants the new “Low Stock” badge visible right now. The founder wants both by Friday.

I reach for stale-while-revalidate on collection pages because a 5-minute-old grid is usually acceptable, and a slow grid during a paid campaign is not. A header like this is a sensible starting point for /collections/* on a mid-volume store:

Cache-Control: public, max-age=300, stale-while-revalidate=600
Cloudflare-CDN-Cache-Control: public, max-age=300, stale-while-revalidate=600

Cloudflare documents CDN-Cache-Control and Cloudflare-CDN-Cache-Control for controlling surrogate cache behavior separately from browsers. That separation matters when your shopper’s browser should recheck sooner than Cloudflare’s edge. I often set browser max-age=60 and Cloudflare edge max-age=300 for product listing pages.

PDPs need a tighter hand. If inventory is exact and overselling hurts, cache the shell and fetch inventory from an API endpoint with a 15-second TTL. If inventory is mostly informational, a 60-second PDP cache is fine. In Shopify Plus and Commerce Layer setups I’ve worked on, the buy box was the danger zone. The product description, reviews summary, image gallery, and FAQ could sit at the edge much longer.

Never Cache Personalization By Accident

The fastest broken storefront is still broken. If your Astro middleware adds Set-Cookie to every response so a popup can remember that someone dismissed “10% off,” Cloudflare may stop caching those pages by default. If you force caching with a Cache Rule, you can leak a personalized response into a shared cache. Both mistakes happen in real stores.

Keep personalization client-side until the shopper identifies themselves. Geo banners, first-visit modals, recently viewed products, and UTM-specific copy should hydrate after the HTML arrives. The base HTML should be the same for a shopper from Austin, a shopper from Toronto, and Googlebot. When you truly need server-side personalization, return Cache-Control: private, no-store and accept the cost.

This is where Astro’s partial-hydration model helps. A static PDP can ship as HTML, while a tiny React or Preact island reads localStorage and shows the right offer after load. That is a better trade for most acquisition pages than server-rendering a unique page per visitor.

Purge By Tag, Not By Panic

Cache invalidation should follow the commerce event. When a price changes for SKU LINEN-042, purge the PDP, the category pages that include it, and any landing page modules that feature it. Do not purge the whole zone because one product image changed.

Astro’s route cache API supports tags, and Cloudflare supports cache purging by tag on the CDN side. I like tags that mirror the business object: sku:LINEN-042, collection:summer-shirts, landing:google-brand-august-2026. A CMS publish webhook from Sanity, Contentful, Shopify, or Builder.io can call a small /api/revalidate route and invalidate the affected objects.

Here is the shape I use in Astro API routes:

export async function POST(context) {
  const { tags } = await context.request.json();
  await context.cache.invalidate({ tags });
  return Response.json({ purged: tags.length });
}

Protect that endpoint. I usually require an HMAC signature with a 5-minute timestamp window. A public purge endpoint is an easy way for a bored competitor to turn your edge cache into decoration during a launch.

Watch The Headers Like You Watch CAC

A cache strategy is not finished until someone can prove it in headers. I add x-cache-route, x-cache-policy, and server-timing during rollout, then remove or narrow them once the team understands the behavior. Cloudflare’s cf-cache-status header is useful too. You want to see HIT, MISS, BYPASS, REVALIDATED, and EXPIRED in the places you expect.

For one Astro storefront launched on Cloudflare Workers in June 2026, my launch dashboard had four numbers: p75 TTFB from Cloudflare Web Analytics, origin requests per minute from Workers Analytics Engine, conversion rate from GA4, and add-to-cart rate from Shopify. A faster page that lowers add-to-cart rate is not a win. That usually means stale pricing, a hydration bug, or a personalization script racing the buy button.

Cloudflare Workers limits are generous enough for this architecture, but they are not imaginary. As of July 28, 2026, Cloudflare lists 100,000 requests per day on Workers Free, no request limit on Workers Paid, 10 ms CPU on Free, and 128 MB memory. KV has a 25 MiB value size limit and a 1-write-per-second limit to the same key. Those numbers should shape how you store cache metadata and rate-limit purge jobs.

The Strategy I Would Ship First

For a founder-operated storefront doing $50,000 to $500,000 a month online, I would ship this first version in one sprint. Cache /_astro/* for one year with immutable headers. Cache homepage and evergreen landing pages for 30 minutes at the edge. Cache collection pages for 5 minutes with 10 minutes of stale-while-revalidate. Cache PDP HTML for 60 seconds, unless inventory requires a separate live endpoint. Do not cache cart, checkout, account, search suggestions with user context, or any response with Set-Cookie.

Then I would normalize cache keys so UTM and click IDs do not explode the cache, wire CMS and commerce webhooks to purge by tag, and put a Cloudflare Cache Rule above the Worker only where the rule is simpler than code. Cloudflare’s minimum Edge Cache TTL depends on plan, with Free starting at 2 hours and Business allowing 1 second, so check your plan before you promise a 60-second rule in the dashboard. Workers give you finer control when dashboard TTLs are too blunt.

The point is not to cache everything. The point is to keep the expensive, public, repeatable parts of your storefront close to the buyer while the fragile parts stay fresh. Astro gives you clean route boundaries. Cloudflare gives you the edge. Your job is to make sure paid traffic, SEO crawlers, shoppers, and merchandisers are not all fighting over the same HTML response.

Sources worth keeping open while you implement this: Astro Cloudflare adapter docs, Astro route caching docs, Cloudflare default cache behavior, Cloudflare CDN cache-control docs, and Cloudflare Workers limits.