eth_newFilter creates a server-side filter object on a specific node and returns a hex quantity id. That id is a handle to node-local state, not a portable query. eth_getFilterChanges is a destructive read: it returns only items accumulated since the previous poll and advances an internal cursor, so calling it twice loses the first batch. Filters expire after a period of inactivity (documented by clients such as Geth), after which getFilterChanges returns a filter-not-found error; eth_uninstallFilter releases a filter explicitly. Because filter ids do not migrate between nodes, load-balanced endpoints break naive filter usage. This article covers the full lifecycle, a runnable Node.js polling loop, and a measurement table you can fill in against your own endpoint.
What a Filter Is: Server-Side State Keyed by a Hex ID
A filter is not a query you re-send. It is an object created on a specific node by eth_newFilter (for logs), eth_newBlockFilter (for new block hashes), or eth_newPendingTransactionFilter (for pending transaction hashes). The node stores the filter's criteria and an internal cursor, then returns a hex quantity id such as 0x1 or 0x7f3a. That id is the only thing you send on subsequent calls. The Ethereum JSON-RPC specification defines these methods and their return shapes; the JSON-RPC 2.0 Specification defines the request/response envelope and the error object returned when an id is unknown.
The critical mental model is that the filter lives in the node's memory, not in your request. Two consequences follow immediately. First, the id is meaningless to any other node. Second, the node can drop the filter without telling you, which is why the lifecycle matters more than the initial call. If you are still comparing this pull model against push subscriptions, see eth_subscribe subscriptions versus polling filters.
The filter method contracts are defined by the Ethereum JSON-RPC specification for the filter methods, and the error envelope returned for an unknown or expired filter id follows the JSON-RPC 2.0 specification. The inactivity-timeout behaviour of a server-side filter is documented by clients such as Geth, which is why a long-quiet poll loop must recreate the filter rather than assume it persists.
- eth_newFilter: creates a log filter from a filter object (fromBlock, toBlock, address, topics).
- eth_newBlockFilter: creates a filter that accumulates new block hashes.
- eth_newPendingTransactionFilter: creates a filter that accumulates pending transaction hashes.
- All three return a hex quantity id; the id is node-local state.
The Three Filter Kinds and What getFilterChanges Returns
The filter kind determines the element type returned by eth_getFilterChanges. A log filter returns an array of log objects, each with address, topics, data, blockNumber, transactionHash, and related fields. A block filter returns an array of block hashes as hex strings. A pending-transaction filter returns an array of transaction hashes. The array is empty when nothing new has arrived since the last poll.
This type difference is a common source of integration bugs: code written for a log filter that assumes objects will break when pointed at a block filter that returns strings. If your goal is to read full logs for a known range rather than to track new ones, the stateless eth_getLogs event topic filtering path is usually simpler and has no id to manage.
- Log filter -> array of log objects.
- Block filter -> array of block hashes.
- Pending-transaction filter -> array of transaction hashes.
- Empty array means no new items since the previous poll, not an error.
Cursor Semantics: getFilterChanges Versus getFilterLogs
eth_getFilterChanges is a destructive read. It returns only the items accumulated since the previous call and advances the filter's internal cursor past them. Calling it twice in a row returns the first batch and then an empty array (or only newly arrived items). This is why repeated polling is the normal pattern and why accidentally calling it from two code paths loses data: the second caller consumes what the first should have seen.
eth_getFilterLogs is the non-destructive counterpart. It returns the complete set of logs matching the filter's criteria, not just the delta. It is useful as a reconciliation check: after a suspected missed batch, call getFilterLogs to see the full matching set and compare it against what your cursor-based loop recorded. The two methods answer different questions, and mixing them without understanding the cursor is a documented cause of duplicate or missed logs. For a related discussion of safe retries, see JSON-RPC idempotency and duplicate-request safety.
- getFilterChanges: delta since last poll, advances the cursor, destructive.
- getFilterLogs: full matching log set, does not advance the cursor.
- Polling getFilterChanges in a loop is expected; calling it twice discards the first result.
- Use getFilterLogs to reconcile, not as a drop-in replacement in the poll loop.
Lifecycle and Expiry: Idle Filters Are Dropped
A filter is server-side state with an inactivity timeout. Geth's documentation describes filters as being removed after a period without polling, and other clients document similar behaviour. Once a filter is dropped, eth_getFilterChanges for that id returns a filter-not-found error rather than an empty array. The exact timeout window is client- and version-dependent, so treat it as documented behaviour that varies by provider rather than a fixed constant.
eth_uninstallFilter releases a filter explicitly and returns a boolean indicating whether the filter existed. A long-quiet consumer must therefore handle the drop: catch the filter-not-found error, recreate the filter, and resume polling. Recreating from the last processed block avoids a gap. The stateless eth_getLogs has no such timeout because it holds no server-side state, which is a key reason some teams prefer it for low-frequency consumers.
- Idle filters are dropped after a client-defined inactivity period.
- After expiry, getFilterChanges returns a filter-not-found error, not an empty array.
- eth_uninstallFilter releases a filter explicitly and returns a boolean.
- Recreate on filter-not-found and resume from the last processed block.
Why Filter IDs Do Not Migrate Across Nodes
Because the filter is node-local state, an id created against one endpoint is unknown to another. If your client sends eth_newFilter to node A and then eth_getFilterChanges to node B through a round-robin load balancer, node B has never seen that id and returns a filter-not-found error. This is one of the most common production failures with the filter API and it is invisible in single-node testing.
The fix is to pin filter traffic to the node that created the filter, or to avoid the stateful API entirely in favour of stateless eth_getLogs polling. If you must load-balance, use sticky sessions or a single dedicated connection for the filter's lifetime. Provider behaviour here varies: some managed endpoints document sticky routing, others do not, so verify against your own endpoint. For endpoint selection guidance, see Ethereum RPC endpoints and provider selection (RPC Assistant).
- Filter ids are per-node state and are not portable.
- Round-robin load balancing breaks naive filter usage.
- Pin filter traffic to the creating node or use stateless eth_getLogs.
- Sticky-routing support varies by provider; verify empirically.
Runnable Node.js Example: Create, Poll, Reconcile, Uninstall
The example below uses the built-in fetch (Node.js 18+) and no external dependencies. It creates a log filter, polls eth_getFilterChanges on an interval, uses eth_getFilterLogs as a reconciliation check, uninstalls the filter on shutdown, and recreates it when a filter-not-found error occurs. Replace the endpoint URL and the filter criteria with your own values.
The recreate-on-error branch is the part most integrations omit. Without it, a quiet period longer than the node's inactivity timeout silently ends your stream. The reconciliation call is optional but useful during development to confirm the cursor is behaving as expected.
const ENDPOINT = process.env.ETH_RPC_URL || 'https://your-endpoint.example';
const FILTER = { fromBlock: 'latest', address: null, topics: [] };
let filterId = null;
let running = true;
async function rpc(method, params) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params })
});
const json = await res.json();
if (json.error) {
const err = new Error(json.error.message);
err.code = json.error.code;
throw err;
}
return json.result;
}
async function createFilter() {
filterId = await rpc('eth_newFilter', [FILTER]);
console.log('created filter', filterId);
}
async function poll() {
try {
const changes = await rpc('eth_getFilterChanges', [filterId]);
if (changes.length) console.log('new logs', changes.length);
} catch (err) {
if (err.code === -32000 || /filter not found/i.test(err.message)) {
console.warn('filter expired, recreating');
await createFilter();
} else {
throw err;
}
}
}
async function reconcile() {
const all = await rpc('eth_getFilterLogs', [filterId]);
console.log('full matching set', all.length);
}
async function shutdown() {
running = false;
if (filterId) {
const ok = await rpc('eth_uninstallFilter', [filterId]);
console.log('uninstalled', filterId, ok);
}
}
(async () => {
await createFilter();
process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
while (running) {
await poll();
await new Promise(r => setTimeout(r, 5000));
}
})();Results Table: Measure Filter Behaviour Against Your Own Endpoint
Filter behaviour is client- and provider-dependent, so the only reliable answer is the one you measure. Run the probe below against your endpoint, leave a filter idle for a known period, then poll it and record what happens. Fill in the table with your observations. Do not assume the values from another provider's documentation apply to yours.
The probe creates a block filter, records the id format, waits, and then attempts a poll. Adjust the idle period to bracket the suspected timeout. A JSON-RPC error object with a code and message indicates the filter was dropped; an empty array indicates it is still alive.
// probe.js - run with: node probe.js
const ENDPOINT = process.env.ETH_RPC_URL || 'https://your-endpoint.example';
async function rpc(method, params) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
return res.json();
}
(async () => {
const created = await rpc('eth_newBlockFilter', []);
console.log('id format:', created.result);
const idleMs = Number(process.env.IDLE_MS || 300000);
console.log('idling for', idleMs, 'ms');
await new Promise(r => setTimeout(r, idleMs));
const polled = await rpc('eth_getFilterChanges', [created.result]);
console.log('after idle:', JSON.stringify(polled));
const unknown = await rpc('eth_getFilterChanges', ['0xdeadbeef']);
console.log('unknown id:', JSON.stringify(unknown));
})();Failure Modes and Troubleshooting
Most filter incidents fall into four buckets. The first is filter-not-found after an idle period: the consumer was quiet longer than the node's inactivity timeout, and the next poll errored. The fix is to catch the error and recreate the filter from the last processed block. The second is a filter created on a load-balanced endpoint that round-robins to a node that never saw it; the fix is sticky routing or stateless polling.
The third is duplicate or missed logs from mixing getFilterChanges and getFilterLogs. Because getFilterChanges advances the cursor and getFilterLogs does not, using both in the same loop without tracking which items you have already processed leads to double-counting or gaps. The fourth is a ballooning filter state: if filters are never uninstalled, the node accumulates them until they expire or the process restarts. Always call eth_uninstallFilter on shutdown. For reorg-related gaps, see Block reorg detection over RPC.
- filter-not-found after idle: recreate the filter and resume from the last processed block.
- Load-balanced endpoint: pin filter traffic or switch to stateless eth_getLogs.
- Mixed cursor methods: track processed items explicitly to avoid duplicates or gaps.
- Never uninstalled: filters accumulate until expiry or restart; uninstall on shutdown.
Operational Guidance: Polling Cadence, Idempotency, and Shutdown
Once the filter model is understood, the operational details decide whether a poller is reliable. Poll on a cadence faster than the node's idle timeout so the filter is never dropped, but not so fast that each empty getFilterChanges call costs a round trip for nothing; a cadence of a few seconds is the usual compromise, tuned to the chain's block time. Because getFilterChanges is a destructive read, the consumer must persist whatever it does with the returned items before the next poll, or the items are lost the moment the cursor advances.
Uninstalling filters on shutdown keeps the node's filter state bounded. A process that crashes without calling eth_uninstallFilter leaves the filter to expire on its own, which is acceptable, but a process that creates a fresh filter on every restart without uninstalling accumulates state. Treat the filter id as a resource with an explicit release, exactly as you would a database cursor, and recreate it — never resume it — after any restart or filter-not-found error.
- Poll faster than the idle timeout but no faster than the chain produces new logs.
- Persist or forward every item before the next getFilterChanges call; the read is destructive.
- Call eth_uninstallFilter on shutdown and recreate the filter after any expiry or restart.
- Never cache a filter id across endpoint changes: it is node-local state.
Limitations and Tradeoffs: When the Filter API Is the Wrong Tool
The stateful filter API is convenient for high-frequency consumers that poll often enough to stay inside the inactivity window, but it carries real costs. It requires a persistent connection to one node, it has no portable id, and it can be dropped silently. Some providers have deprecated the filter methods in favour of stateless eth_getLogs polling or push-based eth_subscribe, and which surfaces are available varies by provider. Always confirm against your endpoint's documentation before designing around filters.
For low-frequency consumers, batch reconciliation, or any workload that must survive node restarts, stateless eth_getLogs is usually the better choice: no id, no cursor, no expiry. For high-frequency, low-latency consumers that can hold a connection, eth_subscribe is often preferable. The filter API sits between them and is best treated as a specialised tool rather than a default. If you are reading receipts in bulk, eth_getBlockReceipts versus individual receipt reads covers a related stateless pattern.
- Stateful: requires a persistent connection to one node.
- Not portable: ids do not migrate across nodes.
- Can be dropped silently after inactivity.
- Availability varies by provider; some deprecate filters in favour of eth_getLogs or eth_subscribe.
Next Steps: Choosing Between Filters, getLogs, and Subscriptions
Decide based on polling frequency and connection stability. If you poll every few seconds and can hold a connection to one node, the filter API is workable provided you handle expiry and uninstall. If you poll infrequently or need to survive restarts, use stateless eth_getLogs. If you need push semantics and your provider supports it, use eth_subscribe. The OnFinality Learn hub has companion articles on each pattern.
Before committing, run the probe above against your endpoint and fill in the results table. That measurement, not another provider's documentation, is the basis for your design. For endpoint options and provider selection, see Ethereum RPC endpoints and provider selection (RPC Assistant), and for network-specific details see Ethereum on OnFinality. If you are planning capacity, RPC pricing and the API service pages describe the commercial surfaces.
- High-frequency + stable connection: filter API with expiry handling.
- Low-frequency or restart-tolerant: stateless eth_getLogs.
- Push semantics available: eth_subscribe.
- Always measure your own endpoint before designing around filters.