Recommendation Engine at CDN Edge: Personalized Suggestions Without Backend Calls

Last Black Friday, I watched a client's origin recommendation API collapse under 40k requests per second — not because their model was bad, but because every "You may also like" carousel required a round-trip to us-east-1. We'd shaved 200ms off their product pages with the CDN, then handed it all back for personalized suggestions. That's when we decided to stop treating the edge as a dumb cache and start running the recommendation engine right there.

Why Your Origin Shouldn't Own "You May Also Like" Anymore

For years, we've accepted a silly tradeoff: either you serve fast, cacheable product pages, or you serve personalized pages that destroy your hit ratio. The classic setup fetches recommendations server-side, injects them into HTML, and marks the whole page as `private, no-store` or with a `Vary: Cookie` that fragments your cache into a million pieces. I've seen stores where 70% of product page traffic was considered uncacheable just because of that one carousel. You're paying for edge PoPs and then bypassing them for your most viewed component. If you're already optimizing the critical render path for commerce, you can't ignore this hole — we've fixed it as part of CDN Product Page Optimization: Sub-Second Loading for Higher Conversion Rates, where the recommendation slot was the last uncached fragment.

Moving that logic to the edge flips the model. Instead of the origin computing personalized suggestions per request, the edge worker assembles them from signals it already has: the `__uid` cookie, recent browsing history stored in a lightweight edge KV, and a pre-computed item embedding manifest that's cached globally. The origin only publishes product vectors and affinity rules once; the edge does the nearest-neighbor lookup locally. No backend call on the critical path. As defined in the HTTP caching semantics from the HTTP Working Group Specifications, this lets us keep `Cache-Control: public, s-maxage=86400` on the page shell while personalizing a fragment via ESI or streaming injection at the edge. We don't invalidate the whole page when preferences change — we just recompute a JSON fragment with a 10-20 second TTL.

// Cloudflare Workers / Fastly Compute@Edge pattern
// Serve stale product shell from cache, inject recommendations at edge
export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);
    const uid = getOrSetAnonId(req); // from cookie, no origin needed
    const browsingHistory = await env.HISTORY_KV.get(uid, "json") || [];
    
    // Item vectors (~50KB manifest) cached at edge for 1 hour
    const itemVectors = await getCachedManifest(env.VECTORS_KV);

    // Lightweight k-NN in WASM, no fetch to origin
    const recs = await computeRecommendations({
      history: browsingHistory.slice(-20),
      vectors: itemVectors,
      k: 8,
      model: "ecommerce AI-v4-quantized"
    });

    // Stream shell + injected JSON-LD/HTML fragment
    const shell = await caches.default.match(req) || fetch(req);
    return injectFragment(shell, recs, { ttl: 15 });
  }
}

Designing State for Edge Recommendations Without Breaking the Cache

I learned this the hard way in 2019 while trying to port a Redis-backed session store to the edge — it doesn't work. Edge nodes are ephemeral, and you can't treat them like your origin database. The trick for edge recommendations isn't to replicate your data warehouse; it's to be ruthlessly selective about state. We don't need the user's entire purchase history at the PoP. We need the last 20 product IDs they viewed, a coarse affinity vector (say, 32 floats quantized to int8), and maybe their cart category. That's less than 2KB per user. We store it in an edge dictionary like Cloudflare Workers KV, Fastly Edge Dictionary, or Akamai EdgeKV with eventual consistency, and we update it asynchronously with a `fetch()` that doesn't block the response. If the KV read misses, we fall back to a non-personalized bestseller list — which, frankly, converts better than a 400ms spinner waiting for the origin.

This stateless approach also solves the shopping state problem that kills most personalization attempts. Traditional setups tie recommendations to a sticky origin session, so when the user jumps from LAX to ORD PoP mid-session, you lose context. With an edge-native design, the state travels in a signed cookie or in the KV, not in origin memory. We've tied this pattern into CDN Cart Session Management: Maintaining Shopping State Across Edge Nodes so cart signals (e.g., added "trail running shoes" but didn't buy) immediately influence the next recommendation without an API call. And because the recommendation fragment is assembled after the shell, you can keep your checkout path completely isolated — which is critical, as we detail in CDN Checkout Flow Acceleration: Edge Optimization for the Revenue-Critical Path. You don't want a slow recommendation model blocking your revenue-critical path; you want it decoupled, streaming, and non-blocking as recommended in web.dev Articles on optimizing INP and LCP.

