Where a Binance book update spends its first two milliseconds
I spent a day in October 2026 with a Rust process parked next to Binance's matching engine, asking one question: when the order book changes, how long until anyone outside can know? The number that matters for a taker strategy is not round-trip time to the API. It is the age of a quote at the moment you could act on it, and that age has several parts that no amount of client-side engineering touches. This post is the latency anatomy, measured, with the parts I could change separated from the parts I could not.
The setup was deliberately boring. One c7i.large in ap-northeast-1, in the availability zone whose id is apne1-az4, chrony reporting a 5 µs offset to its time source. Binance's own clock, checked against the REST and WebSocket time endpoints, sat between −0.5 and +0.3 ms of mine, so every "exchange stamp to my receipt" number below carries that much uncertainty and no more. The process was my own engine: a single-threaded SBE decoder, no allocations on the hot path, decode p99 6–8 µs, decision p99 under 81 µs at two to three thousand events per second. I mention those because every tail you see below belongs to the exchange, not to the client.
Getting next to the engine
Zone first, because it dominates everything else on the client side. Binance's WebSocket and API front-ends answer from one zone of the Tokyo region, and from any other zone you pay a cross-zone hop on every packet.
| target | TCP RTT from apne1-az4 | from apne1-az2 |
|---|---|---|
stream-sbe.binance.com backends | 0.29–0.52 ms | 1.81–2.59 ms |
data-stream.binance.vision backends | 0.37–0.54 ms | 1.79–2.21 ms |
api1 / api3.binance.com, REST keep-alive | 1.8 ms | 3.1 ms |
api.binance.com, REST via the CDN | 7.4 ms | — |
ws-api.binance.com, request on an open session | 1.0 ms (min 0.8) | — |
Two things in that table cost real money if you get them wrong. The hostname everyone copies from the documentation, api.binance.com, goes through a CDN and answers in 7.4 ms; the numbered hosts answer the same request in 1.8 ms, and a request on an open ws-api session in 1.0 ms. And the zone: az2 is four to six times further from the stream backends than az4. AWS shuffles the mapping between zone ids and console letters per account, so resolve the id, not the letter: in my account apne1-az4 is ap-northeast-1a and apne1-az1 is ap-northeast-1c.
For scale, from my desk in Europe the same stream backends are 275 ms away and a quote is 159 ms old at the median when it reaches me. Nothing below is measurable from there.
The clock inside the update
Every SBE frame on the market-data stream carries an eventTime, stamped by the publisher. The trades stream also carries transactTime, stamped by the matching engine at the match. Subscribe to both for the same symbol and you can watch the same event wear two stamps:
// TradesStreamEvent, root block: eventTime i64 @0, transactTime i64 @8
let event_us = i64::from_le_bytes(buf[0..8].try_into()?);
let transact_us = i64::from_le_bytes(buf[8..16].try_into()?);Over a two-hour recording of 67 symbols (1.85 million frames, 237,816 trade events, zero decode errors, zero reconnects) the gap between the two stamps was:
| quantity | p10 | p50 | p90 | p99 |
|---|---|---|---|---|
trade transactTime → eventTime | 191 µs | 294 µs | 465 µs | 3.5 ms |
eventTime of the book update − eventTime of the trade, same match | −1 µs | 1 µs | 277 µs | 808 µs |
| my receipt of the book update − my receipt of the trade | −376 µs | −12 µs | +647 µs | +1,064 µs |
So the first public timestamp is already about 0.3 ms old when it is written. The book update and the trade that caused it are stamped within a microsecond of each other, then travel separately and reach me in either order with a jitter of ±0.4–0.6 ms; the trade frame wins the race 15 % of the time. I had hoped the trades stream might lead the quotes. It does not. Listening to both buys nothing but a second copy of the same moment.
The anatomy of one update, on the engine's clock, then reads like this:
The highlighted row is the one that ended the taker idea, and it belongs to the next post. The rest of this one is about the row beneath it: why a quote reaches a process sitting 0.3 ms from the backend a full millisecond after it was stamped, and whether anything on my side could shorten that.
The publisher floor
The staleness of a book update, eventTime to my receipt, on a single SBE connection carrying the full market at about 2,000 events per second:
| p10 | p50 | p90 | p99 | |
|---|---|---|---|---|
| single connection, 2 h, book updates | 605 µs | 1,001 µs | 1,492 µs | 5,638 µs |
| single connection, 2 h, trades | 597 µs | 1,019 µs | 1,465 µs | 5,261 µs |
| five connections, 15 min, per thread | — | 0.92–1.0 ms | 1.4 ms | 2.5–3.7 ms |
Subtract the 0.15–0.25 ms the packet needs to cross the zone and roughly 0.75 ms is spent inside Binance between stamping the update and writing it to my socket. The 5.6 ms p99 over two hours came from a handful of episodes; the 30-second windows in the log show p99 of 1.6–2.4 ms most of the time. One of those episodes was self-inflicted: a FIX probe sharing the box for ten minutes left p50 unchanged, 956 against 1,007 µs, and thickened the tail to 7.5 ms. Worth knowing if you plan to run two feeds on one small instance.
Is the 0.75 ms a queue I am stuck behind, or a property of the publisher? The way to find out is to open more connections and see whether any of them is consistently early.
What a second connection buys
I raced clones: identical subscriptions over separate connections, every event matched by symbol and update id, the first arrival declared the winner. The JSON bookTicker stream on the 50 most active symbols:
| run | events | win shares | 1st → 2nd gap p50 / p99 / max | 1st → last p50 / p99 |
|---|---|---|---|---|
| 4 clones, distinct backends, 15 min | 811 k | 25 / 40 / 11 / 24 % | 55 µs / 1.8 ms / 9.5 ms | 0.56 ms / 13.1 ms |
| 8 clones, every backend, 15 min | 1.43 M | 4–26 % | 19 µs / 0.5 ms / 3.8 ms | 0.66 ms / 16.8 ms |
| 4 clones, same backend, 7.5 min | 680 k | 50 / 10 / 26 / 14 % | 17 µs / 0.56 ms / 12 ms | 73 µs / 10.5 ms |
The winner changes on 25–42 % of consecutive events. No backend is the fast one. The third row is the control: four connections to the same IP still split the wins 50/10/26/14 and still show a 1 % tail where the last copy arrives 10 ms after the first. The lag is born per connection, at the server's fan-out, not per backend. Picking a "good IP" does nothing; opening a second socket does something.
How much, and does it saturate? Three runs on the same box and day, on the SBE stream: a single connection over the two-hour recording, the first of four clones on 50 symbols, the first of sixteen clones over eight backends on 20 thin symbols:
One connection, the first of four, the first of sixteen: 0.95, 0.88 and 0.655 ms at the median. Sixteen connections over eight backends, each clone individually at 0.79–1.05 ms, none of them consistently early, and the first of them 0.3 ms ahead of a single socket, with a p99 of 1.25 ms against 2.5 ms or worse on one socket. The gain from four to sixteen is small and flattening. Clones buy tail protection and about a quarter of a millisecond; they do not buy an earlier feed, because the variation they exploit is the per-connection part, and the common part, the 0.6 ms that every copy shares, is the publisher itself.
SBE against JSON
Binance offers the same streams as JSON and as SBE. SBE is smaller and carries eventTime with microsecond resolution, so I assumed it would also be first. Two SBE clones raced against two JSON clones for ten minutes: the JSON clones delivered 568 k events each, the SBE clones 332 k. On the events both carried, SBE arrived first 46 % of the time, JSON 54 %, and the loser was 70 µs behind when SBE lost and 200 µs behind when JSON lost. Keyless JSON is not slower.
The missing 42 % is conflation. On SBE bestBidAsk, intermediate updates are dropped per connection, more of them under load; I saw 11 %, 20 % and 42 % of updates missing in three runs. The culled updates are precisely the sub-millisecond re-quotes inside a burst. A simulator fed by SBE therefore sees quotes that live longer than they did, and overstates how often a signal could have been filled. For anything that asks how long a quote survived, use JSON or FIX.
FIX against WebSocket
The one public channel left was FIX market data, fix-md.binance.com on port 9001, text FIX requests with SBE-encoded responses. It is the channel the documentation reserves for serious participants, so the question was whether it is also the faster one. One FIX session and one WebSocket SBE connection subscribed the same 50 book tickers; every update was matched by symbol and update id, since the FIX LastBookUpdateID equals the WebSocket u. A cross-check of the quoted side found zero mismatches on 395 k matched events, so the pairing is exact.
| run 1, 600 s | run 2, 300 s | |
|---|---|---|
| book tickers delivered, FIX / WebSocket | 440 k / 263 k | 222 k / 133 k |
| matched; only on FIX; only on WebSocket | 262 k; 178 k; 52 | 133 k; 89 k; 55 |
| FIX receipt − WebSocket receipt, same event, p10 / p50 / p90 / p99 | −261 / +394 / +1,085 / +2,372 µs | −430 / +95 / +916 / +4,240 µs |
| FIX first | 20 % | 35 % |
FIX sendingTime − WebSocket eventTime, p10 / p50 / p90 | +222 / +662 / +1,126 µs | +166 / +607 / +1,072 µs |
TCP RTT from apne1-az4 | fix-md 0.9–1.4 ms, stream-sbe 0.3–0.6 ms |
FIX is not a faster channel. The same update reaches the process 0.1–0.4 ms later than on WebSocket at the median; when FIX is first, in 20–35 % of events, it leads by at most the 0.4 ms of per-connection jitter the WebSocket side carries anyway. The FIX gateway stamps the event about 0.6 ms after the WebSocket publisher's eventTime and sits a further 0.4 ms away one-way. I checked that the extra distance is not a zone effect by measuring from three zones:
| from | stream-sbe | ws-api | fix-md | fix-oe |
|---|---|---|---|---|
apne1-az4, three instances | 0.3–0.5 ms | 0.3–0.5 ms | 0.9–1.2 ms | 0.9–1.2 ms |
apne1-az1 | 1.9–2.1 ms | 1.8–1.9 ms | 2.4–2.7 ms | 2.3–2.8 ms |
apne1-az2 | 1.8–2.3 ms | 2.2–2.4 ms | 2.2–3.0 ms | 2.3–2.9 ms |
Both FIX gateways are a constant 0.6 ms of round trip beyond the WebSocket backends from every zone: one extra hop behind the same entry point, not a different location. You cannot co-locate with it from outside.
What FIX does do is deliver everything. 40 % of the FIX book updates never appeared on the WebSocket SBE connection, which is the conflation from the previous section seen from the other side. FIX sends one side per message, never drops an intermediate quote, allows 100 sessions per account and 1,000 streams per session. For a recorder or a simulator it is the better data source. For a taker it is 0.1–0.4 ms slower than the thing it was supposed to beat.
Adding it up
Put the pieces on one clock, with the match at zero:
- 0.29 ms: the publisher writes the first public stamp. Hidden from everyone.
- 1.28 ms: the update reaches my process in the best zone, median, after 0.9–1.0 ms of publisher and fan-out and 0.15–0.25 ms of wire. The first of sixteen clones would make it about 1.0 ms; FIX would make it later.
- 1.8 ms: the earliest my order could reach the engine, adding half of the 1.0 ms
ws-apiround trip.
Everything I could change on that path, I had already changed: the zone, the hostname, the number of connections, the encoding, a decoder that costs microseconds. Together they are worth perhaps 0.5 ms against a naive setup in the wrong zone, and nothing against the 0.6 ms that every public channel spends inside the publisher.
And the highlighted row in the timeline: on the same recording, in 81 % of the legs my engine's signals pointed at, a trade printed at that price on that side within 10 ms, at a median of 0.81 ms after the public stamp; measured from the triggering match, the first such print landed at 0.74 / 1.00 / 1.43 ms, p10 / p50 / p90. Somebody acts on these book changes before the public stamp has crossed the room, and does it consistently. Where their market data comes from is a question the public feed cannot answer, but the arithmetic above says what it is not: it is not a better zone, not more sockets, not FIX, not a faster decoder. That story, and what it did to the taker strategy, is the next post.
What I would measure next
Two things I did not do and would, given another day on the box. First, hardware receive timestamps on the NIC, to split the 0.9 ms between Binance's side and the last hop with more confidence than subtracting a TCP round trip. Second, a self-trade: place a resting order, hit it from a second connection, and read the timestamps on the execution report. That measures the one path I never saw, the private one from the engine to an account that was party to the trade, and it is the only way to learn whether a maker hears about a fill before the public feed hears about the quote.