Telemetry

The free data path behind Formula 1's live timing

F1's official live-timing player is fed by a public static file tree plus a SignalR socket, most of it reachable without a key. The recipe from building F1Peak, including the BOMs, the raw-deflate payloads, the per-topic auth, and the 30-minute publication delay.

When I started F1Peak, a game-style viewer for Formula 1 sessions, the first question was where the data would come from. The answer turned out to be hiding in plain sight: the official live-timing player on formula1.com is fed by a static file tree plus a SignalR websocket, and most of it is publicly reachable with no API key at all. I verified the whole path empirically in June 2026 and wrote it into my build records as I went. This post is that recipe, with every trap that cost me time.

I was not first to this territory. My build records name undercut-f1 and FastF1 v3.7.0 as the reference implementations I checked my token-gated realtime work against, and f1-dash.com ran a hosted instance of the live feed until F1 shut it down (more on how later). What follows is what I confirmed myself.

The static tree

Three URLs get you everything for replay:

https://livetiming.formula1.com/static/SessionInfo.json      latest session + its Path
https://livetiming.formula1.com/static/{year}/Index.json     season catalog, 2018 to today
https://livetiming.formula1.com/static/{Path}Index.json      the session's list of 27 feeds

SessionInfo.json points at the current or most recent session; when its ArchiveStatus.Status reads "Complete" the session is archived. The season indexes go back to 2018 (2022's index 404s upstream, an odd gap). Each session offers 27 feeds: positions, telemetry, timing, race control, weather, driver list, and more. Every feed comes in two shapes: a .jsonStream file where each line is a 12-character stream clock (HH:MM:SS.mmm) followed by JSON and \r\n, and a keyframe .json holding the final state. Team radio clips are plain mp3 files under the same session path, and they play cross-origin.

No auth on any of it. That single fact is why F1Peak has full replay coverage of every session back to 2018 without an account.

Trap one: the byte order mark

Every file in the tree starts with a UTF-8 BOM. JSON.parse dies on it. Decode as utf-8-sig or strip the marker before parsing, always.

This trap bites twice. F1Peak's live path tail-polls files with HTTP Range requests, and when the range starts at offset 0 the BOM is inside the bytes you get back. The first line of a feed is its full initial state, so an unstripped BOM there means that line silently fails to parse and everything downstream is subtly empty. My live-mode test harness caught it as drivers=0: a session with no drivers, no error, no obvious cause. Strip the BOM at the byte layer, not just when reading whole files.

Trap two: the .z topics

The two feeds that actually draw cars, Position.z and CarData.z, are compressed. The payload on each line is a JSON string containing base64 of raw-deflate data. Raw deflate, not gzip: the distinction matters because the stream has no gzip header, so the usual decompressors reject it.

const inner = JSON.parse(line);                     // a JSON *string*
const buf = Buffer.from(inner, "base64");
const data = JSON.parse(zlib.inflateRawSync(buf));  // raw deflate, NOT gunzip

Decoded, each line batches roughly ten samples keyed by racing number. The CarData channels map as 0 = RPM, 2 = speed, 3 = gear, 4 = throttle, 5 = brake. The 2026 cars publish no channel 45, which was DRS, so a viewer built on this feed has to live without a DRS flag. This decoded stream is what places every car on the track map:

F1Peak showing a Monaco race session with cars on the track map

One more trap inside the timing feeds: they are partial deltas with SignalR semantics. You deep-merge objects, arrays arrive patched as index-keyed objects, and "_kf" keys should be ignored. Clone the patch arrays before storing them into replayed state. I did not at first, and scrubbing backwards in the replay corrupted the event log, because the merged state and the log were sharing array references.

The socket that looked dead

My first decision on realtime, recorded in the project log as D1, was that the SignalR websocket was locked: livetiming.formula1.com/signalr/negotiate returns 401 even with the classic client headers. I built the whole project around the static tree on that basis.

The conclusion was wrong by exactly one endpoint. F1 migrated the socket at the Monaco GP in May 2025, so the classic /signalr endpoint is simply dead. Its replacement negotiates fine with no credentials:

POST https://livetiming.formula1.com/signalrcore/negotiate?negotiateVersion=1   -> 200, no auth

It is ASP.NET Core SignalR. Connect to the Streaming hub, send the handshake {"protocol":"json","version":1} terminated with the 0x1E separator, then invoke Subscribe with an array of topic names. Carry the AWSALBCORS sticky cookie from the negotiate response onto the websocket or the connection lands on the wrong backend.

The catch is that gating is per-topic, and has been since around August 2025. The timing tower, laps, race control, weather, track status and driver list all stream anonymously. Position.z and CarData.z (plus ChampionshipPrediction and PitLaneTimeCollection) only deliver with a Bearer subscriptionToken tied to an active F1 TV subscription; a free F1 account gets rejected because its subscription status is not active. I pay for F1 TV, and F1Peak simply reuses my own account's session token: after I log in normally in my own browser, the login-session cookie (URL-encoded JSON) carries a data.subscriptionToken field, and that JWT is what the app presents for the topics my subscription already entitles me to watch. It expires after four to seven days, so pasting it in every few days is the honest UX for a personal, single-account tool. Nothing here shares, resells or bypasses access; it is my subscription, consumed by my own viewer instead of F1's.

The thirty-minute finding

During a live session, the free static tree is not merely stale. The session directory does not exist. I tested this hard during Barcelona FP2: a poller hit the session directory every 45 seconds for the entire session and got 403 the whole way through. Then every file appeared in one batch at 16:30:56 UTC, all Last-Modified within the same second, roughly 30 minutes after a session that ran 15:00 to 16:00.

So the free feed is not live timing at all; it is a full-session recording published about half an hour after the chequered flag. F1Peak treats it that way: during a session it says so plainly, polls session status every 20 seconds, and auto-opens the full replay the moment the feed lands. The Range-polling live reader stays in the codebase, verified against real data, with simple semantics (416 means no new bytes yet, 403 on the directory means not published). If F1 ever publishes mid-session again, the app upgrades itself with zero changes.

Why there is no hosted F1Peak

F1 IP-blocks datacenter and hosting IPs on this feed. That is what killed f1-dash.com's hosted instance. Residential and local clients are fine, which is exactly why F1Peak runs from a residential connection and cannot be deployed to any normal host. It is the strangest constraint in the whole recipe: the data is free, the protocol is open, and the one thing you cannot do with it is put a server in front of it.

The end state has three transports feeding one client: anonymous SignalR Core for the live tower, the token-gated topics for live positions and telemetry, and the static tree for replay and as the fallback. All three speak the same bundle contract, so the playback engine cannot tell them apart. Everything above sits in my build records with dates and evidence, which is the only reason I can write it down now without guessing.

← All devlog stories