Modern C2 dashboards no longer render hundreds of tracks. They render tens of thousands. Air pictures from federated radar networks, AIS maritime feeds, drone swarm telemetry, ground unit GPS pings, and fused multi-sensor tracks all arrive on the same operator screen. A workable common operational picture in 2026 routinely peaks above 100,000 simultaneous tracks. The visualization layer either holds 60fps under that load or the operator loses trust in the system.
This article is an engineering walkthrough of how to keep frame budgets honest at that scale. We cover WebGL instancing, Cesium primitive batching, deck.gl layer composition, LOD strategy, GPU memory budgets, frame-pacing arithmetic, and the test harness you need to catch regressions before an operator does.
The 100,000-Track Reality
The old rule of thumb — "a tactical display needs to handle a few thousand tracks" — was correct in 2010 and dangerously wrong in 2026. NATO multi-sensor fusion feeds, low-altitude radar grids, and commercial ADS-B/AIS aggregators routinely produce six-figure track counts during exercises and active operations. Drone-swarm doctrine alone can put 200–500 friendly emitters into a 50 km box.
The perf gap matters because operator confidence collapses below 30fps. Once panning stutters or symbology lags the underlying track update, the operator distrusts everything else on the display — and starts cross-checking the picture against a paper map or a secondary screen. That cross-checking time is exactly the operational latency the C2 system was built to eliminate. A C2 dashboard that drops below 60fps under realistic load is not a slow C2 dashboard. It is a broken one.
The target is fixed: 60fps sustained, with 100,000+ tracks, on a typical mid-tier operations workstation (8-core CPU, mid-range dGPU, 1440p). Hitting that target requires every layer of the stack — geometry, draw calls, GPU memory, network ingest, state mutation — to respect the 16.67ms frame budget.
WebGL Instancing
The first lever is the draw call. Issuing 100,000 individual draws per frame is impossible at 60fps on any current GPU; the CPU-side driver overhead alone burns the entire budget. Instanced rendering collapses thousands of symbols into a single draw call. One geometry (the symbol mesh), one shader, and a per-instance attribute buffer carrying position, heading, affiliation, and symbol ID.
The standard pattern uses ANGLE_instanced_arrays in WebGL 1 or native drawArraysInstanced in WebGL 2. Per-instance attributes stream from a tightly packed buffer: typically 32 bytes per track (vec3 position, vec2 velocity, uint32 packed flags). At 100k tracks that is 3.2 MB of vertex attribute data — small enough to re-upload every frame if needed, though partial updates via bufferSubData are cheaper.
Three.js exposes instancing through InstancedMesh; deck.gl handles it natively for almost every layer; Cesium's Primitive API supports it through GeometryInstance arrays. The three frameworks land on the same spectrum — Three.js gives you the most freedom and the least batteries-included geospatial math, deck.gl is the fastest path to a working high-density layer, Cesium provides 3D globe semantics and terrain occlusion the other two do not.
Cesium Primitive Batching
Cesium's Entity API is the wrong tool above 5,000 tracks. Entities allocate per-track JavaScript objects, run a CPU-side update loop, and rebuild geometry on property changes. The cost is amortized fine at small counts and catastrophic at large ones.
For 100k-scale rendering, drop down to the Primitive API. A PointPrimitiveCollection renders up to ~1M points in a single draw call. A BillboardCollection handles tens of thousands of icon-textured sprites with a shared texture atlas. The GeometryInstance pattern groups thousands of static geometries (e.g. range rings, geofences) into one batched primitive at creation time.
Label rendering is asymmetric. A 100k PointPrimitiveCollection renders effortlessly; adding 100k labels on top breaks the frame budget instantly. Labels go through Cesium's SDF text path, which costs both glyph atlas memory and a separate batched draw. The fix is LOD-driven label visibility: render labels only for tracks within a zoom-dependent screen-space radius of the cursor or for tracks flagged "of interest" by the fusion engine. A typical operator screen needs no more than 50–200 visible labels at once.
deck.gl Layers for Defense
deck.gl sits on top of a MapboxGL or MapLibre base map and gives you a composable layer stack designed for exactly this problem. The relevant layers for a C2 display:
ScatterplotLayer. The workhorse for raw track positions. Renders millions of points at 60fps because every attribute (position, color, radius) is GPU-bound. Use it for unsymbolized track dots, sensor coverage rings, and high-density flat layers.
IconLayer. Renders MIL-STD-2525 symbols from a texture atlas. Performance scales with atlas size; pack 2525 symbology into a single 4096×4096 atlas with all affiliation/echelon variants pre-rasterized. At 100k icons with a single shared atlas, IconLayer holds 60fps comfortably on mid-tier hardware.
PathLayer. For track histories and projected courses. Cost scales with vertex count, not path count — favor decimating long histories (Douglas-Peucker, with epsilon tied to zoom level) rather than dropping paths.
GPU-aggregation layers. ScreenGridLayer, HexagonLayer, and HeatmapLayer aggregate millions of points into bin-summed visualizations on the GPU. Useful as zoom-out density overlays — at low zoom you do not want to see 100k symbols, you want to see the threat density gradient.
Level-of-Detail (LOD) Strategy
The most effective performance optimization is to not render what the operator cannot meaningfully see. At a 2000 km wide zoom level, a 100k-track display cannot resolve individual symbols — every screen pixel covers multiple tracks. Rendering full MIL-STD-2525 symbology at that zoom is wasted GPU time and produces an unreadable picture.
The LOD ladder: at far zoom, render aggregated density (GPU-binned heatmap or hex layer); at medium zoom, render unsymbolized dots colored by affiliation; at close zoom, render full 2525 symbology with labels for flagged tracks; at maximum zoom, render full symbology with labels, history trails, and projected courses for every visible track.
Screen-space clustering is the complement. Even at close zoom, dense clusters (a parking lot of vehicles, a port full of ships) produce overlapping symbols that hide each other. A k-d tree or grid-bin clustering pass (run on the worker thread, not the main thread) collapses overlapping symbols into a single "N tracks here" badge until the operator zooms in.
The usage profile justifies aggressive LOD: an operator zooms in once every 30–60 seconds and spends most of their time scanning the wide picture. Optimizing the wide-picture frame budget pays back continuously; optimizing the close-zoom frame budget pays back only at the moments of active interest.
GPU Memory Budgets
Memory pressure is the silent killer of high-density displays. The visible track count is only one part of the budget. Texture atlases (symbol sheet, terrain tiles, basemap raster tiles), vertex buffers (per-instance attributes, history paths), uniform buffers, framebuffer attachments, and the browser's own compositor all draw from the same pool.
The real-world budgets we plan against: a 4 GB integrated GPU (Intel Iris Xe, AMD Radeon 780M) on a deployed laptop has roughly 2–2.5 GB usable for the WebGL context after the OS, browser, and other tabs take their share. A 16 GB discrete GPU (RTX 4070 / 5070 class) has 12+ GB usable. Many headless conservative deployments — operations centers with hardened workstations procured years ago — still run iGPU-class hardware. Designing for the iGPU envelope is the safer default.
Practical numbers for a 100k-track C2 dashboard: per-instance attribute buffers ~5–10 MB; symbol atlas ~64 MB (4096² RGBA); basemap raster cache ~200–400 MB; terrain tile cache ~300–600 MB; history path geometry ~20–50 MB depending on retention. Total ~600 MB–1.2 GB. That fits the iGPU envelope with margin, but only if every layer is disciplined about texture sizes and buffer growth.
Latency and Frame-Pacing
The 16.67ms frame budget breaks down roughly: 2–3ms for input handling and state mutation, 4–6ms for layer update (CPU-side, mostly attribute buffer recomputation and culling), 6–8ms for GPU rendering, 1–2ms for compositor overhead. Anything that consumes more than its slice steals from the next slice and produces a dropped frame.
The worst spikes hide in places that are easy to overlook. Track-correlation joins on the main thread — running the fusion engine's correlation pass inline with rendering — produce 50–200ms stalls every time a new sensor batch arrives. The fix is to run correlation on a worker thread and post immutable track deltas to the main thread. Server-pushed update bursts (a websocket dumping 5,000 track updates in one tick) saturate the JS event loop; rate-limit and batch deltas to one buffered update per frame.
Garbage collection is the third spike source. Allocating new objects per track per frame produces sawtooth GC pauses every few seconds. Use typed-array pools and reuse buffers; avoid per-frame object literal creation in hot paths. The 60fps-or-bust expectation is real, and a single 100ms GC pause every 10 seconds is exactly the kind of jank that destroys operator trust.
Testing at Scale
You cannot ship a 100k-track display without a test harness that generates and replays loads at that scale. Three components: a synthetic track generator, an automated FPS-regression suite, and a recorded-playback truth source.
The synthetic generator produces deterministic 100k-track scenarios — random distributions, dense clusters, drone-swarm formations, mass-raid scenarios — each seeded so a CI run reproduces the same scene every time. Each scenario drives a headless browser through a scripted camera path (pan, zoom in, zoom out, declutter, filter) while the harness samples performance.now() delta histograms and reports p50/p95/p99 frame times.
The FPS-regression suite runs on every PR. The thresholds are explicit: p95 frame time under 18ms, p99 under 25ms, no dropped frames exceeding 50ms across a 60-second scripted run. Any commit that pushes the numbers past threshold blocks the merge. This is the only way to catch the death-by-a-thousand-cuts regressions where each individual change costs 0.2ms.
Real-world recorded sensor playback is the truth source. Synthetic loads catch perf cliffs but miss the asymmetric distributions of real data — the clusters, the gaps, the burst patterns. A pcap-style recording of a live exercise feed, replayed at wall-clock speed against the dashboard, is the closest you get to operational load without an operator in the chair. Pair the synthetic harness with two or three recorded scenes from verification exercises and you have a regression net that holds.