Your filter poll returns filter not found after working for hours. The ID lived in one node's memory. A restart, idle timeout, or load balancer move erased it. Repeating the same poll cannot recover the logs you missed.
eth_newFilter creates that node-local state, and eth_getFilterChanges drains new results on each poll. This guide shows how to detect a lost filter, backfill the gap with eth_getLogs, and resume without processing logs twice. It also compares polling costs and client idle timeouts.
For range limits and backfill pricing, see eth_getLogs pricing per 10,000-block call. For push delivery over WebSocket, see eth_subscribe reconnect, backpressure, and costs.
The filter lifecycle in six methods
Filters are server-side, stateful, and node-local. The node keeps each filter in memory with its criteria, results, and idle deadline. The chain does not store filter IDs. A provider must add its own sharing layer if polls can reach different nodes.

| Method | Parameters | Returns | Production notes |
|---|---|---|---|
eth_newFilter | fromBlock, toBlock, address, topics | Filter ID | Polling returns logs from blocks the node processes after creation |
eth_newBlockFilter | None | Filter ID | Polling returns new block hashes |
eth_newPendingTransactionFilter | None (Geth accepts an optional full-transaction flag) | Filter ID | Mempool view of one node; not in Infura's filter method list |
eth_getFilterChanges | Filter ID | Logs or hashes since the last poll | Drains the buffer and resets the idle timer |
eth_getFilterLogs | Filter ID | All logs matching the filter's full criteria | A range query, priced like eth_getLogs on weighted plans |
eth_uninstallFilter | Filter ID | true or false | false means the node no longer had the filter |
Three details from the go-ethereum filter API source matter for production code:
- The
eth_newFilterhandler "cannot be used to fetch logs that are already stored in the state." AfromBlockin the past does not makeeth_getFilterChangesreplay history. Useeth_getFilterLogsoreth_getLogsfor that. - Geth rejects
pendingas a block bound withpending logs are not supported, and accepts at most 4 topic positions. - When a reorg removes logs that the filter already returned, Geth returns them again with
removed: true.
In Geth, only eth_getFilterChanges resets the idle deadline. eth_getFilterLogs reads the filter's criteria but does not touch the timer. Erigon's eviction change resets the deadline on both.
Filter IDs also behave differently by client. Geth returns random hex IDs. Nethermind's filter store assigns sequential integers from a counter that starts over when the process restarts, so a stale ID from before a restart can match a filter created afterward.
What a poll costs compared with eth_getLogs
Filters look cheap because each poll is small. Whether they are cheaper depends on how your provider weights methods. Per-call weights, taken from each provider's published tables:
| Method | Dwellir | Alchemy (CU) | QuickNode, Ethereum (credits) | Infura (credits) |
|---|---|---|---|---|
eth_newFilter | 1 | 20 | 20 | 80 |
eth_getFilterChanges | 1 | 20 | 20 | 140 |
eth_uninstallFilter | 1 | 10 | 20 | 80 |
eth_getFilterLogs | 1 | 60 | 20 | 255 |
eth_getLogs | 1 | 60 | 20 | 255 |
eth_blockNumber | 1 | 10 | 20 | 80 |
Sources: Dwellir rate limits and request counting, Alchemy compute unit costs, QuickNode API credits, and Infura's per-method pages for eth_getFilterChanges and eth_newFilter. Infura's filter methods draw on a daily credit balance.
Now compare two watchers for one contract, both polling every 2 seconds for a 30-day month:
- Filter loop: one
eth_getFilterChangesper poll. 30 × 86,400 / 2 = 1,296,000 calls. - Cursor loop: one
eth_blockNumberper poll, plus oneeth_getLogsper new block. Ethereum uses 12-second slots, so a month has at most 30 × 86,400 / 12 = 216,000 blocks.
| Provider | Filter loop: monthly cost* | Cursor loop: monthly cost* |
|---|---|---|
| Dwellir | $2.54 | $2.96 |
| Alchemy | $13.61 | $13.61 |
| QuickNode | $15.88 | $18.52 |
| Infura | $20.16 | $17.64 |
* Allocated share of Dwellir Developer ($49/25M responses monthly), QuickNode Build ($49/80M credits monthly), and Infura Developer ($50/15M credits daily) over 30 days. Alchemy Pay As You Go bills $0.525/1M CU. Subscriptions still bill their full monthly fee.
The arithmetic points to three conclusions:
- On flat per-response pricing, the two loops cost almost the same. Pick the one that fails more safely. That is usually the cursor loop.
- On weighted pricing, the gap is smaller than the per-call weights suggest. A cursor loop that polls
eth_blockNumberand callseth_getLogsonly when the head moves matches the filter loop's CU use on Alchemy and consumes fewer daily credits on Infura. - Polling faster than the block time buys latency, not data. Polling every 12 seconds instead of every 2 cuts the filter loop to 216,000 calls a month, one sixth of the figures above.
Recovery is the cost the table leaves out. Every lost filter costs at least one eth_newFilter, one eth_blockNumber, and one eth_getLogs backfill: 3 credits on Dwellir, 90 CU on Alchemy, 60 credits on QuickNode, and 415 credits on Infura. eth_getFilterLogs is not a cheaper backfill. Alchemy and Infura price it the same as eth_getLogs.
Idle timeouts by client
Every major execution client evicts filters that nobody polls. The defaults differ, so the safe polling interval depends on what runs behind the URL.
Call web3_clientVersion with params: [] to inspect the execution client behind an RPC endpoint. The response may identify a client such as Geth. A provider proxy can replace that response or route later calls to another backend.
| Client | Default idle timeout | Configurable | Notes |
|---|---|---|---|
| Geth | 5 minutes | No CLI flag | The cleanup loop runs every 5 minutes, so an idle filter disappears somewhere between 5 and 10 minutes |
| Erigon v3.7.0 | 5 minutes | --rpc.subscription.filters.timeout, 0 disables | Buffers up to 10,000 logs per filter and drops the oldest when full |
| Reth | 5 minutes | No CLI flag; stale_filter_ttl in EthConfig | Returns filter not found with code -32000 |
| Nethermind | 15 minutes (900,000 ms) | JsonRpc.FiltersTimeout | Cleanup runs every 5 minutes, so removal lands between 15 and 20 minutes |
Sources: go-ethereum filter system defaults, Erigon filter config, Reth RPC defaults, and Nethermind configuration reference.
Plan for the shortest value. A 4-second poll is far inside every timeout above. A backoff that grows past 5 minutes during a provider incident outlives the filter on Geth, Erigon, and Reth, so cap it.
The buffer is also a memory commitment on the node. Geth appends every matching log to the filter until you poll. Erigon caps the buffer at 10,000 entries and silently discards the oldest, so a slow poller on a busy contract loses logs without seeing an error.
Load balancers and sticky routing
Most RPC endpoints sit in front of several nodes. A filter created on node A is invisible to node B. If the poll lands on B, you get filter not found a few seconds after creation, with no timeout involved. QuickNode's support article describes both paths: filters expire after 5 minutes without a poll, and a node that falls behind the tip is dropped from the cluster, taking its filters with it.
Providers handle this in three ways:
- Make a filter ID pollable from any connection. Infura documents that any connection using the same API key can poll a filter ID.
- Offer sticky sessions. Dwellir's sticky sessions use a
DWSESSIONcookie: the first HTTP response sets it, and requests that send it back reach the same backend node for up to 7 days. The pinning is best effort. If that node falls behind, the load balancer moves you and sets a new cookie, and your filters stay on the old node. - Do nothing, which makes filters unreliable at any poll rate.
Sticky sessions only work if your client keeps cookies. curl needs a cookie jar, and Node's built-in fetch, which viem and ethers use by default, does not store cookies. WebSocket connections are pinned to one node for their lifetime, so filter calls over a socket need no cookie.
If you run your own node pool, filter methods need session affinity or a shared filter service. eth_getLogs needs neither, because any synced node can answer it.
On the client side, viem's fallback transport pins filter polling to the transport that created the filter, according to its filter request scoping source. That pins the provider URL, not the node behind it.
When eth_subscribe or eth_getLogs is the better tool
| Workload | Use | Why |
|---|---|---|
| Low-latency events on a persistent connection | eth_subscribe (logs, newHeads) | Push delivery; no poll traffic between events |
| Indexers, backfills, anything that must be replayable | eth_getLogs with a block cursor | Stateless, works on any node, same result for the same range |
| HTTP-only clients watching a few contracts | Filters, with the recovery loop below | Small polls, no socket to manage |
| Liveness checks | eth_newBlockFilter or newHeads | A healthy block filter returns hashes after every block |
WebSocket subscriptions have the same node-local weakness. They die with the connection and need their own backfill with eth_getLogs. The eth_subscribe recovery post covers that path, including cost estimates for notifications and recovery requests.
Failure modes and how to handle them
A stale or unknown filter ID
Geth, Reth, Erigon, and Nethermind all return a filter not found message for an unknown ID on eth_getFilterChanges. The error codes differ. In our checks against three public Ethereum endpoints on 29 September 2026, the same message came back with codes -32000, 3, and -32602. Match on the message, treat it as "filter gone," and rebuild. Do not retry the same ID.
Empty arrays: normal or dead
[] from a log filter is normal when the contract emitted nothing since the last poll. It is suspicious when a busy contract returns [] for minutes, or when a block filter returns [] after several blocks. A healthy block filter always has new hashes after a block.
In the same checks, one endpoint returned an empty array from a block filter after 4 minutes of idle time, about 20 slots, instead of an error. Another endpoint's block filter, checked over the same 4 minutes, returned 19 hashes. Nethermind's sequential IDs can produce the same symptom after a restart: your stale ID matches someone else's new filter. Erigon's full buffer produces a partial version: data, but not all of it.
The defense is to treat eth_getLogs as the source of truth. If a busy filter stays silent longer than you expect, drop it and rebuild with a backfill.
Node restarts and deploys
Filters live in process memory. A client restart, a rolling deploy, or a node replaced for lag deletes every filter on that node. From the client side this looks the same as an eviction, and it gets the same treatment.
Reorgs
A log filter reports reorged logs again with removed: true. Undo whatever you did for that log, then process the replacement logs that follow. A backfill with eth_getLogs cannot report removals, because it only sees the current canonical chain. If a filter is lost during a reorg, its removals are lost with it. When you act on logs near the head, re-read recent blocks after every rebuild and treat handled logs that are no longer canonical as removed, or only act on logs at or below the safe or finalized block.
Gaps after recreation
A rebuilt filter starts empty. Everything between the last processed block and the new filter's creation is missing unless you backfill it. The order matters:
- Create the new filter.
- Read the current head with
eth_blockNumber. - Backfill with
eth_getLogsfrom a few blocks before the last processed block to that head, in chunks within your plan's range limit. Undo handled logs in that window that the backfill no longer returns. - Resume polling and drop duplicates by
blockHashandlogIndex.