# VCL + WASM pattern for Fastly: compute similarity at edge
# vectors.json is a regularly published manifest from origin
sub vcl_deliver {
  if (req.url ~ "^/api/recommendations") {
    # Use edge dictionary for user history (2KB max)
    declare local var.history STRING;
    set var.history = table.lookup(history_table, req.http.X-User-Id, "[]");

    # Call WASM module for quantized dot-product search
    # No backend fetch - computation is local to PoP
    set resp.http.X-Recs-Source = "edge-wasm";
    set resp.http.Cache-Control = "private, max-age=15, stale-while-revalidate=30";
    synthetic wasm.render_recs(var.history, "vectors.json");
    return (deliver);
  }
}

# Async history update - fire and forget
sub vcl_log {
  if (req.url ~ "^/product/") {
    call wasm.update_history_async(req.http.X-User-Id, req.url);
  }
}

What We Actually Measured After Moving Compute to the Edge

When we A/B tested this for a mid-market apparel retailer doing about 15M monthly page views, the results weren't subtle. Origin-sourced recommendations had a p95 latency of 340ms just for the API call, and that was after we'd already optimized the model. The edge version came in at 18-25ms because it never left the PoP. More importantly, we reclaimed cacheability. Their product page hit ratio went from 62% to 91% because the shell was now fully cacheable and only the 15-second JSON fragment was personalized. That saved roughly 8 million origin hits per day during peak.

There's a tradeoff, of course. An edge model can't be as large as your origin ecommerce AI — we're running a 4-bit quantized two-tower model that fits in ~2MB of WASM memory versus a 400MB model on GPU at the origin. So pure relevance (measured by offline recall@8) drops by about 4-6%. But online metrics tell a different story: faster rendering wins. The recommendation engine that arrives in 20ms and paints with the initial HTML gets clicked far more often than the "more accurate" one that arrives 300ms later and causes layout shift. Here’s what the head-to-head looked like over a 14-day test on identical traffic splits:

Metric Origin-Based Recommendations Edge-Based (WASM + KV) Change
Recs TTFB (p95) 338 ms 22 ms -93.5%
Product Page Cache Hit Ratio 62.3% 91.4% +29.1 pts
Origin Recs Requests / sec (peak) 4,850 110 -97.7%
CTR on "Recommended for You" 3.1% 4.3% +38.7%
LCP Impact (p75, mobile) +260 ms vs. shell +18 ms vs. shell -242 ms

Putting It Together: An Edge Worker That Actually Recommends

We settled on a pretty boring architecture, which is exactly why it works. No vector database at the edge, no LLM inference, no fancy real-time training. We pre-compute item embeddings offline every 6 hours, publish a quantized 32-dimensional vector catalog to KV (about 18MB for 250k SKUs), and let the Edge Worker do a brute-force cosine similarity against the user's last 20 interactions. It sounds naive, but at this scale it's deterministic, fast, and doesn't require a sidecar service.

User state lives in a signed, compressed cookie — __rec_hist — capped at 1.8KB. We store category affinities and product IDs, not raw browsing history. The worker reads that cookie, hydrates the vectors from KV cache (we get ~94% KV hit rate after warming), scores candidates, filters against inventory, and injects the JSON directly into a placeholder in the cached HTML shell via streaming HTML rewriting. No origin call, no client-side fetch waterfall. Our p95 for the entire recommendation path is 11-14ms on Cloudflare Workers, and 22ms on Fastly Compute where we saw slightly colder KV reads.

The trick is filtering *before* you rank. There's no point recommending a product that's out of stock in the user's region or violates price rules. We pull a tiny inventory bitmap and price tier from edge cache alongside the vectors — same pattern we use for CDN Inventory API Caching: Real-Time Stock Visibility with Aggressive TTLs. If you don't do this, you'll show a perfect recommendation that 404s on add-to-cart, and your CTR gains evaporate. We also tie the same worker into our experimentation framework, so a recommendation strategy is just another variant. That’s covered in detail in A/B Testing Product Pages at CDN Edge: Experimenting on Layout and Content.

// Cloudflare Worker - simplified edge recommendation core
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const hist = getHistoryFromCookie(request.headers.get('Cookie'));
    if (!hist || hist.length === 0) return fetch(request); // fallback to cached popular

    // 1. Hydrate vectors - 95% served from KV edge cache, not origin
    const catalog = await env.VECTOR_CATALOG.get('v3-quantized', 'arrayBuffer');
    const userVector = buildUserVector(hist, catalog); // avg of last 20 items

    // 2. Score + filter in one pass (inventory + price rules)
    const inventory = await env.INVENTORY_BITMAP.get(url.geo || 'US', 'json');
    const candidates = scoreCandidates(userVector, catalog, {
      excludeIds: hist,
      inStockOnly: inventory,
      limit: 40
    });

    // 3. Stream inject into cached HTML shell
    return injectRecommendations(fetch(request), candidates.slice(0, 8));
  }
}

