Building LapDelta: A Real-Time F1 Replay for a Pound a Month
A 60fps replay of real Formula 1 telemetry that runs for about a pound a month with no request-serving backend, built on a zero-copy data contract shared across three languages and a fair amount of geometry.
5 August 2026 13 min read
Playing back live-looking telemetry for twenty cars at sixty frames a second sounds like a job for a streaming backend, a service that holds session state, interpolates positions on request and pushes frames down a socket. That's the obvious shape and it's the wrong one.
LapDelta has no request-serving backend at all. It's an animated, to-scale track map with synchronised telemetry traces and a session-aware timing tower, replaying real Formula 1 sessions in the browser, live at lapdelta.app. The entire running system is an offline build that produces one file per session, a CloudFront distribution serving static bytes, and a browser client that does all the work. It costs roughly a pound a month to run.
I should be plain about what this is and isn't. There are excellent, mature F1 data products out there, built by real teams with real budgets, that do far more than this and do it better. LapDelta doesn't compete with any of them and this isn't the story of a better one. It's a fan project whose one genuinely interesting property is economic: it does a credible version of the thing, real telemetry, twenty cars, sixty frames a second, a timing tower you can trust, on almost no budget, and it stays fast while doing it. That constraint shaped almost every decision below, far more than the feature list did.
This post is about how that's possible, and about the part I enjoyed most along the way: the maths that turns noisy positional data into a track you can recognise and an order you can trust. Some of that maths falls out of the cost constraint and some of it is just the geometry the problem happens to need. I'll try to be honest about which is which.
There are animated plates of the circuit, and of a selection of the other tracks, on the project page.
(LapDelta is an unofficial fan project. It is not affiliated with, endorsed by, or connected to Formula 1, the FIA, or any team, it uses no official marks, and it is non-commercial.)
Start With the Constraint
I wanted the project to be cheap to run when nobody is watching, simple to operate, and genuinely fast under load. Those three constraints rule out the streaming backend immediately, because a service that interpolates frames per client is a service you pay for per client, babysit, and scale.
So the constraint forces an inversion. With no server to do the compute, the compute moves to the two places that are already free: some of it happens once, offline, when a session is ingested, and the rest happens in the browser on the viewer's own machine. What travels between them is a single precomputed artifact.
That gives three zones with hard boundaries:
An offline build in Python ingests a session and writes one artifact. A dumb static layer, S3 behind CloudFront, serves that artifact as immutable, brotli-compressed bytes. A clever client, Angular with a Rust core compiled to WebAssembly, downloads the artifact once and renders everything from it. There's no /api, no database in the request path, and no request path.
One Contract, Three Languages
The artifact is the whole interface between the zones, so its format matters more than any single piece of code. It's a FlatBuffers schema called SessionArtifact, and it's the published language of the system: Python writes it, Rust and TypeScript read it.
FlatBuffers earns its place here because it's zero-copy. The browser downloads the bytes, hands the buffer to the WASM module, and Rust reads fields straight out of that memory with no parse step. On the sixty-frames-a-second render path that's literal, and sampling every car's state is a handful of reads straight off the buffer, allocating nothing. The one exception is the one-time build of the running-order model, which does decode every sample into owned arrays up front, a genuine pass over the data that happens once at load rather than per frame. For a file holding tens of thousands of telemetry samples across twenty cars, "no parse step" is the difference between a snappy load and a stuttering one.
This is the one decision in the project I'd call a clean technical tradeoff rather than a preference. Protocol Buffers was the obvious alternative and for most jobs I'd reach for it first, but protobuf has to decode the wire format into language objects before you can read a single field, allocating as it goes, and for a buffer this size on the browser's main thread that decode is exactly the cost I wanted to avoid. FlatBuffers stores its data in a layout you can read in place. The price of reading a field is a pointer offset. I gave up protobuf's friendlier ergonomics and smaller wire size to buy that.
It also collapses three sources of truth into one. There's exactly one definition of what a session is, and the Python writer and the Rust and TypeScript readers are all generated from it, so a field can't mean one thing on the way out and another on the way in, because there's only one field. Anywhere the same concept is defined in several places, the copies eventually drift. A code generator makes that drift impossible here.
The Stack, and Why
The serialisation format was a technical decision. The languages, mostly, weren't, and it's worth being honest about which is which.
Python, because the data lives there. The ingestion is Python because FastF1, the library that exposes historical Formula 1 timing and telemetry, is Python. It's also free and open. That's the other half of the cost story: the input side of the pipeline costs nothing because the data source costs nothing. The data arrives already in the shape I need, and writing the ingestion in anything else would have meant reimplementing FastF1 or shelling out to it for no benefit. Use the language your data already speaks.
Rust compiled to WASM, half on purpose and half to learn it. The compute core has a genuine reason to be Rust. WebAssembly is a first-class target, the tooling is mature with wasm-bindgen and wasm-pack, and there's no garbage collector or runtime to ship, so the module stays small and its performance is predictable. A sixty-frames-a-second hot path wants exactly that. It's also a language I'm deliberately learning, and a self-contained core with a narrow interface, bytes in and frames out, is a forgiving place to do that, because the blast radius of getting it wrong is one module sitting behind a contract.
Angular, because it's what I reach for. I'm most productive in Angular, it's my daily driver for single-page apps served from CloudFront, and the goal was to spend my thinking on the hard parts, the geometry and the contract and the render loop, rather than on relearning a framework. Comfort is a legitimate input when the framework isn't the interesting problem.
That's the honest throughline. Pick the tool that removes friction everywhere the tool isn't the point, and spend the saved effort where it is.
The Maths, Part One: Building a Track From Noise
Raw F1 positional data is a stream of (x, y, time) samples in some circuit-specific coordinate system. It isn't a track. It's a smudge of points, rotated arbitrarily, at whatever scale the source happened to use, sampled unevenly, with the occasional wild outlier where the feed dropped and interpolated across a gap.
Turning that into a clean, consistently oriented track map is the first interesting bit of geometry and it happens once, offline.
First, resample a reference lap to a fixed number of evenly spaced points by arc length. Raw samples cluster on the straights, where high speed at the same sample rate means more metres per sample, and thin out in the corners, which is exactly backwards for drawing a smooth outline. Walking the polyline by cumulative chord length and interpolating at even intervals fixes that:
deltas = np.diff(closed, axis=0)
seg_lengths = np.hypot(deltas[:, 0], deltas[:, 1])
cumulative = np.concatenate([[0.0], np.cumsum(seg_lengths)])
total = cumulative[-1]
targets = np.linspace(0.0, total, count, endpoint=False)
x = np.interp(targets, cumulative, closed[:, 0])
y = np.interp(targets, cumulative, closed[:, 1])
Then align it. Different circuits arrive at arbitrary rotations, and I want every track to land in the same orientation and the same normalised box so the renderer never has to special-case one. The trick is principal component analysis: take the covariance matrix of the centred points, then its eigenvectors, and the eigenvector with the largest eigenvalue is the track's longest axis, so rotating that onto the horizontal gives a repeatable orientation:
covariance = centered.T @ centered
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
major = eigenvectors[:, int(np.argmax(eigenvalues))]
angle = np.arctan2(major[1], major[0])
A final translate-and-scale into a unit-ish bounding box, and every circuit in the calendar comes out centred, level and drawn to the same scale, with the renderer doing nothing more than drawing a polyline. Two honest caveats. The major axis is only defined up to a sign, so this pins the rotation but not a possible 180-degree flip, and a near-square circuit whose two axes are close in length has no stable long axis to align in the first place. Both are cosmetic. The running order comes from arc length, not from which way the map happens to point.
The Maths, Part Two: Making It Small
The artifact has to be small enough to download fast and sit in memory comfortably, and telemetry is naturally floating point, with speed, throttle, brake and position all arriving as f64 from the source. Storing it raw would more than quadruple the file for precision nobody can see at sixty frames a second.
So each channel is quantised. Position and speed go to i16 with a per-channel scale and offset chosen to fit the channel's actual range symmetrically into the integer space:
offset = (highest + lowest) / 2.0
scale = (span / 2.0) / _I16_HALF_RANGE
raw = np.round((values - offset) / scale)
raw = np.clip(raw, -_I16_HALF_RANGE, _I16_HALF_RANGE).astype(np.int16)
Decoding is the inverse, raw * scale + offset. Because the scale comes from the channel's own range, the reconstruction error is bounded to half a quantum of that range. Invisible on screen, and a quarter of the bytes. Bounded channels that don't need the resolution go further: throttle and brake to a u8 percentage, gear clamped to 0 to 8, DRS to a single bit's worth of u8. The schema carries the scale and offset alongside the values, so the reader needs no out-of-band knowledge to decode.
The Maths, Part Three: Sixty Frames a Second in the Browser
Now the client side, in Rust compiled to WASM. The playback engine runs one clock, and on every tick it asks the core for the state of every car at a given millisecond, and the core has to answer fast enough to hit the frame budget for twenty cars at once.
The samples are timestamped but not on a fixed grid, so "the state at time t" almost never lands exactly on a stored sample. It's a binary search for the bracketing samples followed by a linear interpolation between them:
let lower = last_at_or_before(×, time_ms);
let (upper, fraction) = if lower + 1 >= count {
(lower, 0.0)
} else {
let span = times.get(lower + 1) - times.get(lower);
if span > GAP_MS {
return None;
}
(lower + 1, (time_ms - times.get(lower)) as f32 / span as f32)
};
The GAP_MS guard is the detail that matters. Real feeds drop out, and if two consecutive samples are more than two seconds apart, interpolating between them would slide a car smoothly across a chunk of track it was never on. So beyond that threshold the core refuses to invent data and reports the car as absent rather than lie convincingly. The interpolation itself dequantises both endpoints and lerps between them in one step, so the integer packing from the build stage gets paid back transparently here.
The Maths, Part Four: Who Is Actually Winning
The hardest question turned out to be the simplest to ask. What is the running order? The source data doesn't hand you a clean position-per-car-per-instant you can trust frame by frame, so the order has to be derived from where each car physically is on the track.
That's a projection problem. For each car's (x, y), find the nearest point on the track polyline, and the cumulative arc length up to that point is how far around the lap the car is. Nearest-point-on-a-segment is a clamped dot product per edge:
let t = if length_squared <= 1e-12 {
0.0
} else {
(((point.0 - a.0) * edge_x + (point.1 - a.1) * edge_y) / length_squared).clamp(0.0, 1.0)
};
Run that against the track's cumulative arc-length table, accumulate a lap each time the car crosses the start line, and a noisy cloud of coordinates becomes a single, mostly-monotonic number: total race distance. Mostly, because projection noise can nudge it backward between frames, so the model tolerates small reversals rather than assuming they can't happen. Sort the cars by it and you have the order. A search window keeps the projection local, since a car this frame is near where it was last frame and there's no need to scan the whole circuit each time, the same two-second gap rule prevents a dropout from teleporting a car forward a lap, and once the winner takes the flag the order locks to the official classification so the final result is exact rather than reconstructed. The pretty track map and the timing tower are, underneath, the same arc-length model viewed two different ways.
The obvious objection is why derive the order from positions at all, rather than reading it from timing. Because the timing the source gives you isn't continuous. The structured timing comes per lap: lap start, lap time, sector times, pit in and out, plus a final classification per session. There's no gap-to-the-car-ahead you can resolve at an arbitrary millisecond. A replay you can pause and scrub needs the order as a continuous function of time, and it needs that order to agree with the cars drawn on the map. Position telemetry is the only signal dense and continuous enough. Derive the order from it and the timing tower and the dots on the track can never disagree, because they come from the same samples. The cost is honest. Mid-race gaps are estimates, a fraction of a lap times a representative lap time, and the exact gaps only appear at the finish where the order locks to the official classification. Timing data still does the work positions can't, pinning each car's absolute lap number so a car a lap down sorts correctly even when it's physically ahead on track.
The Bug That Made the Phone Go Dead
Everything above worked on a laptop long before it worked on a phone. On iOS you could play a session and watch it run perfectly, and taps on the telemetry just died. No response, no error, nothing.
The cause wasn't in any of the maths. An Angular effect was rebuilding the telemetry chart on every frame, and at sixty rebuilds a second it was starving the main thread so completely that iOS never got the idle moment it needs to promote a touch into a click. The fix was one word, wrapping the chart rebuild in untracked() so the playback clock stops triggering a reconstruction it was never meant to trigger. The render loop kept running, the main thread got its breath back, and taps came back to life.
It's the most ordinary kind of performance bug, the cost of a thing you're doing far more often than you need to, and a useful reminder that on the client the frame budget is a shared resource and the renderer doesn't own all of it.
What It Reveals
The thing I'd want a reader to take from this isn't the F1 data. It's the shape.
Starting from constraints, cheap and simple and fast, rather than from a stack, forced an architecture where the expensive work happens exactly once and the per-viewer work happens on the viewer's own hardware. A single generated contract made it safe to split that work across Python, Rust and TypeScript without the three drifting apart. And a surprising amount of what looks like product, the recognisable track and the smooth motion and the trustworthy order, is geometry done carefully at the right stage of the pipeline.
The running cost is about a pound a month. There's no server to patch, no scaling to plan and no request path to fall over. The compute didn't disappear; it moved to where it was free, and the contract is what let it move safely.
The mature tools I mentioned at the start got a lot of their polish from having a budget. This got its shape from not having one. That isn't a complaint and it isn't a humblebrag either. It's just the most interesting thing about the project, and the reason it was worth writing down.