Creating the filter before reading the head closes the race: any block after that head reaches you through the filter, and overlap is removed by the duplicate check. The head read and the backfill can land on different nodes. In our test, a backfill whose toBlock was the head just read hit a node one block behind, and Reth rejected it with block range extends beyond current head block. A short retry fixed it. Sending the same session cookie on the filter calls, the head read, and the backfill keeps them on one node while the session holds.
A poll that fails midway
A poll can fail after the node has already drained the buffer: a gateway timeout, a 502 from a proxy, a dropped connection. The node has moved on, and the same filter ID returns only later changes. Treat any failed eth_getFilterChanges as filter loss, rebuild, and backfill from your cursor. Do the same when your own handler fails partway through a batch.
If rebuilds keep failing with filter not found seconds after creation, the endpoint is spreading filter calls across nodes. Switch that endpoint to a block-cursor loop with eth_getLogs.
Rate limits
Dwellir returns HTTP 429 with JSON-RPC code -32005 and Rate limit exceeded when you exceed your plan's rate. A block-range violation also uses -32005, but with HTTP 200, as the rate limits page documents. viem's HTTP transport retries both HTTP 429 and -32005 with exponential backoff and honors Retry-After. Keep backfill chunks within your plan's range, 500 blocks on Developer and 10,000 on Growth and Scale, so viem never retries a request that cannot succeed. In the loop below, only the idempotent reads use those retries. A rate-limited poll surfaces as an error and triggers a backed-off rebuild. The same page notes that Dwellir applies this range validation to eth_newFilter when your plan permits the method.
Core methods with curl
Replace YOUR_API_KEY with your key. The examples watch USDC Transfer events on Ethereum. Every call reads and writes cookies.txt, so the DWSESSION cookie from the first response pins the later calls to the same node.
Create a log filter. Omitting fromBlock and toBlock defaults both to latest:
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_newFilter",
"params": [{
"address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"topics": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]
}]
}'
Create a block filter or a pending transaction filter:
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_newBlockFilter","params":[]}'
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_newPendingTransactionFilter","params":[]}'
Poll for changes, read the full criteria, and uninstall. FILTER_ID is the result from the create call:
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getFilterChanges","params":["FILTER_ID"]}'
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getFilterLogs","params":["FILTER_ID"]}'
curl -sS -c cookies.txt -b cookies.txt https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_uninstallFilter","params":["FILTER_ID"]}'
An evicted or unknown ID looks like this on a Reth node:
{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"filter not found"}}
A resilient polling loop in TypeScript
This watcher uses viem's createEventFilter and getFilterChanges (viem 2.56). It implements the rebuild order above, deduplicates by blockHash and logIndex, handles removed: true, reconciles recent blocks after a rebuild, splits backfill ranges that return too much data, and rebuilds after any failed poll or when a busy filter goes quiet.
import { BaseError, ResponseBodyTooLargeError, RpcError, createPublicClient, http, parseAbiItem, type Log } from 'viem'
import { mainnet } from 'viem/chains'
const RPC_URL = 'https://api-ethereum-mainnet.n.dwellir.com/YOUR_API_KEY'
const TOKEN = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' // USDC on Ethereum
const transfer = parseAbiItem(
'event Transfer(address indexed from, address indexed to, uint256 value)',
)
const POLL_MS = 4_000 // far below the 5-minute default idle timeout
const MAX_DELAY_MS = 60_000 // backoff cap after failures
const QUIET_MS = 10 * 60_000 // a busy contract silent this long: rebuild
const MAX_RANGE = 10_000n // eth_getLogs blocks per request on your plan; halved if too large
const REORG_DEPTH = 64n // re-read on rebuild; older blocks are normally final
// Dwellir pins HTTP requests to one backend node with the DWSESSION cookie.
// Node's fetch keeps no cookies, so carry it by hand. Both clients share it,
// so the filter, the head read, and the backfill hit the same node.
let session: string | undefined
const stickyFetch = async (input: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers)
if (session) headers.set('Cookie', session)
const res = await fetch(input, { ...init, headers })
const cookie = res.headers.get('set-cookie')?.match(/DWSESSION=[^;]+/)?.[0]
if (cookie) session = cookie // a new node after a health failover
return res
}
// Idempotent reads (eth_getLogs, eth_blockNumber): viem retries HTTP 429
// and 5xx with exponential backoff and honors Retry-After.
const client = createPublicClient({
chain: mainnet,
transport: http(RPC_URL, { retryCount: 5, retryDelay: 500, fetchFn: stickyFetch }),
})
// Filter calls: no transport retries. A retried eth_getFilterChanges can
// skip a batch the node already drained, so every failure must reach run().
const filterClient = createPublicClient({
chain: mainnet,
transport: http(RPC_URL, { retryCount: 0, fetchFn: stickyFetch }),
})
const newFilter = () => filterClient.createEventFilter({ address: TOKEN, event: transfer })
type TransferFilter = Awaited<ReturnType<typeof newFilter>>
let cursor = 0n // highest block with handled logs; a rebuild re-reads it
let floor = 0n // never backfill below it; handled logs below it are forgotten
const seen = new Map<string, Log>() // handled logs at or above floor, by blockHash:logIndex
async function onLog(log: Log) {
console.log('transfer', log.blockNumber, log.transactionHash)
}
async function onRemoved(log: Log) {
console.log('reorged out', log.blockNumber, log.transactionHash)
}
function isFilterGone(err: unknown): boolean {
if (!(err instanceof BaseError)) return false
const rpcErr = err.walk((e) => e instanceof RpcError)
// Geth, Erigon, Reth and Nethermind all say "filter not found" on a
// poll; error codes differ between stacks, so match the message.
return rpcErr instanceof RpcError && /filter not found/i.test(rpcErr.details)
}
// MAX_RANGE caps blocks, not bytes. viem rejects response bodies over 10 MiB
// by default, and providers reject eth_getLogs queries with too many results.
function isTooLarge(err: unknown): boolean {
if (!(err instanceof BaseError)) return false
const tooLarge = err.walk(
(e) =>
e instanceof ResponseBodyTooLargeError ||
(e instanceof Error && /too large|size exceeded|more than \d+ results|too many results/i.test(e.message)),
)
return tooLarge !== null
}
// eth_getLogs never returns removed logs, so a rebuild cannot deliver a
// removal again. Keep each one queued until onRemoved succeeds.
const unremoved: Log[] = []
async function flushRemovals(until?: Log) {
while (unremoved.length > 0) {
const log = unremoved[0]
await onRemoved(log)
seen.delete(`${log.blockHash}:${log.logIndex}`)
unremoved.shift()
if (log === until) return
}
}
// Record progress only after the handler succeeds. If a handler throws,
// run() rebuilds and the backfill delivers the same log again.
async function handle(logs: Log[]) {
await flushRemovals() // removals left over from a failed handler go first
for (const log of logs) {
if (!log.removed || log.blockNumber === null) continue
unremoved.push(log) // queue the whole batch's removals before any handler runs
if (log.blockNumber < cursor) cursor = log.blockNumber // re-read on rebuild
}
for (const log of logs) {
if (log.blockNumber === null || log.logIndex === null) continue // pending
if (log.removed) {
await flushRemovals(log) // in batch order, up to this removal
continue
}
const key = `${log.blockHash}:${log.logIndex}`
if (seen.has(key)) continue // overlap between backfill and filter
await onLog(log)
seen.set(key, log)
if (log.blockNumber > cursor) cursor = log.blockNumber
}
prune()
}
// Forget handled logs only after they leave the reorg window, and raise
// `floor` with them so no later backfill re-reads the forgotten blocks.
function prune() {
const keepFrom = cursor - REORG_DEPTH
if (keepFrom <= floor) return
floor = keepFrom
for (const [key, log] of seen) {
if (log.blockNumber !== null && log.blockNumber < floor) seen.delete(key)
}
}
async function backfill(from: bigint, to: bigint) {
for (let start = from; start <= to; start += MAX_RANGE) {
await backfillRange(start, start + MAX_RANGE - 1n < to ? start + MAX_RANGE - 1n : to)
}
}
// A range that returns too much data is split in half and each half retried,
// down to a single block. Each completed half advances `cursor`, so a rebuild
// after a later failure resumes from there.
async function backfillRange(start: bigint, end: bigint): Promise<void> {
let logs: Log[]
try {
logs = await client.getLogs({ address: TOKEN, event: transfer, fromBlock: start, toBlock: end })
} catch (err) {
if (start === end || !isTooLarge(err)) throw err
const mid = (start + end) / 2n
await backfillRange(start, mid)
await backfillRange(mid + 1n, end)
return
}
// A filter lost during a reorg never delivers its removals. A handled log
// in this range that is no longer canonical was reorged out: undo it.
const canonical = new Set(logs.map((l) => `${l.blockHash}:${l.logIndex}`))
const queued = new Set(unremoved.map((l) => `${l.blockHash}:${l.logIndex}`))
for (const [key, log] of seen) {
const block = log.blockNumber ?? -1n
if (block >= start && block <= end && !canonical.has(key) && !queued.has(key)) {
unremoved.push({ ...log, removed: true })
}
}
await handle(logs)
if (end > cursor) cursor = end
}
// Create the filter first, then read the head and backfill up to it.
// Blocks after that head arrive through the filter; overlap is deduplicated.
async function createAndCatchUp(): Promise<TransferFilter> {
const filter = await newFilter()
try {
const head = await client.getBlockNumber({ cacheTime: 0 })
const from = cursor - REORG_DEPTH > floor ? cursor - REORG_DEPTH : floor
if (head >= from) await backfill(from, head)
return filter
} catch (err) {
await filterClient.uninstallFilter({ filter }).catch(() => {})
throw err
}
}
export async function run(startBlock: bigint) {
cursor = startBlock
floor = startBlock
let filter: TransferFilter | undefined
let delay = POLL_MS
let rebuilds = 0 // consecutive rebuilds without a successful poll
let lastLogAt = Date.now()
for (;;) {
try {
if (!filter) {
filter = await createAndCatchUp()
lastLogAt = Date.now()
}
const logs: Log[] = await filterClient.getFilterChanges({ filter })
await handle(logs)
if (logs.length > 0) lastLogAt = Date.now()
delay = POLL_MS
rebuilds = 0
if (Date.now() - lastLogAt > QUIET_MS) {
// Busy contract, silent filter: trust eth_getLogs over the filter.
await filterClient.uninstallFilter({ filter }).catch(() => {})
filter = undefined
}
} catch (err) {
// Any failure means a rebuild. A timed-out or rejected poll may already
// have drained the node's buffer, and a failed handler left logs
// unrecorded. The backfill from `cursor` recovers both.
const gone = isFilterGone(err) // evicted, restarted, or another node
if (filter && !gone) await filterClient.uninstallFilter({ filter }).catch(() => {})
filter = undefined
rebuilds++
if (!gone || rebuilds > 2) {
// 429s that outlasted viem's retries, timeouts, 5xx, handler errors,
// or filters that vanish right after creation: back off first.
delay = Math.min(delay * 2, MAX_DELAY_MS)
console.warn(`rebuild ${rebuilds} in ${delay} ms`, err)
}
}
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
A few choices in this code are deliberate:
- The backfill re-reads the last
REORG_DEPTHblocks. A filter lost during a reorg never delivers its removals, so the backfill compares the logs already handled in that window with whateth_getLogsreturns now and queues any that disappeared as removals. The same overlap covers Erigon, which appends logs to the filter buffer one at a time, so a poll can end partway through a block. The duplicate check absorbs everything else.prune()forgets a handled log only after its block falls more thanREORG_DEPTHblocks belowcursor, and raisesfloorso no later backfill starts below the forgotten blocks. Memory grows with the logs in the window, not up to a fixed count that a busy window could overflow. On Dwellir Growth, the 64-block window plus the gap usually fits in oneeth_getLogscall. QUIET_MSdepends on the contract. Ten minutes suits a token that emits events every block. For a contract that is quiet for hours, raise it or remove the check, and rely on the error path.stickyFetchcarries theDWSESSIONcookie. Both clients share it, so the filter, the head read, and the backfill go to the same node. When the load balancer moves the session to a healthy node, the new cookie replaces the old one and the next poll fails withfilter not found, which the loop already handles. On providers without cookie affinity the wrapper does nothing.- Every failed poll triggers a rebuild, not only
filter not found.eth_getFilterChangesdrains the buffer on the node. If the response is lost to a timeout or a 502 after the node answered, retrying the same ID returns the next batch and the lost one is gone. That is also why filter calls go through a client withretryCount: 0: viem builds each filter'srequestfrom the client that created it, and a transport retry would hide the loss. A rebuild costs 3 calls when nothing is missing. - Progress is recorded after the handler succeeds. If
onLogthrows, the log is not marked as seen andcursordoes not move, so the rebuild's backfill delivers it again. Removals are different:eth_getLogsnever returns aremoved: truelog, so every removal in a batch is queued before any handler runs, and a failedonRemovedleaves it in theunremovedqueue to run again before anything else. Make handlers idempotent, because a crash between your write and the in-memory update replays the log. MAX_RANGEmust match your plan, and it caps blocks, not bytes. Set it to 500 on Dwellir Developer. A chunk over the block limit returns-32005, which viem retries before the loop sees it. A chunk within the limit can still return too much data: viem's HTTP transport rejects response bodies over 10 MiB by default withResponseBodyTooLargeError, and some providers reject queries that match too many logs.backfillRangehalves the range on those errors and retries each half, down to a single block, recording progress after each completed half. viem retries an oversized response like any other failure before the loop sees it, so each split costs up toretryCountextra requests.
We ran this loop against a public Ethereum endpoint on 29 September 2026, watching USDC transfers, with test hooks added: the filter was uninstalled on every sixth poll, and the handler threw once, on the 151st transfer. MAX_RANGE was lowered to 5 and REORG_DEPTH to 8 for that endpoint, and it rate-limited us hard, so several polls and backfills also failed with -32005 and went through the rebuild path with backoff. Afterwards, eth_getLogs over the same range (blocks 26,080,461 to 26,080,501) returned 2,509 transfers. The loop had processed all 2,509, with no gaps and no duplicates. No reorg happened during the run, so we tested the removal paths separately with synthetic logs: removals whose onRemoved failed once were all delivered, in order, after the handler recovered, and a block replaced while the filter was gone was undone by the next backfill.
How to check your provider
The script creates a block filter, polls it ten times two seconds apart, goes idle, and polls once more. It reads eth_blockNumber before and after the idle period, so the result is unambiguous: if the head moved, a live filter must return at least one hash.
#!/usr/bin/env bash
# Usage: ./check.sh <rpc-url> <idle-seconds>
URL="$1"; IDLE="${2:-240}"
# Keep cookies so cookie-based sticky sessions (Dwellir's DWSESSION) apply.
# Run with COOKIES=0 to see how the endpoint routes without affinity.
JAR=$(mktemp); trap 'rm -f "$JAR"' EXIT
if [ "${COOKIES:-1}" = 1 ]; then CJ=(-c "$JAR" -b "$JAR"); else CJ=(); fi
rpc() { curl -sS "${CJ[@]}" "$URL" -H 'Content-Type: application/json' -d "$1"; }
poll() { rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getFilterChanges\",\"params\":[\"$1\"]}" \
| jq -c 'if .error then .error else {hashes: (.result | length)} end'; }
ID=$(rpc '{"jsonrpc":"2.0","id":1,"method":"eth_newBlockFilter","params":[]}' | jq -r '.result // empty')
[ -z "$ID" ] && { echo "eth_newBlockFilter failed"; exit 1; }
echo "filter $ID"
# 1. Routing: ten polls, two seconds apart. Every one should return a result.
for i in $(seq 1 10); do poll "$ID"; sleep 2; done
# 2. Idle survival: stay silent, then poll once.
tip() { rpc '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' | jq -r .result; }
H0=$(tip); sleep "$IDLE"; H1=$(tip)
echo "after ${IDLE}s idle, head moved $((H1 - H0)) blocks:"; poll "$ID"
rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_uninstallFilter\",\"params\":[\"$ID\"]}" | jq -c .
Read the output this way:
| Result | Meaning |
|---|---|
filter not found during the ten routing polls | Filter calls are spread across nodes. If it happens only with COOKIES=0, your client must keep the session cookie |
| Hashes after 240 seconds idle | The filter survives a 4-minute gap |
filter not found after the idle period | Expected once the gap passes the stack's timeout |
{"hashes":0} after the idle period, head moved | The filter exists but is not receiving blocks; treat as broken |
{"hashes":0} after the idle period, head did not move | No block was produced; run it again |
eth_newBlockFilter failed | Filters may not be available on this plan or endpoint |
Run it with 240, 360, 660, and 1,260 seconds of idle time to bracket the effective timeout. Geth, Erigon, and Reth defaults should survive 240 seconds and fail at 660. Nethermind defaults should survive 660 and fail at 1,260. Infura documents 15 minutes. Test every URL in your failover list, because each can route filters differently.
Operator checklist
- Poll well inside the shortest idle timeout you might hit: 5 minutes on Geth, Erigon, Reth, and QuickNode.
- Keep the provider's session cookie (
DWSESSIONon Dwellir) on every HTTP filter call, or use a WebSocket connection. - Match
filter not foundby message, not code, and rebuild instead of retrying the same ID. - Rebuild in order: create filter, read head, backfill with
eth_getLogs, then resume polling with deduplication. - Handle
removed: true, and reconcile recent blocks after a rebuild if you act on unconfirmed logs. - Rebuild after any failed poll, disable transport retries for filter calls, keep backfill chunks within your plan's block range, and split a range whose response is too large.
- Price the loop with your provider's weights. On flat pricing, the safer loop costs almost the same.
- Run the routing and idle check against every endpoint before shipping.
Dwellir counts each response as one credit, so a filter poll, an eth_blockNumber call, and a 10,000-block eth_getLogs backfill cost the same, and the rebuild path adds 3 credits per lost filter. The rate limits page lists per-plan response rates and block ranges. Run the check script on the URL you will bill against before choosing filters over a block cursor.


