Measuring Tile Load Times in the Cesium Frame Loop

This page instruments a CesiumJS client so that “the tiles are slow” becomes four numbers — request latency, decode time, GPU upload, and draw — measured separately and adding up to the frame budget. The instrumentation is light enough to leave running in production, which matters because the interesting cases happen on a viewer’s connection and hardware rather than on yours.

Why you hit this

A tile’s journey has four stages with completely different remedies, and every one of them presents to a user as the same symptom. A slow network means prefetching earlier; a slow decode means different compression settings; a slow upload means smaller or fewer buffers; a slow draw means too many draw calls. Choosing between them by inspection is guesswork, and the browser’s default profiler distorts the frame budget it is supposed to be measuring.

The diagnostic framework this feeds is in streaming and runtime diagnostics; this page is about producing the numbers it reads.

Prerequisites

  • CesiumJS 1.107+ (earlier versions expose fewer tile events), served over HTTPS so performance timing is unthrottled.
  • A tileset you can reload, and a repeatable camera path — a scripted flight, not a hand-flown one.
  • Chrome or Chromium for PerformanceObserver and the Resource Timing API. Both work in Firefox with minor naming differences.
  • Timing-Allow-Origin set on the tile CDN, or cross-origin resource timings return zeros for everything except duration.

Step-by-Step

1. Capture the network half with Resource Timing

The browser already measures every tile request. You only have to collect it.

javascript
const netStats = [];

const obs = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (!/\.(b3dm|glb|pnts|subtree)$/.test(e.name)) continue;
    netStats.push({
      url: e.name,
      wait: e.responseStart - e.requestStart,       // server think time
      download: e.responseEnd - e.responseStart,     // transfer
      total: e.duration,
      bytes: e.encodedBodySize,
      cached: e.transferSize === 0,
    });
  }
});
obs.observe({ type: 'resource', buffered: true });

function summariseNetwork() {
  const live = netStats.filter((s) => !s.cached);
  const p = (arr, q) => arr.sort((a, b) => a - b)[Math.floor(arr.length * q)] || 0;
  const totals = live.map((s) => s.total);
  console.table({
    requests: live.length,
    cacheHits: netStats.length - live.length,
    p50_ms: p(totals, 0.5).toFixed(1),
    p95_ms: p(totals, 0.95).toFixed(1),
    medianKB: (p(live.map((s) => s.bytes), 0.5) / 1024).toFixed(1),
  });
}

Separating wait from download is what distinguishes a CDN miss from a fat tile. A p95 wait of 400 ms with a small download means requests are reaching the origin; a small wait with a long download means the tiles are simply large.

2. Time decode and upload inside the frame loop

CesiumJS raises events as a tile moves through its lifecycle. Timing between them gives decode and upload separately.

javascript
const tileset = await Cesium.Cesium3DTileset.fromUrl('/live/tileset.json');
viewer.scene.primitives.add(tileset);

const started = new Map();
const timings = { decode: [], upload: [] };

tileset.tileLoad.addEventListener((tile) => {
  const t = started.get(tile._header.content?.uri);
  if (t) timings.decode.push(performance.now() - t);
});
tileset.tileVisible.addEventListener((tile) => {
  const t = started.get(tile._header.content?.uri);
  if (t) {
    timings.upload.push(performance.now() - t);
    started.delete(tile._header.content?.uri);
  }
});
tileset.tileFailed.addEventListener((e) => console.warn('tile failed', e.url, e.message));

Cesium.RequestScheduler.requestCompletedEvent.addEventListener(() => {
  // nothing here; the hook exists so the scheduler's queue depth can be sampled
});

tileLoad fires when the content has been parsed and decoded; tileVisible fires on the first frame the tile is actually drawn, which is after the GPU upload. The difference between them is the upload cost, and it is frequently larger than people expect for texture-heavy tiles.

