CDN for Spatial Computing: Delivering AR/VR Assets with Sub-Frame Latency

Last winter I watched a user test an AR maintenance app we'd spent months tuning. The headset was on a 5G edge PoP 8ms away, cache hit ratio was 98.7%, and it still felt terrible. Every time she turned her head fast, the high-res valve model popped in a quarter-second late — just enough to break presence and make her queasy. That's when it clicked for me: for spatial computing, our standard CDN scorecards are lying to us.

The Sub-Frame Budget: Where Milliseconds Become Motion Sickness

Traditional web delivery thinks in seconds. Even our “low latency” targets for video are 1-2 seconds. Spatial computing doesn't care. A Quest 3 or Vision Pro renders at 90Hz to 120Hz. That gives you 11.1ms down to 8.3ms per frame. Your entire motion-to-photon budget — tracking, rendering, display — has to fit inside that. If network delivery for a newly visible AR asset takes more than one frame, the user sees a hole, a low-res placeholder, or worse, a stutter. I've seen teams celebrate a 35ms p95 for API calls while their users were getting sick.

We had to throw out TTL as our primary tool and design around deadlines instead. An AR asset isn't just “fresh” or “stale” — it's “needed in 11ms or it's useless.” That means we can't afford a round-trip to origin on a cache miss, and we can't even afford a standard conditional revalidation. The spatial CDN has to know what's in the user's field of view *next* frame, not just what was requested last frame. This is why techniques from the HTTP Working Group Specifications around prioritization and server push replacement matter so much more here than on a normal website.

For us, the fix was moving from file-level caching to component-level caching with absolute urgency hints. We stopped treating a 40MB glTF as a single object and started treating its JSON manifest, buffers, and textures as separate cache keys with different priorities. The WebXR Content Delivery Through CDN: Streaming Immersive Experiences at Low Latency approach of progressive enhancement is the only thing that works when your deadline is sub-frame. You send the 3KB mesh first, then the textures you can actually see.

# Edge logic for spatial asset prioritization - VCL / HTTP/3
# We tag requests with urgency based on viewport prediction

sub vcl_recv {
  if (req.http.Sec-Purpose == "spatial-prefetch") {
    set req.http.Priority = "u=0, i"; # Immediate, incremental
  } else if (req.url ~ "\.bin$" || req.url ~ "\.ktx2$") {
    # Geometry and compressed textures get highest urgency
    set req.http.Priority = "u=1, i";
  } else if (req.url ~ "\.jpg$" || req.url ~ "\.png$") {
    set req.http.Priority = "u=4, i"; # Fallback textures can wait
  }
}

sub vcl_deliver {
  # HTTP/3 + Early Hints for the next predicted tile
  if (req.http.Predicted-Tile) {
    set resp.http.Link = "<" + req.http.Predicted-Tile + ">; rel=preload; as=fetch; priority=high";
    set resp.http.103-Priority = "u=0";
  }
  # Never revalidate blocking on view-critical assets
  set resp.http.Cache-Control = "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400";
}

Tiling Space Itself: Predictive Prefetch That Doesn't Flood the Network

We tried the naive thing first: prefetch everything around the user. Don't. On a factory floor digital twin with 12,000 parts, we saturated a 100Mbps link in seconds and still missed the assets the user actually looked at. The breakthrough wasn't more bandwidth, it was understanding space the way a game engine does. We started using 3D Tiles and octree partitioning at the edge, so each spatial region becomes a cacheable tile with a deterministic URL like `/tiles/{z}/{x}/{y}/{lod}.glb`. If you've worked with map tiles, the mental model is identical, just with an extra dimension.

The edge worker's job is no longer just “serve from cache.” It runs a lightweight frustum prediction. We take the last 6DoF pose (position + quaternion) sent via a WebSocket or WebTransport datagram, extrapolate 50-80ms into the future with a simple Kalman filter, and expand the view frustum by 15%. Then we check which tiles intersect that predicted frustum and push hints for them. I've found a 60ms look-ahead is the sweet spot — long enough to hide the network, short enough that you aren't wrong more than 20% of the time when the user jerks their head. The web.dev Articles on speculative loading principles apply directly, but the signal is head velocity, not cursor hover.

This pairs incredibly well with edge compute. We don't want to render the full scene on the CDN — that's what WebGPU Edge Rendering: Offloading 3D Computation to CDN Infrastructure covers — but we do want to do frustum culling and occlusion checks at the PoP closest to the user. If the edge knows the user's pose and the scene's bounding volume hierarchy, it can avoid sending assets for objects behind a wall without ever hitting origin. That single check cut our egress by 42% in one warehouse pilot.

