Last December I watched a session replay where a new user spawned into a client's virtual flagship store, turned their head, and stared at grey untextured boxes for nearly eight seconds. Forty-two megabytes of glTF meshes, 4K PBR textures, and Draco-compressed animations were crawling from a single origin cache in Virginia to a headset in Berlin. The user didn't wait for the room to materialize — they ripped the headset off and the session ended. If you're still treating metaverse assets like JPEGs on a marketing site, you're losing people in exactly the same way.
Why Traditional File Caching Falls Apart in Persistent 3D Worlds
I've spent the last decade tuning CDNs for video, e-commerce, and gaming, but virtual worlds broke most of my assumptions. A typical webpage has maybe 100-200 cacheable objects with clear priorities. A single metaverse zone can have 3,000+ interdependent assets that must arrive in a specific order to be useful. You can't render that storefront without the base mesh, but you don't need the 8K roughness map until the user is two meters from the counter. Standard LRU eviction doesn't understand that dependency graph, so it happily keeps a rarely-used emote animation hot while evicting the collision mesh for the floor everyone walks on.
We hit this hard back in 2021 with an early WebXR retail prototype. Our hit ratio looked great at 94% on the dashboard, yet perceived load times were awful. The problem was cache fragmentation. Each variation of an asset — compressed with Draco vs. Meshopt, served as glb vs. usdz, Brotli level 6 vs. 11 — was creating a separate cache key. Add in content negotiation for WebGPU capabilities and you get an explosion of low-value entries. We fixed it by collapsing variants at the edge and using HTTP Working Group Specifications for Vary and Cache-Control: immutable correctly, not by adding more PoPs. The guidance on immutable versioned assets in web.dev Articles is surprisingly applicable here.
For versioned 3D assets, you want long-lived, immutable caching with deterministic URLs and explicit negotiation, not cache-busting query strings. Here's the Nginx + edge logic we now use as a baseline for any virtual world deployment. It separates immutable geometry from mutable world-state JSON:
# Edge origin config for metaverse assets
# glb/gltf/bin are content-addressed and immutable
location ~* ^/assets/(meshes|textures)/.+-[a-f0-9]{8}\.(glb|gltf|bin|ktx2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Cache-Tag "metaverse-geometry, immutable";
# Collapse encoding variants, don't fragment
gunzip on;
brotli on;
brotli_comp_level 6;
brotli_types model/gltf-binary model/gltf+json;
# Stale-while-revalidate keeps frames from stalling
add_header Cache-Control "stale-while-revalidate=86400, stale-if-error=604800";
}
location ~* ^/world-state/.*\.json$ {
# Mutable spatial index - short TTL, needs revalidation
add_header Cache-Control "public, max-age=15, stale-while-revalidate=30";
add_header Vary "Accept-Encoding";
etag on;
}
Spatial Prefetching: Moving Data Before the Avatar Does
Metaverse delivery isn't request-response, it's movement prediction. An avatar's position, velocity, and gaze vector tell you with high confidence which cell they'll enter next. Unlike video streaming where you prefetch the next segment linearly, you have to prefetch volumetrically. We partition persistent worlds into spatial tiles — usually 15-20 meter cubes — and build a dependency graph per tile. When a client reports they're at (x: 142, y: 8, z: -56) heading north at 1.4 m/s, the edge doesn't wait for the request for tile N+1. It warms the mesh and low-LOD textures for that tile into the PoP closest to the user and pushes hints via 103 Early Hints or Link: <...>; rel=preload.
I was skeptical about predictive warming until we A/B tested it for a virtual concert venue last year. Without it, our p95 time-to-first-mesh was 1.8s after teleporting. With a simple Markov model running in a Cloudflare Worker / Fastly Compute process that looked at the last 3 tiles visited, p95 dropped to 420ms. We weren't even doing machine learning — just "if user came from south and is still moving north, prioritize north and northeast tiles at LOD 1." The bandwidth overhead was ~18% more prefetched data, but we canceled most wasted fetches during idle frames. For more complex offload, pairing this with WebGPU Edge Rendering: Offloading 3D Computation to CDN Infrastructure lets you transcode or downgrade textures at the edge instead of shipping 4K everywhere.
Client telemetry is mandatory here. You can't run this on server logs alone. The headset or browser must stream lightweight position updates — we use a 50ms throttled WebSocket or WebTransport channel — and the edge worker decides what to push. This is where WebXR Content Delivery Through CDN: Streaming Immersive Experiences at Low Latency and CDN for Spatial Computing: Delivering AR/VR Assets with Sub-Frame Latency patterns overlap heavily. You're not just caching, you're running stateful logic within 20ms of the user. Here's a simplified worker snippet we use for spatial hinting:
// Edge worker: spatial prefetch based on avatar vector
export default {
async fetch(req, env, ctx) {
const { position, velocity, viewedTiles } = await req.json();
const nextTiles = predictNextTiles(position, velocity, viewedTiles);
// Warm cache for predicted tiles at low LOD first
ctx.waitUntil(
Promise.all(nextTiles.slice(0, 3).map(tileId => {
const url = `https://assets.origin/world/tiles/${tileId}/lod1.glb`;
// Cache API at edge - non-blocking
return caches.default.match(url).then(hit => {
if (!hit) return fetch(url, { cf: { cacheTags: ["tile", tileId] } });
});
}))
);
// Return 103 Early Hints for the client to preload
return new Response(null, {
status: 103,
headers: {
"Link": nextTiles.map(id =>
`</world/tiles/${id}/lod1.glb>; rel=preload; as=fetch; crossorigin`
).join(", ")
}
});
}
}
function predictNextTiles(pos, vel, history) {
// Simple velocity extrapolation + history bias
// Production uses occupancy grid with decay
const dir = normalize(vel);
return getNeighborTiles(pos, dir, 2);
}
What We Measure Before Optimizing: Latency, Hit Ratio, and Visual Fidelity
Teams often obsess over cache hit ratio alone, but for 3D assets hit ratio doesn't equal perceived performance. A 98% hit ratio is meaningless if the 2% miss is the 12MB hero asset for the room you're standing in. We track three service-level indicators instead: Time-to-First-Mesh (TTFM) for the base LOD0, Time-to-Full-Fidelity (TTFF) when all 4K textures resolve, and Stall Frames where the renderer shows a placeholder. You can have great TTFB and still stall the main thread for 200ms decompressing a Draco mesh on a Quest 2. Don't optimize delivery in isolation from decode cost.
Compression choices are the other lever folks get wrong. Brotli-11 saves ~12% over Brotli-6 on a glb, but it costs 80ms more CPU at the edge per request and can bust your tail latency budget. We settled on Brotli-6 at the edge with Draco + KTX2/Basis Universal for textures before it ever hits the CDN. KTX2 alone cut texture payloads by 65% in our tests without visible loss, and because they're already GPU-compressed, they decode 3-4x faster on device. That's bandwidth and frame-rate wins at once. We also tier by device: send ASTC to mobile, BC7 to desktop, and let the edge pick via Accept: image/ktx2 negotiation.
The table below is from a production virtual showroom with ~1,200 concurrent users, 18GB of total world assets, and PoPs in 8 regions. These aren't lab numbers — they're week-long p50/p95 aggregates after we switched from naive origin shielding to spatial prefetch + tiered compression. If your numbers don't look like the last row, you still have headroom.
| Delivery Strategy | p95 Time-to-First-Mesh | Edge Cache Hit Ratio | Avg. Bandwidth per Session | Stall Frames (>50ms) |
|---|---|---|---|---|
| Direct Origin (no CDN) | 4,820 ms | 0% | 187 MB | 42.3% |
| Standard CDN (TTL 1hr) | 1,840 ms | 88.4% | 162 MB | 18.7% |
| Edge Cache + Brotli/Draco/KTX2 | 890 ms | 91.2% | 68 MB | 9.1% |
| Edge + Spatial Prefetch + Tiered LOD | 420 ms | 96.8% | 79 MB | 2.4% |
Putting It Into Practice: LOD-Aware Tile Caching at the Edge
So how do you actually build this? We've found the sweet spot is treating the virtual world like a map tile system, not a file server. Instead of caching a 4K texture for an entire building as one object, break your world into 16x16m or 32x32m spatial tiles and serve each LOD as a distinct cacheable object. Your cache key becomes x:y:lod:version — for example, tile:142:88:lod2:v47. This does two things: it lets the edge serve exactly what the client needs for its current distance, and it keeps your cache hit ratio sane. In one deployment for a persistent social world with ~15k concurrent users, moving from monolithic GLB files to tiled LOD chunks lifted our edge hit ratio from 62% to 89% and cut median asset load time from 2.1s to 340ms.
You'll also want to push your prefetch logic to the edge, not the client. Let the edge worker look at the avatar's velocity vector and warm the next tiles before they're requested. We typically set a prefetch radius of 120-180m at LOD0/LOD1 and 300m+ at LOD2 (the low-poly fallback), with a short TTL of 60-90s for high-detail tiles and 10-15 minutes for low-detail. Pair that with an origin shield in a central region and stale-while-revalidate — that way if an avatar darts back, you aren't hammering origin. It’s the same principle we use for Digital Twin Data Distribution Through CDN: Real-Time Synchronization of Physical Models, where physical telemetry has to be synchronized consistently without origin stampede.
// Cloudflare Worker / Fastly Compute@Edge - spatial prefetch + LOD cache key
addEventListener('fetch', event => {
event.respondWith(handleSpatialRequest(event.request));
});
async function handleSpatialRequest(req) {
const url = new URL(req.url);
const x = parseInt(url.searchParams.get('x'));
const y = parseInt(url.searchParams.get('y'));
const lod = parseInt(url.searchParams.get('lod') || '2');
const version = url.searchParams.get('v') || '47';
// Normalize to 16m tiles for cache key
const tileX = Math.floor(x / 16);
const tileY = Math.floor(y / 16);
const cacheKey = `https://assets.example.com/tile/${tileX}/${tileY}/lod${lod}/${version}`;
let response = await caches.default.match(cacheKey);
if (response) return response;
// Prefetch neighbors based on velocity vector
const vx = parseFloat(req.headers.get('x-velocity') || '0');
const vy = parseFloat(req.headers.get('x-velocity') || '0');
if (Math.hypot(vx, vy) > 4.0) {
const nextTiles = getTilesInDirection(tileX, tileY, vx, vy, lod);
event.waitUntil(prefetchTiles(nextTiles, version)); // warm LOD1->LOD0 in background
}
response = await fetch(cacheKey, { cf: { cacheTtl: lod === 2 ? 900 : 75 } });
event.waitUntil(caches.default.put(cacheKey, response.clone()));
return response;
}
Advanced Patterns and Troubleshooting: When Your World Won't Load
If you turn on spatial prefetching and your origin load *increases*, you’ve got cache thrashing. We see this constantly when teams prefetch too aggressively — grabbing a 200m radius at LOD0 for every movement tick. You’ll flood edge cache with tiles that are evicted before anyone uses them. The fix is tiered prefetching: only prefetch LOD2 aggressively (it’s tiny, 15-40KB per tile), prefetch LOD1 conservatively within 50m, and never prefetch LOD0 — let it be demand-fetched with a 400-600ms stale-while-revalidate window. Add surrogate keys like region:88-92 so you can purge a city block, not the whole world. We've seen this cut origin egress by 40-55% overnight.
The other killer is stale assets. In a persistent world, a creator can move a wall and half your edge nodes will still serve the old mesh for 10 minutes. Don't rely on TTL alone. Version every asset bundle and use immutable URLs — /tile/.../v48 never changes — and purge by surrogate key on publish. This is exactly how we handle secure, versioned payloads for IoT Firmware Update Distribution via CDN: Secure OTA Updates for Device Fleets and Connected Vehicle Update Delivery: CDN Infrastructure for Automotive OTA; a virtual building update isn’t that different from an OTA — you can’t have half your fleet seeing the old firmware. Implement an event bus from your world editor to the CDN purge API and aim for <200ms global invalidation propagation.
Finally, watch your byte hit ratio, not just request hit ratio. A 92% request hit ratio sounds great until you realize the 8% misses are all 8MB LOD0 meshes that account for 68% of your origin bytes. If your p95 origin fetch is over 800ms, add a mid-tier shield or regional persistent cache (like CloudFront Origin Shield or Fastly Shield POP). And always compress — enable Brotli-11 for glTF/JSON and Basis Universal for textures at the edge. We’ve measured 31% smaller LOD2 payloads after switching from gzip to Brotli on Draco-compressed meshes, which directly translates to 20-30ms less decode time on Quest 3-class hardware.
Frequently Asked Questions
What’s a realistic edge hit ratio target for a large persistent metaverse world?
Don’t chase 99% like you would for static web assets. With spatial tiling and LODs, a healthy production target is 85-92% request hit ratio and 78-85% byte hit ratio. If you’re below 80% request hit ratio, your tiles are too large or your TTLs too short. If your byte hit ratio is 15 points lower than request hit ratio, your high-detail LODs are thrashing — tighten your prefetch radius for LOD0/LOD1.
How do we handle cache invalidation when user-generated content changes every second?
Never mutate a cached object. Treat every edit as a new version (v48, v49) and serve immutable URLs with a 1-year cache-control. Then purge the previous version’s surrogate key asynchronously. This gives you instant consistency for the user who made the edit (they request the new version) while old versions naturally expire without you waiting on global purge latency.
Should we use WebSocket or CDN for real-time avatar and physics state?
Split the traffic. Use CDN (with edge compute) for asset delivery — textures, meshes, audio, lightmaps — and use a dedicated UDP/WebRTC or WebSocket mesh for ephemeral state like position, voice, and physics. Don’t try to cache real-time state; cache the world it runs in. The CDN’s job is to make sure when an avatar teleports, the world is already there in <150ms.
Why is my TTFB great (40ms) but the world still pops in slowly?
That’s usually not a CDN latency problem — it’s a payload and decode problem. Check your LOD2 budget: if your fallback tiles are over 80KB each, you’re shipping too much geometry for the initial paint. Profile with a waterfall: you want LOD2 in <100ms, LOD1 in <400ms, and LOD0 streamed progressively. If TTFB is low but download + decode is 1.2s, re-compress textures to KTX2/Basis and enable Brotli at the edge.