Build1 publisher3 min readPublished
The polling fix for a dead WebSocket only reads ten blocks every fifteen minutes
A developer's Aave liquidation monitor went silent five minutes into a free-tier Alchemy WebSocket. The getLogs loop written to replace it samples ten blocks on a fifteen-minute timer, so most blocks are never read.
The Engineer · Build desk

What happened
- The replacement calls getLogs from currentBlock minus nine to currentBlock, driven by a setInterval of fifteen minutes.
- A separate price bot hitting CoinGecko for five tokens every fifteen minutes, 480 calls a day, started returning 429s after a day and was cut to two tokens every two hours.
- Two days in, Supabase inserts to price_history began failing row-level security with the anonymous key, and both bots stopped writing data without announcing it.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Bounded-range polling only sees what the range covers: ten blocks per call at 900-second spacing needs a 90-second block time to be complete, so the interval and the window have to be sized together.
- decision Anyone lifting this loop has to choose where the cursor lives, because deriving fromBlock from currentBlock makes every restart and every slow tick a permanent gap.
- exposure Both outages here presented as absent rows. A monitor with no liveness check reports a quiet market when it is actually dead.
- contradiction The rate-limit numbers in the post do not add up to the 429s it explains, so the real ceiling on the free CoinGecko tier is still unidentified.
`setInterval(pollLiquidations, 15 * 60 * 1000)` fires every 900 seconds. Each call requests logs `fromBlock: currentBlock - 9` to `toBlock: currentBlock`, which is ten blocks inclusive [3]. For ten blocks to cover the interval, the chain would have to produce one block every 90 seconds [12]. Unless it does, each poll reads the tail of the window and skips the blocks in between.
The range is derived from the wrong variable. `currentBlock` is where the chain is now; what the loop needs is where it stopped. Persist the last processed block, request `fromBlock: last + 1` to `toBlock: currentBlock`, cap the span so a long outage does not turn into one enormous `getLogs` call, and advance the high-water mark only after the handler returns. That costs one row of state, and a restart turns into a catch-up.
The post says little about the listener, and what it says is a familiar failure. It reports that `provider.on(filter, ...)` worked for about five minutes on free-tier Alchemy and then stopped with no error message and no recovery [1], and attributes it to providers timing out idle WebSocket connections with no reconnect logic on the free tier [2]. It does not cite a timeout value from any provider's documentation. Nothing was thrown, so the process stayed up and simply stopped seeing liquidations.
The rate-limit story does not survive its own numbers. The post gives 5 tokens x 96 checks a day = 480 calls, against a stated free tier of roughly 5 requests per second with daily limits after 50 concurrent calls per minute [5][6]. Spread over a day, 480 calls average a third of a call per minute [14], and each burst is five parallel requests. Neither figure reaches the ceilings it cites, and the post never identifies which limit returned the 429s. Cutting to 2 tokens every 2 hours brought the load to 24 calls a day [7], a twentyfold reduction [13] that works whatever the real cause was.
The Supabase failure arrived two days in and looked the same from outside: inserts to `price_history` began violating the row-level security policy and both bots stopped writing [8]. The published remedy is `ALTER TABLE price_history DISABLE ROW LEVEL SECURITY`, with a policy given as the alternative, `CREATE POLICY "Enable insert for all" ON price_history FOR INSERT WITH CHECK (true)` [9]. The author wrote that "RLS is security theater on hobby projects" [10]. I would take the policy line instead. It costs one statement, it is recorded in the schema where the next reader will find it, and the day the project has a second user the table is not already open.
The fix I would copy unchanged is the tmux one. `tmux capture-pane -t price-alert -p` prints a session's last output without attaching, which removes the stuck state that follows when Ctrl+B then D fails to register on a mobile on-screen keyboard [11].
What to watch
- An idle-timeout value published for free-tier Alchemy WebSockets would let a heartbeat replace guesswork about when the socket dies.
- Clarification of whether CoinGecko's free tier limits per-minute concurrency or a monthly total decides whether serialising five calls is enough.
- A follow-up showing a liquidation the polling bot caught, checkable against chain history for the blocks it skipped.