Tracking 6,000 Starlinks in the browser with SGP4 and TypeScript
How we put the ISS, Hubble, and the entire Starlink constellation onto a 60 FPS WebGL globe — TLE pipelines, ECI→ECEF math, what to cache where, and what NOT to do.
TL;DR — Kepler is enough for planets. Earth satellites need SGP4: a 1960s-vintage analytical perturbation model NASA still publishes, fed by TLE strings NORAD updates daily. We put the ISS, Hubble, ~30 named satellites, and the entire ~6,000-satellite Starlink constellation onto a live 3D globe at 60 FPS using
satellite.js, ~500 lines of glue code, two cache layers, and one ECI→ECEF coordinate hop. This is the engineering walkthrough.Live demo: /universe — toggle the Satellites layer + the Starlink layer. · interactive decoder: /tools/tle
A natural follow-up to our Keplerian solar system post: Kepler is the right model for planets and moons, but it is the wrong model for an Earth-orbiting satellite. Atmospheric drag, oblateness (J2), lunar/solar gravity, solar radiation pressure — every one of these perturbations is small per orbit and accumulates fast enough that a pure-Kepler ISS prediction drifts ~10 km within a single day. A satellite five orbits old in our renderer would be visibly in the wrong place.
The fix is SGP4 — Simplified General Perturbations 4 — a closed-form analytical propagator developed for NORAD’s tracking catalog in 1970, refined in 1980 (the spec we still use), and given a definitive C / C++ reference implementation by Vallado in 2006. There are TypeScript ports of that reference, the best-maintained being satellite.js. It’s 30 KB minified and runs SGP4 propagation in roughly 100 microseconds per satellite. Multiply by 6,000 Starlinks and you’re at ~600 ms for one tick of the whole constellation — too slow for a per-frame call, fine for a per-second call. We’ll come back to this.
The TLE format
Every satellite has a Two-Line Element set — two 69-character ASCII lines, like this for the ISS:
1 25544U 98067A 24181.71527778 .00012654 00000+0 22861-3 0 9991
2 25544 51.6406 109.5394 0001423 326.0921 156.7820 15.50157644457245
Don’t try to read these. Their fields are positional, fixed-width, and overloaded with implicit decimal points and exponents (22861-3 means 0.00022861e-3). The job of satellite.js is to turn these two lines into a SatRec struct that SGP4 can step through time.
import * as satellite from "satellite.js";
const satrec = satellite.twoline2satrec(tle.line1, tle.line2);
// satrec is now a stateful propagator for this exact satellite.
Where do you get TLEs? Celestrak is the de facto public source — they republish NORAD’s catalog every few hours, free, no API key needed, with a polite request to cache and not hammer them. They have two query patterns:
- Per-satellite by NORAD ID:
gp.php?CATNR=25544&FORMAT=tle— 144 bytes. - By group:
gp.php?GROUP=starlink&FORMAT=tle— ~1 MB plain text for all Starlinks.
For named satellites (ISS, Hubble, Tiangong, Landsat, BeiDou, GLONASS) we hit the per-satellite endpoint with a 24 h memory cache + a 48 h localStorage cache. For Starlink we hit the GROUP endpoint with a 12 h cache, because 6,000 individual fetches would get us rate-limited in seconds. More on that further down.
The pipeline: TLE → screen, in one diagram
TLE strings (two 69-char ASCII lines from Celestrak)
│
▼
satellite.js: twoline2satrec()
│
▼
SatRec ─────────► cache by (norad_id, tleHash)
│
▼
SGP4 propagate(satrec, date)
│
▼
ECI position (km) (Earth-Centered Inertial — non-rotating frame
│ where Earth spins under you)
│
▼
eciToEcf(eci, gmst)
│
▼
ECEF position (km) (Earth-Centered Earth-Fixed — rotating frame
│ where ground stations sit still)
│
▼
Scale by KM_TO_SCENE = 0.8 / 6371
│
▼
Three.js Vector3 in scene units, relative to Earth's center
│
▼
+ Earth's heliocentric position (from our Kepler solar system)
│
▼
Final world coordinate in the scene
The whole thing fits in one function. Here’s the actual production code, stripped of error handling:
export function computeSatelliteEcefFromTle(
tle: TLE,
date: Date,
cacheKey: string
): THREE.Vector3 {
const satrec = getSatRecFromTLE(tle, cacheKey);
// 1. SGP4: time → orbital state in Earth-Centered Inertial (km).
const { position: eciKm } = satellite.propagate(satrec, date);
// 2. ECI → ECEF: rotate by Earth's current spin angle (GMST).
const gmst = getGreenwichSiderealTime(date);
const ecefKm = satellite.eciToEcf(eciKm, gmst);
// 3. km → scene units.
const x = ecefKm.x * KM_TO_SCENE;
const y = ecefKm.y * KM_TO_SCENE;
const z = ecefKm.z * KM_TO_SCENE;
// 4. Remap axes: satellite.js ECEF puts X at Greenwich, Z at the
// north pole, Y at 90°E. Our scene puts Y at north pole. Swap.
return new THREE.Vector3(x, z, y);
}
Twelve lines of business logic, six lines of comments. The work is choosing the right calls, not writing the math.
Why ECI → ECEF matters
This is the one piece newcomers always get wrong. Let me unpack it.
SGP4 outputs in ECI — Earth-Centered Inertial. The frame’s origin is Earth’s center and its X axis points at the vernal equinox (a fixed direction in space, not on Earth). The Earth rotates inside this frame once per sidereal day.
If you take SGP4’s raw output and draw it on top of an Earth that’s also rotating with the calendar, both your Earth and your satellites are rotating. The ISS appears to fly across Tokyo, Tokyo flies out from under it, the ISS appears to fly across Mumbai. Wrong.
You have two options:
- Render your Earth in ECI — pointing the same way 24/7, with the texture (continents) rotating under it. Satellites stay still relative to the inertial frame.
- Render your Earth in ECEF — Earth-Centered Earth-Fixed — texture stuck to the surface, ground stations stationary. Convert satellite positions from ECI to ECEF every frame.
(1) is wrong for any UX that involves “where is the ISS over right now” — the ground tracks become unreadable. (2) is what every consumer satellite tracker uses, and what we use.
The conversion is one matrix rotation by the Greenwich Mean Sidereal Time angle:
const gmst = getGreenwichSiderealTime(date); // radians
const ecef = satellite.eciToEcf(eci, gmst);
satellite.js does the rotation. The one thing it doesn’t do is align with your Earth texture’s prime-meridian orientation — that depends on how your texture is mapped. We empirically rotate by an extra -π/2 to match a typical equirectangular Earth texture’s Greenwich-at-center convention:
const LONGITUDE_OFFSET = -Math.PI / 2; // rotate to align with Earth texture
If your ISS appears in the Indian Ocean when it should be over Houston, this is the knob.
The two cache layers
SGP4 itself is fast (~100 µs per satellite per call). The expensive parts are fetching TLEs over the network and building the SatRec from the TLE strings. Both of these we cache aggressively:
Layer 1: SatRec memoization (per process, in memory)
const satrecCache = new Map<string, satellite.SatRec>();
const tleHashCache = new Map<string, string>();
export function getSatRecFromTLE(tle: TLE, cacheKey: string): satellite.SatRec {
const tleHash = `${tle.line1}|${tle.line2}`;
if (tleHashCache.get(cacheKey) === tleHash && satrecCache.has(cacheKey)) {
return satrecCache.get(cacheKey)!;
}
const satrec = satellite.twoline2satrec(tle.line1, tle.line2);
satrecCache.set(cacheKey, satrec);
tleHashCache.set(cacheKey, tleHash);
return satrec;
}
The cache key is the satellite’s stable identifier (e.g. "iss"); the invalidation key is a hash of the TLE strings. New TLE → new SatRec. Same TLE → instant reuse. Always hash the TLE, never trust the cache key alone — TLEs are updated daily, and a stale SatRec means stale predictions.
Layer 2: TLE persistence (per browser, in localStorage)
For named satellites, the TLE itself is cached for 24 h in memory and 48 h in localStorage. For Starlink (one fetch covers 6,000 sats), 12 h. The cache is stale-while-revalidate — if it’s expired we serve the stale data immediately AND fire an async background refresh, so users never see “loading 6,000 satellites” on a returning visit.
async function getStarlinkTles(): Promise<StarlinkTleEntry[]> {
const cached = readFromLocalStorage();
const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS;
if (cached) {
if (!fresh) refreshInBackground(); // fire-and-forget
return cached.entries; // serve stale immediately
}
return await fetchFresh(); // first visit, must wait
}
12 h is the sweet spot for LEO satellites: SGP4 with a 12-hour-old TLE keeps error well under 1 km, which at our scene’s km-to-scene scale is sub-pixel. Celestrak’s own TLEs typically have epoch ages of a few hours to a day; using a 12-hour-old version is no worse than what professional ground stations work with.
Scaling: ISS to Starlink
For one satellite the pipeline is trivial. For 6,000 it’s a budget problem.
A typical render budget is 16.6 ms per frame at 60 FPS. SGP4 at ~100 µs/satellite means 600 ms for the whole Starlink constellation — 36 frames at 60 FPS. We can’t propagate every satellite every frame.
What we do:
- Tick at 1 Hz, not 60 Hz. Satellite positions don’t change perceptibly over 16 ms — the ISS moves ~120 m in that time, well below one pixel at typical zoom. A 1-second update is invisible to the eye and reduces SGP4 cost by 60×.
- Single
THREE.Pointsfor the whole constellation. Six thousand individual meshes would shred React’s reconciler. We pack all 6,000 positions into a singleFloat32Array, update thepositionattribute’s underlying buffer in place, and setneedsUpdate = true. One draw call, one buffer update per second. - Frustum cull on read, not write. Don’t skip propagating satellites that are off-screen — the propagation cost is the same, and you’d have to re-propagate every time the camera moves. Just feed all 6,000 positions in and let the GPU clip.
The result: ~10 ms of CPU per second to keep the entire constellation current. The rest of the frame is yours.
What we deliberately didn’t do
Honest list:
- Atmospheric models. SGP4 includes a simple drag term, but realistic atmospheric density (which affects LEO orbits significantly during solar storms) needs an external model like NRLMSISE-00. We don’t run one. For a visualization, the resulting error is invisible. For collision prediction, it would be everything.
- Maneuvers. Starlink satellites do collision-avoidance burns regularly. The TLEs reflect post-burn state once SpaceX publishes the new ones; between updates we’d show pre-burn trajectory. We accept the drift.
- Deep-space (SDP4). For satellites above geostationary altitude, SGP4 hands off to SDP4 with lunar/solar perturbations.
satellite.jsdoes this for you transparently — we use it but didn’t write it. - TLE epoch checking. Best practice is to refuse to propagate a TLE more than 14 days past its epoch — SGP4 error grows nonlinearly past that. We don’t enforce this; we trust Celestrak’s update cadence to keep things fresh. A more conservative implementation would warn or refuse.
What it weighs
| Module | Lines | Note |
|---|---|---|
satellitePosition.ts (the pipeline above) |
188 | TLE → ECEF, with cache |
tleService.ts (per-satellite fetch + cache) |
306 | Celestrak per-NORAD endpoint |
starlinkService.ts (constellation fetch) |
207 | GROUP endpoint + SWR cache |
ISS.tsx (the 3D component + telemetry) |
281 | Mesh + ground track + footprint |
Hubble.tsx, generic Satellite.tsx |
~200 | Same shape, different model |
siderealTime.ts (GMST helper) |
~50 | One math function |
Add the data files (TLEs, the catalog) and the React glue and the whole satellite layer is ~1,500 lines on top of satellite.js. The hard work — SGP4, ECI/ECEF, the propagation math — lives in the dependency. Our job is plumbing, caching, and respecting Celestrak’s bandwidth.
Lessons for your past self
- Don’t propagate at 60 Hz. Satellites at orbital velocity move less than a pixel per frame at typical zoom. 1 Hz updates are visually identical and 60× cheaper.
- Cache SatRecs by TLE hash, not by name. A stale SatRec is invisible until your visualization is wildly wrong. Hashing the TLE lines is one line; it saves you the wrong kind of debugging session.
- Stale-while-revalidate is the right shape for TLEs. Users on slow connections shouldn’t stare at a blank scene. Serve last-fetched, refresh in the background, replace silently. SGP4 with a half-day-old TLE is still within 1 km.
- Convert ECI → ECEF or your ground tracks lie. If your texture is mapped Greenwich-at-center and your satellites are spinning with Earth, you’re doing it wrong. Pick a frame, stick to it.
- Don’t fan-fetch 6,000 satellites. Celestrak’s GROUP endpoint exists for a reason. Use it. Your users’ rate-limit fines will thank you.
satellite.jsis the answer. Don’t port SGP4 yourself unless you enjoy implementing 1980s Fortran. The TypeScript library is mature, fast, and the same math NORAD uses.
Try it
The live constellation lives at /universe — turn on the Satellites layer (ISS, Hubble, Tiangong + ~30 catalog entries) and the Starlink layer (all 6,000-ish active satellites, GROUP-fetched and SWR-cached). The Birthday Sky variant (/universe/birthday) reuses the same satellite layer at any moment in time — type a date, the TimeManager pauses, the satellite layer redraws from the same TLE pipeline at that exact instant.
If you’re building a satellite tracker and have questions — about the cache invalidation, the ECEF frame conversion, the Starlink draw call, or the TLE epoch policy — reach out via Contact.