Vol. 07 · Dispatch2026-06-24

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.

by MetaWorldOS Engineering
TypeScriptSatellitesWebGLThree.jsSGP4

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:

  1. 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.
  2. 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:

  1. 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×.
  2. Single THREE.Points for the whole constellation. Six thousand individual meshes would shred React’s reconciler. We pack all 6,000 positions into a single Float32Array, update the position attribute’s underlying buffer in place, and set needsUpdate = true. One draw call, one buffer update per second.
  3. 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.js does 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. satellite.js is 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.

— Read Next —

推荐延伸阅读

更多关于 3D 渲染、物理仿真与网页游戏架构的深度技术解析

Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

阅读全文
Three.js

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

阅读全文
IndexedDB

When signed URLs break your browser cache, put the bytes in IndexedDB

Signed OSS URLs rotate every request, so the disk cache never matches. Sound Race redownloaded 13 GLBs on every visit until we sat an IndexedDB layer in front of GLTFLoader. Here's the code, why Cache Storage doesn't help, and one invalidation edge case we're leaving as tech debt.

阅读全文

订阅工程深潜文章 — 约每月一篇,没有营销邮件。