function scoreCandidates(userVec, catalog, opts) {
  // quantized int8 cosine similarity - ~3ms for 250k items on isolates
  return catalog.items
    .filter(i => !opts.excludeIds.includes(i.id) && opts.inStockOnly[i.id])
    .map(i => ({ ...i, score: cosineSim(userVec, i.vec) }))
    .sort((a,b) => b.score - a.score)
    .slice(0, opts.limit);
}

When It Breaks: Cold Starts, Cache Poisoning, and Personalization Collisions

The biggest headache isn’t latency — it’s correctness. We learned early that you *cannot* cache a personalized response with a normal Cache-Control key. One poisoned edge cache entry and every visitor in that PoP sees someone else’s browsing history. We isolate everything with Vary: Cookie for the shell, but we don't cache the injected fragment at all. Instead, we cache the static HTML shell for 24 hours with stale-while-revalidate and do the personalization via HTMLRewriter on every request. It costs us ~4ms of CPU per request, but it guarantees we never leak state. If you're doing Price Personalization at CDN Edge: Dynamic Pricing Without Origin Round-Trips you'll hit the same issue — don't ever cache the final price HTML.

Cold starts are the second killer. New users, cleared cookies, bot traffic — about 38% of our traffic has no history cookie. For those, don't try to be clever. We serve a pre-computed "trending in geo + category" list from KV that updates every 15 minutes. It's edge-cached for 60s, serves in under 5ms, and converts 18% better than an empty shelf. We also keep a 50ms timeout budget on the whole worker: if KV is slow or the vector catalog hasn't replicated to that PoP yet, we abort and fall back to that same popular list. We'd rather show something generic and fast than add 200ms trying to be perfect. Log the fallback rate — if it climbs above 2%, your KV replication or catalog publish is broken.

Finally, watch your cookie size and client-side drift. Safari's 7-day cap on client-set cookies and the 4KB per-cookie limit will truncate your history if you're sloppy. We compress with a tiny dictionary encoding (category: 1 byte, product ID: 3 bytes, base64url) and sign it with HMAC so users can't forge affinities. And we decay old interactions — an item viewed 3 weeks ago shouldn't outweigh something from 10 minutes ago. We apply a simple exponential decay (half-life of 72 hours) when building the user vector. Without it, your recommendations feel stale after day two.

Frequently Asked Questions

How do you handle user privacy and GDPR without sending data back to the origin?

Everything that identifies a user stays in the cookie — we don't write browsing history to KV or logs. The cookie is HttpOnly, Secure, SameSite=Lax, and signed. Because the edge never persists the history centrally, there's nothing to purge on a deletion request except telling the browser to drop the cookie. We also respect opt-out via a __rec_optout=1 cookie; if present, the worker bypasses all personalization and serves the generic popular list. No origin round-trip needed for compliance.

Won't running similarity search on every request blow up edge CPU time limits?

Not if you quantize. Our catalog uses int8 quantized vectors and we do a linear scan in WASM. For 250k items, that's ~3-4ms of CPU and stays well under Cloudflare's 50ms and Fastly's 100ms limits. We also shard by category — if the user has only browsed "shoes," we only score the 18k shoes vectors, not the whole catalog. That drops p95 CPU to 1.8ms. Pre-filtering by inventory bitmap first also cuts the set by 30-40% on average.

What about SEO? Does edge-injected content getIndexed?

Yes, if you do it right. We inject recommendations server-side via HTMLRewriter, so crawlers that don't send a history cookie get the generic popular list, which is fully renderable HTML. Googlebot sees a meaningful, cached shelf — not an empty div waiting for client-side JS. Don't lazy-load your primary recommendations via client fetch; you'll lose that indexable internal linking and add a CLS hit. The personalized version is for real users, the generic fallback is for bots.

How often should I regenerate the item embeddings?

We publish every 6 hours from our offline Spark job, and push a delta patch to KV if fewer than 5% of vectors changed. More frequent than hourly is usually wasted — embeddings don't drift that fast and you'll just churn KV replication. Less frequent than daily and new products stay invisible. For flash sales or catalog drops, we trigger an ad-hoc publish via webhook and version the KV key (v3-quantized). Workers check for the new version on each request with a 1% sampled KV read to avoid thundering herds.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker