Skip to content
← Writing

Where a Binance book update spends its first two milliseconds

Oct 13, 2026 · 14 min readTradingLatencyRust

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.

0.29 ms
engine → public stamp, p50
0.9 ms
stamp → receipt, p50, every channel
1.0 ms
crowd acts after the match, p50

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.

targetTCP RTT from apne1-az4from apne1-az2
stream-sbe.binance.com backends0.29–0.52 ms1.81–2.59 ms
data-stream.binance.vision backends0.37–0.54 ms1.79–2.21 ms
api1 / api3.binance.com, REST keep-alive1.8 ms3.1 ms
api.binance.com, REST via the CDN7.4 ms—
ws-api.binance.com, request on an open session1.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:

rust
// 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:

quantityp10p50p90p99
trade transactTime → eventTime191 µs294 µs465 µs3.5 ms
eventTime of the book update − eventTime of the trade, same match−1 µs1 µs277 µs808 µ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:

00.511.52 msmatching engine changes the book0 mspublisher stamps eventTime0.29 msthe crowd’s take lands1.0 msa trade at the signalled price, on the signalled side; measured in the next postthe update reaches my process1.28 msp50 measured match to receipt; p10 and p90 are the stamp gap plus the staleness quantilesmy fastest order could land1.8 msreceipt plus half of the 1.0 ms ws-api round trip
Hover or tab through a row for its p10 / p50 / p90.
One book update on the engine clock, medians with p10–p90 ranges — Tokyo apne1-az4, 2026-10-12

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:

p10p50p90p99
single connection, 2 h, book updates605 µs1,001 µs1,492 µs5,638 µs
single connection, 2 h, trades597 µs1,019 µs1,465 µs5,261 µs
five connections, 15 min, per thread—0.92–1.0 ms1.4 ms2.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:

runeventswin shares1st → 2nd gap p50 / p99 / max1st → last p50 / p99
4 clones, distinct backends, 15 min811 k25 / 40 / 11 / 24 %55 µs / 1.8 ms / 9.5 ms0.56 ms / 13.1 ms
8 clones, every backend, 15 min1.43 M4–26 %19 µs / 0.5 ms / 3.8 ms0.66 ms / 16.8 ms
4 clones, same backend, 7.5 min680 k50 / 10 / 26 / 14 %17 µs / 0.56 ms / 12 ms73 µ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:

00.511.522.53 msp10p50p90p99eventTime → receipt, mssingle connection p10: 0.6 mssingle connection p50: 1.0 mssingle connection p90: 1.49 mssingle connection p99: 5.64 ms
single connection
Hover a marker for its value.
Staleness, eventTime → receipt, of the first copy to arrive — SBE bestBidAsk, Tokyo apne1-az4, 2026-10-12

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 srun 2, 300 s
book tickers delivered, FIX / WebSocket440 k / 263 k222 k / 133 k
matched; only on FIX; only on WebSocket262 k; 178 k; 52133 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 first20 %35 %
FIX sendingTime − WebSocket eventTime, p10 / p50 / p90+222 / +662 / +1,126 µs+166 / +607 / +1,072 µs
TCP RTT from apne1-az4fix-md 0.9–1.4 ms, stream-sbe 0.3–0.6 ms
01,0002,000 µsp10p50p90p99FIX receipt − WebSocket receipt, µsrun 1, 600 s p10: −261 µsrun 1, 600 s p50: 394 µsrun 1, 600 s p90: 1,085 µsrun 1, 600 s p99: 2,372 µsrun 2, 300 s p10: −430 µsrun 2, 300 s p50: 95 µsrun 2, 300 s p90: 916 µsrun 2, 300 s p99: 4,240 µs
run 1, 600 srun 2, 300 s
Hover a marker for its value.
Arrival of the same book update on FIX relative to WebSocket SBE; positive means FIX was later — Tokyo apne1-az4, 2026-10-12

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:

fromstream-sbews-apifix-mdfix-oe
apne1-az4, three instances0.3–0.5 ms0.3–0.5 ms0.9–1.2 ms0.9–1.2 ms
apne1-az11.9–2.1 ms1.8–1.9 ms2.4–2.7 ms2.3–2.8 ms
apne1-az21.8–2.3 ms2.2–2.4 ms2.2–3.0 ms2.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-api round 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.