// Frustum-aware prefetch worker (Cloudflare / Fastly Compute@Edge)
export default {
  async fetch(req, env) {
    const pose = await req.json(); // { pos: [x,y,z], quat: [x,y,z,w], vel: [vx,vy,vz] }
    const predictedPos = extrapolate(pose.pos, pose.vel, 0.06); // 60ms ahead
    const frustum = buildFrustum(predictedPos, pose.quat, 85, 1.2); // fov, margin

    const neededTiles = queryOctree(frustum, pose.lod); // returns ["tiles/8/45/92/2.glb", ...]
    const cache = caches.default;

    // Check cache and send 103 Early Hints for misses
    neededTiles.forEach(tile => {
      cache.match(tile).then(hit => {
        if (!hit) {
          // Hint the client before it even asks
          sendEarlyHint(tile, { priority: "high" });
          // Warm the PoP from neighbor PoP or R2/S3
          fetch(tile, { cf: { cacheEverything: true }});
        }
      });
    });

    // Also prune tiles that left the frustum from client's cache hints
    return new Response(JSON.stringify({ prefetch: neededTiles }), {
      headers: { "Content-Type": "application/json" }
    });
  }
}

Stop Shipping Monoliths: Progressive Asset Design for Incremental Delivery

If your VR delivery still ships a single 80MB GLB for a room, you're already over budget. No network recovers from that in 11ms. We learned this the hard way on a retail VR experience where the client insisted on “pixel-perfect” models. The load spinner was flawless, the experience was unusable. We had to rebuild the pipeline around progressive LODs and independently decodable textures. The idea is simple: send the smallest thing that lets you render *something* in the next frame, then refine it over the next 10-20 frames.

In practice that means three things: 1) Draco-compressed meshes with multiple levels of detail stored as separate buffers, 2) Basis Universal KTX2 textures with mip streams so a 4K texture can start as 128x128, and 3) a tiny JSON manifest that describes the dependencies. The CDN caches each piece with a different policy. Meshes and low mips are immutable and cached for a year at the edge. High mips are cached with a short stale-while-revalidate because they change more often and you can survive without them for a frame. When you need real-time sync for moving objects, that manifest approach is also how Digital Twin Data Distribution Through CDN: Real-Time Synchronization of Physical Models keeps physical and virtual states aligned without re-sending the entire model.

What's not obvious until you've measured it is how much decode time matters. We obsessed over download size for months and ignored that a 2MB JPEG can take 14ms to decode on a mobile XR2 chip — that's already a missed frame. Switching to KTX2 with Basis dropped decode to ~3ms on the same hardware and let the GPU transcode directly without touching the CPU.

Asset Strategy Initial Payload (First Frame) Decode Time (XR2 Gen 2) Full Quality Time (p95, 50Mbps) Cache Hit Rate at Edge
Monolithic GLB (80MB, 4K textures) 80 MB 28-34 ms 1,850 ms 62%
GLB + Draco + JPEG (LOD0 only) 4.2 MB 14-18 ms 420 ms 81%
Progressive: Manifest + Draco LOD0 + KTX2 mip 0-3 285 KB 3.1 ms 45 ms (mip 0-3) / 210 ms (full) 94% (manifest) / 89% (mips)
3D Tiles (Octree, 1m³ tiles, KTX2) 180-350 KB per visible tile 2.4 ms per tile 38 ms per tile 96%

From Theory to Edge: Building Your Sub-Frame Pipeline

Alright, you've got your progressive assets and your tiled world — now you've got to actually wire it up at the edge without blowing your latency budget. In production, we've found you can't treat spatial assets like traditional video segments. You're not delivering to a single screen anymore, you're delivering to a predicted frustum. Our reference architecture pushes viewport prediction and tile selection directly to the edge worker, not the origin. That shaves a full round trip. For a 90Hz headset, your total motion-to-photon budget is about 11ms per frame. If your network fetch takes more than 6-7ms, you're already motion sick. So we target p95 edge RTT under 4.5ms and we do it by pinning the hot working set — the user's immediate 9 tiles plus the next predicted ring — in edge memory, not just SSD cache.

We use QUIC and WebTransport for this, not plain H2. With TCP + TLS 1.3, you're paying 2-3 RTTs just to resume. QUIC gets you 0-RTT resumption and, more importantly, independent streams per tile. If one high-LOD mesh stalls, it doesn't block your base geometry. Your edge manifest should be a living JSON that updates every 20-30ms, not a static HLS-style playlist. The client POSTs its 6DoF pose, velocity vector, and angular velocity 60 times a second, and the edge responds with a prioritized fetch list: `required`, `prefetch`, and `defer`. We've seen this cut redundant bytes by 62% compared to naive frustum culling at the origin. It's the same prioritization challenge you see in IoT Firmware Update Distribution via CDN: Secure OTA Updates for Device Fleets, just at 1000x the frequency.

The trick is to never wait for origin on a cache miss for base geometry. Your fallback should be instant. Here’s the pattern we run at the edge — a viewport-aware worker that serves stale low-poly immediately while revalidating the high-LOD tile in the background:

