Orbital Mechanics in the Browser: Project Orrery
Vaynerov Project Orrery draws every active satellite in real time, in a browser tab, with no plugin and no server doing the math. Here is how the pipeline actually works — and which parts of it turned out to have nothing to do with orbital mechanics.
On this page
The upstream file is about ten megabytes. It holds roughly sixteen thousand orbital element records — one for every active object in the public catalog, published by CelesTrak and refreshed every couple of hours. Vaynerov Project Orrery takes that file, slims it, ships it to your browser, and propagates all of it in a Web Worker at ten snapshots per second while you spin the planet with your thumb.
There is an easier version of this page. Embed somebody's satellite tracker in an iframe, put a teal border around it, call it a flagship. We wrote the propagator instead — Kepler's equation, the J2 secular rates, the sidereal rotation of the Earth, all of it in TypeScript. That decision is the reason the page exists, and it is also, in retrospect, the least difficult part of the build. The hard parts were a cache and a memory budget.
- ~16k
- element records upstream (~10 MB)
- ~540 KB
- gzipped after slimming
- 5
- Newton iterations per solve
- 0.010°
- GEO drift over 6 simulated hours
What arrives, what ships, what the solver costs, and what the self-check demands of it.
A ten-megabyte file and a two-megabyte cache
Our proxy asks CelesTrak for the active group as JSON, with a sixty-second timeout and a user agent that says who we are — VaynerovOrrery/1.0 (+https://vaynerov.com/orrery) — because CelesTrak asks callers to identify themselves and it costs nothing to be a good guest.
What comes back is verbose JSON with long human-readable keys repeated sixteen thousand times. We re-encode it as an array of arrays, which erases the keys entirely, and round the fields to the precision the eye can actually use: mean motion to eight decimal places, eccentricity to seven, angles to four. Ten megabytes becomes roughly a megabyte and a half raw, and about 540 KB across the wire.
// One satellite, ten slots. The name survives; everything else is a number.
// [name, norad, epochMs, meanMotion, ecc, inc, raan, argp, ma, groupIdx]The slimmed record. Dropping JSON keys is the single largest saving in the pipeline — larger than any rounding.
A small share of records are actively hostile to the renderer, so the proxy drops them: anything with a non-finite element, anything with a mean motion below 0.9 revolutions per day, and anything with eccentricity at or above 0.9. The mean-motion floor removes lunar-distance objects — the deep-space observatories whose orbits would place them several screens outside a planet drawn 6.4 units tall. The eccentricity ceiling removes near-parabolic records, which the Kepler solver dislikes for reasons we will come back to. Roughly eleven thousand records survive to reach a browser.
Then a detail that is easy to get wrong and silent when you do: general perturbation epochs are published without a time zone. Date.parse reads a zone-less timestamp as local time, so the same catalog propagated on two machines in two time zones puts every satellite in a different place — by thousands of kilometres, with nothing throwing. We truncate the fractional seconds to milliseconds and append a literal Z.
Why the cache is hand-rolled
Next.js has a perfectly good data cache and we do not use it here, for three reasons that accumulated in order. The first is size: a ten-megabyte upstream response exceeds the 2 MB per-entry limit, so next: { revalidate } silently never retained a last-good copy. Silently is the operative word — nothing failed, the fetch just went upstream every time.
The second reason is the one that actually hurt. Route-level ISR once cached a 503 from upstream, and because the response was successfully cached, our "constellation offline" state stayed frozen on the page for six hours while CelesTrak was perfectly healthy. Caching a failure is worse than not caching at all.
The third is a protocol subtlety: CelesTrak returns 403 if you ask again inside its two-hour update window. That is not an error, it is a not modified signal, and any cache that treats it as a failure will evict good data in favour of nothing.
Stale elements beat a blank sky. A satellite drawn from six-hour-old elements is still, to the eye, exactly where the satellite is.
So: two tiers, process memory in front of a file in the OS temp directory, a six-hour TTL against CelesTrak's roughly two-hour cadence, a ten-minute floor between failed attempts, and in-flight deduplication so a cold start under load fetches once rather than once per request. When we serve from a stale tier we say so in a response header, x-orrery-elements: stale, and the browser cache gets max-age=3600, stale-while-revalidate=21600. The genuine offline case — no memory copy, no disk copy, upstream down — returns a 503 marked no-store, so that nothing anywhere can pin the failure the way ISR once did.
Kepler, plus the one perturbation you cannot skip
Each record gives you a mean motion — revolutions per day — and five angles. Two-body mechanics turns the first into a semi-major axis in one line, because mean motion and orbit size are the same fact written twice.
The Earth, unhelpfully, is not a point mass and not a sphere. It bulges at the equator by about 21 km, and that bulge — encoded as the dimensionless coefficient J2, 1.08262668 × 10⁻³ — torques every orbit that is not exactly equatorial. Over a single pass the effect is invisible. Over a week it is the difference between a satellite being where you drew it and being on the other side of the planet. J2 is why sun-synchronous orbits exist at all: mission designers pick an inclination that makes the nodal drift precisely match the Earth's motion around the Sun.
const n0 = meanMotion * TWO_PI / 86400; // rad/s
const a = Math.cbrt(MU_EARTH / (n0 * n0)); // km
const p = a * (1 - ecc * ecc);
const f = n0 * J2 * (RE / p) ** 2;
const raanDot = -1.5 * f * cosI;
const argpDot = 0.75 * f * (5 * cosI * cosI - 1);
const mDotJ2 = 0.75 * f * Math.sqrt(1 - ecc * ecc) * (3 * cosI * cosI - 1);The three secular rates. Right ascension of the ascending node regresses, argument of perigee rotates, and the mean anomaly picks up a small correction — all linear in time from the record's epoch.
Advance the three angles to the requested instant, and you have a mean anomaly that needs converting to an eccentric one. That is Kepler's equation, M = E − e sin E, which has no closed-form solution and gets solved by Newton iteration. We start at E = M and run exactly five iterations — no convergence test, no early exit, no loop bound to argue about. That constant is not laziness; it is the payoff from the eccentricity filter upstream. With e < 0.9 guaranteed, five iterations from E = M land inside float precision for every record in the catalog, and a fixed iteration count means every satellite costs the same, which is what a tight inner loop over sixteen thousand objects wants.
The rest is rotations: perifocal coordinates through Rz(−Ω) Rx(−i) Rz(−ω) into Earth-centred inertial, expanded by hand because only two columns of that product survive contact with a two-dimensional orbital plane. Then one axis swap, because the aerospace convention is Z-up and three.js is Y-up.
One decision falls out of the frame choice and is worth stating plainly: we rotate the planet, not the constellation. Satellites stay in inertial coordinates, where their orbits are simple, and the Earth group is spun by Greenwich Mean Sidereal Time — θ = 280.46061837 + 360.98564736629 × (JD − 2451545.0). The textbook expression carries a T² term after that; we dropped it, having measured its contribution at well below a tenth of a degree across decades of simulated time. At the scale of a planet drawn 6.4 units tall, a tenth of a degree is not a pixel.
Visual-grade propagation, and saying so
What we have built is two-body motion plus J2 secular rates. It has no atmospheric drag, no lunisolar perturbations, no solar radiation pressure, no resonance terms, no short-period terms. For a satellite in a 400 km orbit, drag alone will out-run our model within days.
The comment in the propagator says it in one sentence — faithful to the eye at the scale of a planet drawn 6.4 units tall, not to the meter — and ends with the line we liked enough to put in the interface: never navigate with it. The HUD carries a chip reading "visual-grade propagation", and the info card for a selected satellite reports how old its elements are, measured against simulation time rather than wall-clock, so that time-warping forward makes the extrapolation honestly look worse.
A model that knows what it is not is more useful than a model that pretends. The chip is not a disclaimer we were forced to write; it is the most accurate label on the page.
The same honesty shows up in a smaller place. When you select a satellite we draw its orbit as 128 samples over one full period, propagated exactly on the main thread. That curve is only correct for the instant it was computed, because J2 is quietly rotating the orbit underneath it — so it is recomputed after 45 minutes of simulated drift rather than left to lie beautifully.
None of which would be checkable if the numbers only ever appeared as pixels. So a development-only self-check runs on boot and asserts against known physics: an ISS-class mean motion of 15.495 revolutions per day must produce a semi-major axis of 6,796.32 km, an altitude of 416.83 km, a period of 92.933 minutes and a speed of 7.660 km/s; a geostationary record at 1.0027 must produce 42,165.23 km and drift no more than 0.010° over six hours. If the arithmetic ever wanders, we hear about it from the console before anyone hears about it from the sky.
Ten snapshots a second, zero allocations
Eleven thousand Kepler solves per frame at 60 fps is not a thing a main thread should attempt, and it is not a thing it needs to attempt. The propagator lives in a Web Worker that is DOM-free and React-free by contract, and it produces snapshots at a capped ten hertz. The renderer interpolates.
A snapshot is one packed Float32Array, three floats per satellite, and it is transferred rather than copied — ownership moves to the main thread and the worker's reference goes null. The engine keeps the last two snapshots and lerps between them by simulation time, which is why a 10 Hz physics budget renders as continuous motion. When a buffer retires, the main thread transfers it back to the worker as a recycle message, and the worker refills it. In the steady state the whole system allocates nothing at all; there is no garbage to collect, so there is no collector pause to feel.
Time is a pure function here: simulation time is an anchor plus elapsed wall time multiplied by the warp factor, with warp settings of 1×, 60×, 600× and 3600× and a snap back to live. At high warp the worker is asked to look further ahead — a lead of at least 1,500 milliseconds, and more when warp is large — because at 3600× we would rather have smooth flow than exact interpolation between two instants the eye cannot separate anyway. Three guards keep the illusion from breaking: a generation counter discards snapshots produced before a catalog swap, a backward time jump larger than a warp-scaled threshold invalidates the interpolation bracket instead of playing it in reverse, and the whole loop stops when the document is hidden or the canvas is less than 2% on screen.
One mesh, six numbers per satellite
Every satellite in the sky is one instance of a single InstancedMesh built from a tetrahedron — four triangles, the cheapest closed solid there is — with an unlit material and per-instance colour. There is no postprocessing anywhere in Orrery: a bloom pass would have made the sky prettier in screenshots and taken the frame budget of every low-tier device that has to draw it.
The per-frame update writes directly into the instance matrix array, and it writes six numbers per satellite out of sixteen.
m[i + 0] = m[i + 5] = m[i + 10] = s; // scale, on the diagonal
m[i + 12] = x; // translation
m[i + 13] = y;
m[i + 14] = z;
// rotation is never written; m[i + 15] was set to 1 once, at init.No Matrix4 objects, no compose() call, no per-instance temporaries. Eleven thousand of these per frame is a memory copy, not a computation.
Apparent size is distance-compensated so that a satellite on the far limb of the Earth stays clickable: s = 0.016 + dist × 0.0021, clamped at 0.105. Crewed stations render 1.7× larger and breathe with a slow sine pulse, which is skipped entirely under prefers-reduced-motion. Filtering by group — the eight first-match-wins buckets the proxy assigns, with geostationary claimed by mean motion between 0.99 and 1.02 so that a weather satellite parked over the equator is tagged as what it is — never rebuilds the mesh. Hidden instances simply get a scale of zero, which the GPU discards for free.
Picking without a raycaster
Raycasting into an InstancedMesh of eleven thousand tetrahedra to find the one under your finger is a lot of intersection tests to answer a question the projection matrix already answered. So selection runs the other way: a single loop projects every satellite to screen space, culls anything behind the camera by the sign of w, discards anything outside normalised device coordinates ±1.1, and keeps the nearest hit within 14 pixels of the mouse or 20 of a fingertip. It is one pass of arithmetic we were going to do anyway.
Touch then gets the usual careful nonsense: a pointer that travels more than 8 pixels is a drag, not a tap; two taps inside 350 milliseconds and 24 pixels means ride along, which translates the camera rigidly with the satellite's own frame delta and eases the residual, while the zoom floor animates from 8 units down to 0.9 so you can actually get close to the thing you chose.
A coastline in 66,717 bytes
A globe needs continents, and the usual answer is a texture — a few hundred kilobytes of JPEG that looks wrong at every zoom level and blurs into mud on a low-DPR phone. We drew lines instead. Natural Earth's 1:110m coastlines, quantised to a hundredth of a degree and run-length packed, become a module of 134 polylines and 5,127 points weighing 66,717 bytes — less than a small photograph, and sharp at any zoom.
One subtlety earns its code. A long coastline segment drawn as a straight chord between two points on a sphere passes through the sphere, so Greenland briefly submerges. Each segment is therefore subdivided along the great circle by spherical interpolation until no step exceeds roughly two degrees, which puts every vertex exactly on the surface. Same trick for the graticule, the brighter equator, and the dashed geostationary belt at 42.164 scene units — the one ring on the page whose radius you could look up and check.
Orrery shipped in the same release as Blueprint, on the day the Lab crossed twelve games, and it inherits more from those games than it looks like: the quality tiers that cap device pixel ratio per class of hardware, the fullscreen contract, the discipline of tearing every loop down on unmount. A browser game and a live satellite map turn out to have almost identical failure modes.