Efficiently Fetch ERC‑20 Transfer Logs with web3.js pastEvents and Block‑Range Pagination
Learn how to use web3.js’ <code>pastEvents</code> with block‑range pagination to fetch all ERC‑20 Transfer logs reliably. A step‑by‑step guide, example code, trade‑offs, and best practices for Goerli USDC events.
16 Nov 2025, 17:48 UTC

Why you’re missing Transfer events
When you call web3.eth.Contract.getPastEvents without limits, you’ll hit RPC throttling or node timeouts. If you request a huge block span, the node may return fewer logs than actually exist, or the request may fail altogether. The result: incomplete data and wasted retries.
Thesis: Use pastEvents with explicit block ranges and a configurable batch size
By splitting the query into small, predictable blocks, you guarantee that every log is retrieved while staying within the node’s response limits. The pastEvents method returns a promise that resolves to an array of decoded event objects, making it straightforward to accumulate results.
1. Event Filter Basics
- Topics – Each event signature is hashed (Keccak‑256) and appears as the first topic. For
Transfer(address,address,uint256)the topic is0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a8e6f2c8d6f. - getPastEvents vs pastEvents –
getPastEventsis a convenience wrapper that immediately returns a promise.pastEventsis a method on the event object itself and offers the same functionality but can be chained or wrapped in custom logic. - Auto‑decoding – The returned objects already contain decoded values for the indexed and non‑indexed parameters, so you can access
event.returnValues.fromdirectly.
2. Setting Up the Contract Instance
const Web3 = require('web3');
// Choose a provider that supports archive state if you need deep history.
const provider = new Web3.providers.HttpProvider('https://goerli.infura.io/v3/YOUR_PROJECT_ID');
const web3 = new Web3(provider);
const usdcAbi = [
// Minimal ABI fragment for Transfer event
{
"anonymous": false,
"inputs": [
{"indexed": true, "name": "from", "type": "address"},
{"indexed": true, "name": "to", "type": "address"},
{"indexed": false, "name": "value", "type": "uint256"}
],
"name": "Transfer",
"type": "event"
}
];
const usdcAddress = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6'; // USDC on Goerli
const usdcContract = new web3.eth.Contract(usdcAbi, usdcAddress);
Use an HTTP provider for most use‑cases. If you require real‑time updates, a WebSocket provider can also be used, but pastEvents works the same way.
3. Implementing Pagination
The core idea is to iterate over the block range in chunks, calling pastEvents for each chunk and appending the results. A simple loop handles this cleanly.
async function fetchTransfers(fromBlock, toBlock, batchSize = 1000) {
let currentBlock = fromBlock;
const allEvents = [];
while (currentBlock <= toBlock) {
const endBlock = Math.min(currentBlock + batchSize - 1, toBlock);
try {
const events = await usdcContract.events.Transfer({
fromBlock: currentBlock,
toBlock: endBlock
}).get();
allEvents.push(...events);
} catch (err) {
console.error(`Error fetching blocks ${currentBlock}-${endBlock}:`, err);
// Simple retry logic – could be exponential back‑off
await new Promise(r => setTimeout(r, 1000));
continue;
}
currentBlock = endBlock + 1;
}
return allEvents;
}
// Example usage
fetchTransfers(8000000, 8005000, 1000)
.then(events => console.log(`Fetched ${events.length} Transfer events`))
.catch(console.error);
Key points:
fromBlockandtoBlockare inclusive. AdjustbatchSizeto fit the node’s limits.- Errors are caught per batch, so a failure in one chunk doesn’t abort the entire range.
- After the loop, verify that the last block processed equals
toBlockto ensure no gaps.
4. Worked Example: USDC Transfers on Goerli
We target blocks 8,000,000 to 8,005,000 with a 1,000‑block batch. The script above will produce an array of events. A typical console output might look like:
Fetched 27 Transfer events
To double‑check, you can query a block explorer’s API or use the eth_getLogs JSON‑RPC call directly with the same block range and compare the topics arrays.
Trade‑offs and Limitations
- Batch Size vs. Node Limits – Larger batches reduce RPC calls but risk hitting a node’s maximum response size (often 1‑2 MB). Small batches increase overhead but are more tolerant of high‑log blocks.
- Archive State Requirement – Public RPCs may not expose older blocks. If you need logs older than a few thousand blocks, use an archive node or a provider that offers full history.
- Rate Limits – Even with pagination, some providers throttle requests per second. Add a short delay between batches if you hit 429 responses.
- Event Decoding Accuracy – The ABI must match the contract exactly. A mismatched ABI can cause decoding errors or missing fields.
Actionable Takeaways
- Always specify explicit
fromBlockandtoBlockvalues when callingpastEvents. - Choose a batch size that balances speed and reliability; start with 1,000 blocks and adjust based on node responses.
- Implement per‑batch error handling and retries; consider exponential back‑off for persistent failures.
- After fetching, confirm that the final processed block equals
toBlockand that the total event count matches expectations. - For production workloads, consider switching to a dedicated archive node or a provider that guarantees full‑history access.
By following this pattern, you’ll reliably harvest every ERC‑20 Transfer event in a given range without overloading your node or missing data.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.