// Edge Worker - Viewport-Aware Tile Router (QUIC / WebTransport)
export default {
  async fetch(req, env, ctx) {
    const pose = await req.json(); // { position, velocity, fov, predictionMs: 40 }
    const predictedView = predictFrustum(pose, pose.predictionMs);
    const tiles = getVisibleTileIds(predictedView, { lod: 'adaptive' });

    // Serve base tiles from edge memory (sub-ms) with stale-while-revalidate
    const responses = await Promise.all(tiles.map(async (tileId) => {
      const cacheKey = `tile:${tileId}:lod:${tileId.lod}`;
      let res = await env.TILE_CACHE.match(cacheKey, { 
        allowStale: true, 
        ttl: 5 // seconds, hot set
      });
      
      if (!res) {
        // Critical path: serve degraded fallback in <1ms, don't block on origin
        res = await env.FALLBACK_BUCKET.get(`${tileId.baseLod}.glb`);
        ctx.waitUntil(fetchAndCacheOrigin(tileId, cacheKey, env));
      } else if (res.headers.get('x-cache-status') === 'stale') {
        ctx.waitUntil(fetchAndCacheOrigin(tileId, cacheKey, env));
      }
      return res;
    }));

    // Multiplex over WebTransport streams - priority 0 = must-have
    return new Response(mergeStreams(responses), {
      headers: { 'content-type': 'model/gltf-binary', 'x-edge-predict-ms': '40' }
    });
  }
}

Advanced Patterns: When Prediction Is Wrong (And It Will Be)

You’ll never hit 100% prediction accuracy, so don’t design for it. Even our best Kalman + gaze model tops out around 88-92% for a 40ms lookahead. The other 10%? That’s a user whipping their head 120 degrees to check a door. If your CDN treats that as a cache miss storm, you’ll spike origin and drop frames for everyone in that PoP. We solve this with graceful degradation layers baked into your progressive asset design. Always have LOD0 — that 8-12KB ultra-low-poly + baked texture — resident in the client’s local cache and at every edge. When prediction fails, you render LOD0 in under 2ms while the edge backfills LOD1/LOD2 over the next 2-3 frames. The user perceives a soft pop, not a black hole. We set a hard rule: no tile fetch should block rendering for more than one frame (11ms at 90fps).

For troubleshooting, watch your edge cache hit ratio *per LOD tier*, not globally. A 95% overall hit ratio is meaningless if your LOD2 hits are 60%. That tells you your prefetch ring is too small or your eviction policy is too aggressive. We use a 2-tier TTL: 5 seconds for the inner 9 tiles, 45-60 seconds for the outer prediction ring, and we pin base geometry for 5 minutes. And please, enable tiered cache — origin shielding is non-negotiable here. A single popular VR plaza can generate 40k RPS of tile requests on launch. Without shielding, you’ll melt your origin S3 bucket in seconds. This fleet-scale coordination is similar to what’s needed for Connected Vehicle Update Delivery: CDN Infrastructure for Automotive OTA where thousands of endpoints request the same chunk simultaneously.

Finally, don’t ignore your congestion control. With BBR on QUIC, we’ve shaved 18-22ms off tail latency versus Cubic on lossy Wi-Fi 6E — which is exactly what most Quest and Vision Pro users are on. Monitor your `p95 time-to-first-byte for LOD0` as your golden signal. If that creeps over 7ms, your users are feeling it before your dashboards turn red. If you're building this as part of a larger persistent world that spans devices and physical spaces, the observability and edge orchestration patterns overlap heavily with CDN for Smart City Infrastructure: Content Delivery for Urban IoT Networks — you're just operating at a 50x tighter latency envelope.

Frequently Asked Questions

What's a realistic latency target for AR/VR assets via CDN?

Think sub-frame. For 90Hz, you've got 11.1ms per frame and a total motion-to-photon budget of ~20ms. Your CDN fetch for critical tiles needs to be under 5-7ms p95 from the edge, with 0-RTT resumption. Anything over 12ms and you'll need aggressive client-side prediction and LOD0 fallbacks to hide it. We instrument heavily and alert if LOD0 TTFB exceeds 7ms for more than 2% of requests.

Should I use WebTransport, WebRTC, or plain HTTP/3 for spatial delivery?

Use WebTransport over HTTP/3 for most asset delivery. You get QUIC's 0-RTT, stream multiplexing without head-of-line blocking, and datagram support for pose updates. WebRTC is great for peer-to-peer but adds complexity you don't need for CDN-to-client. Plain H3 is fine for progressive manifest fetches, but don't multiplex high-priority geometry and low-priority textures on the same H2 connection — you'll stall.

How large should my spatial tiles be?

Smaller than you think. We found 2m x 2m x 2m volumetric tiles with 3 LOD levels hit the sweet spot. LOD0 is ~10KB, LOD1 ~75KB, LOD2 ~250KB. Larger tiles reduce request count but waste bandwidth when only a corner is visible — you'll flood the client. With 2m tiles, a typical view fetches 12-18 tiles, which parallelizes beautifully over QUIC streams.

How do I handle cache invalidation for persistent spatial worlds?

Don't purge, version. Immutable tile URLs like `/tiles/v47/1_4_2_lod1.glb` with `Cache-Control: public, max-age=31536000, immutable`. When the world updates, you publish a new manifest version and the edge atomically switches. For dynamic objects, serve them as separate delta layers with 1-2 second TTLs, not baked into the base tile. That keeps your hit ratio above 94% even in live, collaborative spaces.

Related Articles

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