/sse/reserves around the clock. One lesson cost us a night of data: treating a transient “stream limit exceeded” error as fatal silenced 8 pools until morning. The patterns below exist so you don’t repeat it.
If you haven’t streamed reserves before, start with the drain detection guide. This one assumes you have a working consumer and need it to survive real networks, real restarts, and real error states.
The five rules
| Rule | Why it exists |
|---|---|
| Reset backoff only after a parsed event | Proxies can return HTTP 200 and close instantly. Resetting on 200 turns backoff into a 1-second hammer. |
| Treat 30s of silence as a dead connection | Pings arrive every ~15s. A socket can die without an error ever surfacing. |
| State self-heals, ledgers don’t | Every event carries absolute reserves. After a gap you’re current again, but the per-block change log has a hole. Detect it with previous_block. |
| Classify errors before retrying | ”Pool not found” never fixes itself. “Stream limit exceeded” always does. Retrying the first wastes a connection slot forever; abandoning the second loses a working subscription. |
| Multiplex deliberately | 25 subscriptions per connection, 10 connections per IP, and one invalid entry rejects the whole batch. |
Rule 1: Trust events, not status codes
A reconnect loop usually looks like: connect, reset backoff, consume, on failure wait and retry with doubled delay. The subtle bug is where the backoff resets. If it resets when the HTTP response arrives, any failure mode that returns200 OK and then closes (load balancer deploys, buffering proxies, overload shedding) defeats the exponential entirely: every cycle reconnects at the floor delay, forever, from every client you’ve shipped.
Reset backoff only after the first parsed SSE frame. A ping counts; it proves the stream actually streams.
Rule 2: Watch the heartbeat
The server sends aping every ~15 seconds. If nothing (events or pings) arrives for 30 seconds, assume the socket is dead even though no error fired, and reconnect. In JavaScript that’s a small watchdog timer that aborts the fetch. In Python, requests gives you this for free: set the read timeout to 30 seconds and the staleness check becomes an exception your retry loop already handles.
Rule 3: State self-heals, ledgers don’t
This is the property that makes reserve streaming forgiving in production. Everypool_reserves event carries absolute values: total_reserve_usd is the pool’s liquidity right now, not a cumulative sum you must maintain. Disconnect for ten minutes, reconnect, and your first event makes your state current again. There is nothing to resync and no REST snapshot to fetch.
What does not self-heal is the change log. If you aggregate deltas over time (net flow over a window, cumulative volume of liquidity changes), the blocks you missed are holes in that ledger. The stream gives you exactly what you need to know when that happened: each event’s previous_block names the last block in which the pool’s reserves changed. If it doesn’t match the block of the previous event you received, you missed something.
previous_block chains event to event, not block to block. Pools only emit
when reserves change, so consecutive events can legitimately be many chain
blocks apart while previous_block still matches perfectly. A mismatch
means missed events, not quiet markets.Rule 4: Classify errors before retrying
Two error channels exist, and both carry a mix of permanent and transient conditions. Before the stream starts, a bad request gets an HTTP error with a JSON body. Mid-stream, the server sends anerror event and closes the connection. Either way, read the message and split:
| Error | Channel | Class | Correct response |
|---|---|---|---|
unsupported chain: … | HTTP 400 or error event | Permanent | Fix the subscription. Retrying can never succeed. |
pool not found: … / asset not found | HTTP 400 or error event | Permanent | Same. The message names the bad entry. |
too many subscriptions | HTTP 400 | Permanent | Your batch exceeds 25. Chunk it. |
ip stream limit exceeded | HTTP 429 or error event | Transient | Back off and retry. A slot will free up. |
| 5xx, network failures, timeouts | HTTP / socket | Transient | Back off and retry. |
Rule 5: Multiplex deliberately
The limits: 25 subscriptions per POST connection, 10 concurrent streams per IP. That’s 250 pools from one machine, if you chunk correctly.- Route events to subscriptions with
request_id: it’s the index of the entry in your POSTed array. - Keep a fallback route on
(chain, pool_id/token_id)for defensive code; both fields are in every payload. - Chunk watchlists into groups of 25 and run one consumer loop per chunk. Let chunks fail independently: a permanent error in one chunk shouldn’t stop the other nine.
- Mixing chains and both methods (
pool_reserves,token_reserves) in one connection works fine.
The full client
Both versions implement all five rules and were run against production as written. Bad-address handling verified live: the client exits with the server’s message instead of looping.Don’t alert on plumbing
If your production consumer feeds alerts, remember that liquidity moves for boring reasons too. JIT liquidity bots on major pools add and remove six-figure positions around single swaps; we’ve measured ±4% of an $8M pool every block. Threshold accordingly, or net deltas over a window so the add/remove cycles cancel. The drain detection guide covers thresholds that survive this.Monitor the monitor
A streaming consumer that silently stops is worse than one that crashes. The minimum viable health surface, all derivable from this guide’s client:- Last event age: if
lastActivityis older than a minute, something is wrong even if the process is alive. - Per-chunk status: which subscriptions are live, which died fatally and why (keep the server’s error message; it names the bad entry).
- Gap count: how many ledger holes you’ve detected since start.
FAQ
Do I need to resync from REST after a disconnect?
Do I need to resync from REST after a disconnect?
Not for state. Every event carries absolute reserve values, so your first event after reconnecting makes you current. You only need REST backfill if you maintain a gapless per-block ledger; detect the holes by comparing each event’s previous_block to the last block you saw.
Is there Last-Event-ID resume support?
Is there Last-Event-ID resume support?
No. The stream has no replay; a reconnect starts fresh from the next change. That’s why the absolute-values property matters: it makes resume-from-now safe for state tracking.
How many pools can one machine watch?
How many pools can one machine watch?
250: 25 subscriptions per POST connection, 10 concurrent streams per IP. Beyond that, distribute across machines or egress IPs.
What happens to my other subscriptions when one entry is invalid?
What happens to my other subscriptions when one entry is invalid?
The whole connection is rejected with one error event naming the bad asset. Drop that entry and resubscribe the rest. Validating addresses against REST before subscribing avoids the round trip.
Next steps
Detect Liquidity Drains
The detection logic this guide keeps alive: thresholds, JIT noise, alerts.
Reserve Streaming Overview
Payload schemas, both subscription methods, limits.
Error Handling Reference
The complete error catalog for REST and streaming.
LiquidityRadar on GitHub
All five rules as a deployable, open-source Cloudflare Worker.
Get support
Join Discord
Connect with our community and get real-time support.
Give Feedback
Share your experience and help us improve.