Which Cesium event marks which boundary The request begins when the scheduler admits a tile, the Resource Timing entry ends when the bytes arrive, tileLoad fires once the content is parsed and decoded, and tileVisible fires on the first frame the tile is drawn. Each interval between those marks is one of the four costs, and each has a different remedy. queued network — Resource Timing decode — to tileLoad upload — to tileVisible backpressure CDN, tile size Draco settings buffer count, textures Four intervals, four remedies — and the user reports all four as "the tiles are slow" The sum should account for the observed latency; a large unexplained remainder means the scheduler queue, not the tile
Each boundary has an event or a timing entry behind it, so the split costs a few listeners rather than a profiler.

3. Sample the scheduler’s queue depth

Time spent queued is invisible in every per-tile measurement, and it is often the largest component when the camera moves quickly.

javascript
const queueSamples = [];
viewer.scene.postRender.addEventListener(() => {
  queueSamples.push({
    t: performance.now(),
    inFlight: Cesium.RequestScheduler.statistics.numberOfActiveRequests,
    pending: Cesium.RequestScheduler.statistics.numberOfPendingRequests,
    tilesLoading: viewer.scene.primitives.get(0).tilesLoaded ? 0 : 1,
  });
  if (queueSamples.length > 3600) queueSamples.shift();      // keep one minute at 60 fps
});

A pending count that sits at hundreds while inFlight sits at six is the browser’s per-host connection limit doing the scheduling for you, which is the case backpressure exists to prevent.

4. Measure the frame itself, cheaply

performance.measure around the render is enough, and unlike the DevTools profiler it does not change what it measures.

javascript
let frames = 0;
const frameTimes = [];

viewer.scene.preUpdate.addEventListener(() => performance.mark('f0'));
viewer.scene.postRender.addEventListener(() => {
  performance.mark('f1');
  performance.measure('frame', 'f0', 'f1');
  const m = performance.getEntriesByName('frame').pop();
  frameTimes.push(m.duration);
  performance.clearMarks(); performance.clearMeasures();

  if (++frames % 600 === 0) {
    const sorted = [...frameTimes].sort((a, b) => a - b);
    console.log(
      `frames ${frames} | p50 ${sorted[sorted.length >> 1].toFixed(1)} ms` +
      ` | p95 ${sorted[Math.floor(sorted.length * 0.95)].toFixed(1)} ms` +
      ` | over 16.7ms: ${(100 * frameTimes.filter((d) => d > 16.7).length / frameTimes.length).toFixed(1)}%`);
    frameTimes.length = 0;
  }
});

Report the p95 and the fraction over budget rather than the mean. A mean of 12 ms with 8% of frames over 30 ms feels considerably worse than a steady 16 ms, and only the distribution shows it.

5. Fly a scripted path so runs are comparable

A hand-flown camera makes every measurement incomparable with the last one.

javascript
async function flyPath(viewer, waypoints, secondsEach = 4) {
  for (const wp of waypoints) {
    await new Promise((resolve) => {
      viewer.camera.flyTo({
        destination: Cesium.Cartesian3.fromDegrees(wp.lon, wp.lat, wp.height),
        orientation: { heading: Cesium.Math.toRadians(wp.heading), pitch: Cesium.Math.toRadians(-35) },
        duration: secondsEach,
        complete: resolve,
      });
    });
  }
}

await flyPath(viewer, [
  { lon: 10.7522, lat: 59.9139, height: 1800, heading: 0 },
  { lon: 10.7601, lat: 59.9210, height: 600,  heading: 45 },
  { lon: 10.7480, lat: 59.9165, height: 250,  heading: 190 },
]);
summariseNetwork();
A frame budget that adds up, and one that does not When the measured stages sum to close to the observed frame time, the instrumentation is complete and the largest stage is the one to attack. When a large remainder is unaccounted for, the time is being spent somewhere not instrumented — usually queued in the request scheduler or blocked on the browser's connection limit. sum ≈ observed — instrumentation complete unaccounted queue network decode upload A large remainder means the time is somewhere you are not measuring — usually the scheduler queue Chasing the largest measured bar while a bigger unmeasured one exists is the most common wasted optimisation here
The completeness check comes before the optimisation. A budget that does not add up is pointing at the wrong bar.
Why the mean frame time hides the problem Two clients with the same mean frame time feel completely different. A tight distribution around eleven milliseconds is smooth. A distribution with the same mean but a long tail past thirty milliseconds stutters visibly, and only the ninety-fifth percentile and the fraction over budget distinguish them. p50 11.2, p95 14.1 p50 11.4, p95 28.9 16.7 ms 16.7 ms Same mean, and only the second one stutters. Report p95 and the fraction over budget, never the average.
Frame time is a distribution, and its tail is what a viewer notices. A mean is the one summary that hides exactly the thing being complained about.

