rh-feed-speed is a WebSocket block feed latency comparison tool. It connects to multiple WebSocket block sources at the same time, identifies the same block across sources, and compares their block arrival times.
For every block received by all configured sources, rh-feed-speed reports which source received the block first and how much later the other sources received it. The program also generates a latency percentile summary every 5 minutes and exposes JSON summary and Prometheus metrics endpoints.
rh-feed-speed can be used to:
- Compare block arrival times across multiple WebSocket sources.
- Identify which source received each block first.
- Measure each source’s latency relative to the winning source.
- Calculate p10, p50, p75, p90, p95, p99, and p99.9 latency percentiles.
- Count the number of wins for each source.
- View rolling 5-minute statistics through logs or JSON.
- Export source win counters in Prometheus format.
A block is included in the completed-block statistics only after it has been received by all configured sources.
- Concurrent connections to multiple WebSocket block sources.
- Configurable
-source name=urlarguments. - Source-specific subscription messages.
- Block matching by
blockHash. - Fallback matching by
block:<blockNumber>. - Per-block latency and winner output.
- Automatic 5-minute percentile summaries.
- JSON summary endpoint.
- Prometheus metrics endpoint.
- Configurable record retention through
-dedup-ttl. - Debug output for message parsing errors.
- Snapshot-based summary calculation to reduce time spent holding the tracker lock.
At least one -source name=url argument is required. In practice, two or more sources are usually needed to compare block arrival latency.
go run . \
-source official=wss://feed.mainnet.chain.robinhood.com \
-source feeder=wss://us.robinhood-feeder.blockrazor.io/ws/{authToken}Each source follows this format:
-source name=websocket_url
The source name is used in block details, latency summaries, and Prometheus metric labels.
After startup, the program first prints a header:
[block] sequence_number block_hash fast slow winner
When the same block has been received by all configured sources, the program prints one detail line:
[block] 12345 0xabc... 0s 18ms fast
The fields mean:
| Field | Description |
|---|---|
sequence_number |
Sequence number parsed from the WebSocket message. |
block_hash |
Block hash used to identify the block. |
| Source column | Source latency relative to the first source that received the block. |
winner |
Source that received the block first. |
In this example, fast received the block first. The slow source received the same block 18ms later.
The program automatically outputs a latency summary every 5 minutes:
[summary] type incremental window 5m0s completed_blocks 120
[summary] type incremental source fast winner 80 p10 0s p50 0s p75 2ms p90 5ms p95 8ms p99 20ms p99.9 30ms max 35ms
The summary contains:
| Metric | Description |
|---|---|
window |
Time window used for the summary. |
completed_blocks |
Number of blocks fully compared in the window. |
winner |
Number of blocks first received by the source. |
p10 |
10th-percentile latency. |
p50 |
Median latency. |
p75 |
75th-percentile latency. |
p90 |
90th-percentile latency. |
p95 |
95th-percentile latency. |
p99 |
99th-percentile latency. |
p99.9 |
99.9th-percentile latency. |
max |
Maximum recorded latency. |
Each percentile value represents that source’s latency relative to the source that received the corresponding block first.
Request the current summary in JSON format:
curl http://127.0.0.1:9092/summaryThe /summary endpoint currently returns a 5-minute window summary using:
SummaryWithPercentiles(now, 5*time.Minute)Prometheus metrics are exposed at:
curl http://127.0.0.1:9092/metricsThe currently available metric is:
speed_test_source_wins_total{source="xxx"} 80
This counter records how many fully compared blocks were first received by the specified source.
Both the automatic 5-minute summary and the /summary endpoint call:
SummaryWithPercentiles(now, 5*time.Minute)The implementation generates the summary as follows:
- Copy a snapshot of the current tracker records.
- Release the tracker lock.
- Calculate winner counts and latency percentiles from the snapshot.
This avoids holding the tracker lock during the complete summary calculation.
The window fields are calculated as follows:
completed_blocksis the number of fully compared blocks in the window.winner_counts[].countis the number of wins for each source.winner_counts[].percentilescontains the latency percentiles for each source.
Window statistics are based only on records still retained in the tracker. The -dedup-ttl setting therefore directly affects how much data the summary can inspect.
To inspect a complete 5-minute window, set -dedup-ttl to at least 5m. For example:
go run . \
-dedup-ttl 10m \
-source official=wss://feed.mainnet.chain.robinhood.com \
-source feeder=wss://us.robinhood-feeder.blockrazor.io/ws/{authToken}With the default 30s TTL, the 5-minute summary can only count records from roughly the most recent 30 seconds that have not already been cleaned up.
Recommended relationship:
summary window: 5m
dedup TTL: >= 5m
The tracker supports two summary scopes:
| Method | Summary scope |
|---|---|
SummaryWithPercentiles(now, 5*time.Minute) |
Uses retained records from the 5-minute window. |
Summary(now, 0) |
Uses accumulated WinCount and DelaySamples since the process started. |
The automatic logs and HTTP /summary endpoint currently use the 5-minute window summary.
Incoming messages are currently parsed according to this JSON structure:
{
"version": 1,
"messages": [
{
"sequenceNumber": 12345,
"blockHash": "0xabc...",
"message": {
"message": {
"header": {
"blockNumber": 100
}
}
}
}
]
}The parser reads these fields:
| JSON field | Usage |
|---|---|
messages[].sequenceNumber |
Sequence number displayed in block output. |
messages[].blockHash |
Primary ID used to match the same block across sources. |
messages[].message.message.header.blockNumber |
Fallback block number when no hash is available. |
If a WebSocket message does not contain a recognizable block, it is skipped. When -debug is enabled, parsing errors are printed.
Tracker.RecordBlock identifies blocks using the following order:
- Use
blockHashas the block ID. - If no block hash is available, use
block:<blockNumber>. - If the message does not contain a recognizable block, skip it.
The first source to record a block becomes the winner. Delays for later sources are calculated as:
now - FirstSeenAt
The block is marked as completed after all configured sources have received it.
The main program flow is implemented in main.go:
- Parse source, subscription message, metrics, and TTL arguments.
- Start one WebSocket goroutine for each source.
- Send the corresponding
-source-subscribemessage after the WebSocket connects. - Parse
messages[].blockHash,sequenceNumber, andblockNumber. - Pass the block to
Tracker.RecordBlock. - Use the block hash as the block ID or
block:<blockNumber>as the fallback. - Record the first source as the winner.
- Calculate the delays for later sources.
- Mark the block as completed after every source has received it.
- Print the block details.
- Increment the winner’s Prometheus counter.
- Store records in a min-heap ordered by
FirstSeenAt. - Index records by block ID for fast lookup.
- Continuously remove expired records from the heap head.
- Copy a snapshot before calculating the summary and release the lock.
rh-feed-speed is a program that compares the arrival time of the same block across multiple WebSocket block sources.
At least one source is required to start the program. Two or more sources are usually needed to compare latency.
The first configured source to receive a block is recorded as the winner. Other source delays are calculated relative to that first arrival.
A block is counted as completed after it has been received by all configured sources.
The summary can only use records still retained by the tracker. If -dedup-ttl is shorter than five minutes, older records may be removed before the summary is calculated.
Use at least -dedup-ttl 5m when the complete five-minute summary window is required. The original example uses -dedup-ttl 10m.
If a block number is available, the tracker uses block:<blockNumber> as the block ID. Otherwise, the message is skipped.
The current metric is speed_test_source_wins_total, which counts wins for each source.
Enable -debug to print message parsing errors.