Expected Output & Verification

A representative run over a scripted three-waypoint path on a city tileset:

text
┌──────────────┬────────┐
│ requests     │  412   │
│ cacheHits    │  1104  │
│ p50_ms       │  38.2  │
│ p95_ms       │ 214.7  │
│ medianKB     │  86.4  │
└──────────────┴────────┘
frames 600 | p50 11.4 ms | p95 28.9 ms | over 16.7ms: 12.3%
decode p50 17.9 ms | upload p50 5.2 ms
pending p95 186 | inFlight p95 6

Two findings fall out of that immediately. inFlight pegged at six with a p95 pending of 186 is the browser’s connection limit scheduling the work, so the client needs backpressure before anything else is worth tuning. And a decode p50 of 17.9 ms against an upload of 5.2 ms says the Draco settings, not the geometry size, are what cost the frame.

Verify by re-flying the same path twice and comparing. Anything that moves by more than about ten per cent between identical flights is measuring the network’s mood rather than the client.

Common Errors

Every Resource Timing entry has zeros except duration. The tile CDN is cross-origin and does not send Timing-Allow-Origin. Add it, or the network split is unavailable and only the total remains.

Decode times look impossibly small. The tiles came from the HTTP cache, so there was nothing to fetch and little to parse. Filter on transferSize === 0 and report cached and live separately.

The DevTools profiler shows a completely different frame time. It is instrumenting every call and inflating the budget. Use it to find a hot function once, and take the numbers you quote from performance.measure.

tileVisible never fires for some tiles. Those tiles were culled after loading, which is normal near the frustum edge and pathological if it is most of them — that is the over-fetch signature.

Frequently Asked Questions

Can this instrumentation stay in production?

Yes. Four event listeners and a performance.measure per frame cost well under a tenth of a millisecond, and the data is exactly what you need when a user reports a problem you cannot reproduce.

How do I get these numbers off a user’s machine?

Batch the summary — percentiles, not per-tile records — and post it to your own endpoint every few minutes. Per-tile detail is large, largely uninteresting, and carries the user’s navigation path with it.

Does the camera path need to be identical between runs?

Yes, if the runs are to be compared. Scripted flyTo waypoints with fixed durations are reproducible; a hand-flown path is not, and the difference between two hand-flown runs routinely exceeds the effect being measured.

One practical caveat about where these numbers come from. Everything above measures the client you are sitting at, and the interesting cases are almost never that client — they are a viewer on a throttled connection, on a device three years old, at a time of day when your CDN’s nearest edge is saturated. That is why the instrumentation is designed to be cheap enough to leave running: the summary it emits every few minutes is the only evidence you will have about a problem you cannot reproduce.

When you do collect from real viewers, send percentiles rather than per-tile records. A per-tile stream is large, dominated by uninteresting successes, and carries the viewer’s navigation path with it — which is both a privacy consideration and a needless volume of data. Five numbers per session, batched, answer every question the detail would have.

Finally, keep the scripted flight path in version control next to the client. A performance comparison between two releases is only meaningful if both flew the same route, and a route that lives in somebody’s browser history is not reproducible by anyone else.

Do these listeners affect the frame budget they measure?

Marginally. Four event listeners and one performance.measure per frame cost well under a tenth of a millisecond, which is inside the noise of the numbers being reported and orders of magnitude below what the DevTools profiler adds.

What is a reasonable target for the fraction of frames over budget?

Under about two per cent on the device class you are targeting. Above five per cent the stutter is perceptible to most viewers even when the median frame time looks healthy, and above ten it reads as a broken client regardless of what the average says.

Should the summary include tiles that failed?

Yes, separately. A tileFailed rate that is non-zero but small usually means a handful of availability bits disagree with what was published, and it is invisible in every timing statistic because a failed tile has no load time at all.

Back to Performance Profiling and Benchmarking.