Indexing Elysium events at ten blocks a second without missing a range
Elysium produces blocks roughly ten times faster than Ethereum produces seconds of history, and its block rate changes with load. That breaks the habits most EVM indexers carry. This is a design that keeps a complete, ordered event table anyway.
Endpoint and plans: Elysium RPC endpoint.
Most EVM indexers were written for a chain that makes a block every twelve seconds. A day of Ethereum is about 7,200 blocks, a single eth_getLogs call can often cover hours, and a polling loop that wakes once per block has plenty of slack. Elysium, Kinetiq's Arbitrum Orbit L2 on HyperEVM, does not give you that slack. It produces several blocks every second, and the exact rate moves with the load on the sequencer.
This post describes an event indexer that stays complete on Elysium: how to size backfill windows, how to keep a cursor honest, what a WebSocket logs subscription does and does not promise, and where finality sits. What the endpoint serves on day one is in our Elysium launch post; this one is about using it well. The numbers come from our own reads of Elysium mainnet (chain ID 99901) on 8 and 9 November 2026 and from the limits in our Elysium documentation.
How fast Elysium actually moves
Kinetiq describes Elysium as a chain with real canonical blocks at a fast cadence and a 300 Mgas/s execution target. What you observe through RPC is a block number that climbs by several units per second, but not at a fixed rate. We read block timestamps over two windows ending at block 2,808,056 on 9 November 2026 at 00:12 UTC:
| Window | Blocks | Seconds | Blocks per second |
|---|---|---|---|
| Last 100,000 blocks | 100,000 | 9,427 | 10.6 |
| Last 1,000,000 blocks | 1,000,000 | 154,459 | 6.5 |
The difference is not noise. A Nitro sequencer creates a block when it has messages to put in one, so a busy hour produces more blocks than a quiet one. Two habits fail because of this. The first is converting time to blocks with a constant: "the last hour is the last 36,000 blocks" is right at one moment and wrong at another. The second is assuming one block per poll. If your loop wakes every second and fetches latest, it will skip several heights every time, which is fine for a head display and wrong for anything that must see every block.
The fix for both is the same. Work in block numbers, read the timestamp of the boundary blocks when you need wall-clock time, and never assume adjacency between two latest reads.
Backfill: windows, filters and an honest cursor
Our endpoint accepts eth_getLogs over at most 10,000 blocks per request, counting both ends. A wider range returns -32602 with a message asking you to split it. A response larger than 8 MB returns -32005. Neither case returns a partial result, so a backfill that handles both errors correctly cannot silently lose events. At ten blocks per second, 10,000 blocks is about sixteen minutes of chain time; at the slower average it is closer to twenty-six. The eth_getLogs reference has the exact request and response shapes.
A backfill loop that stays correct has four rules.
Filter at the source. Pass address and, where you can, topics. An unfiltered window on a busy stretch of chain can carry thousands of logs and run into the 8 MB cap long before it reaches 10,000 blocks. Filtering also keeps your request rate meaningful: eth_getLogs has its own rate category, from one request per second on the trial to ten on Business.
Advance the cursor only after the write. Store last_completed_block in the same transaction as the logs from the window that ends there. If the process dies between the RPC call and the commit, the window is simply read again. Deduplicate on (blockHash, logIndex), or on (transactionHash, logIndex) once the block is final, so a replayed window is harmless.
Split, do not skip. On -32602 or -32005, halve the window and retry the first half, then the second. Keep halving down to a single block if you must. A single block that still exceeds the size cap tells you the filter is too broad, which is a configuration problem, not a data gap. Never record an error as "no logs in this range".
Pin the upper bound. Read the head once, choose toBlock from that number, and leave anything above it for the next pass. Using the literal tag latest as toBlock makes a window's meaning change between the retry and the original call.
Run in this order: backfill from your start block up to the current finalized block first, store those rows as final, then hand over to the tailing loop described below.
Tailing: WebSocket logs as a hint, eth_getLogs as the record
For live data, wss://triport.io/ws/elysium/<API_KEY> accepts eth_subscribe with logs from Basic up and newHeads from Pro up. A logs subscription must carry an address or topics filter. In our two 60-second live runs on 8 November, the logs stream delivered 577 of 577 matching logs and newHeads delivered every header in order, without gaps. Headers arrive in small groups, about once per second, rather than one frame per block, which matches the block rate above.
Two properties decide how you use the stream.
Reorged logs are not re-sent. Our Elysium WebSocket does not emit removed: true notifications. If the chain replaced a block after you received its logs, the stream will not tell you. On a single-sequencer rollup such replacements are rare, but an indexer that must be correct cannot assume "rare" means "never".
A disconnect leaves a hole. After a reconnect you receive logs from the new subscription onwards only. Whatever happened in between is invisible to the stream.
So the stream is the fast path, not the record. Write streamed logs into a provisional table keyed by block number. On every reconnect, read the gap with eth_getLogs from your last confirmed block to the current head, using the same filter. Periodically, and always before promoting rows to final, re-read the range up to the finalized block and replace the provisional rows with what that read returns. Slow consumers are disconnected with close code 1013, which is a signal to process frames off the socket thread, not to reconnect faster.
If you only need new heads to drive polling, newHeads gives you block numbers without a polling loop. If your plan does not include it, poll eth_blockNumber once a second and fetch the logs of every height between the previous and the current value.
Where final is
Elysium settles on HyperEVM. A block becomes final for the RPC tag finalized after its batch has been posted to HyperEVM and the parent block holding that batch is final, so the tag moves in steps that follow batch posting rather than block production. On 9 November 2026 at 00:12 UTC, finalized and safe both pointed at block 2,786,079, which was 21,977 blocks, or 2,842 seconds, behind latest. On a quieter day the gap will be shorter, and on a busy one longer.
For an indexer this gives three useful states:
- seen: delivered by the stream or read at
latest, may still change; - confirmed: re-read by
eth_getLogsand stored, still abovefinalized; - final: at or below the
finalizedblock when re-read.
Most applications can show "seen" data immediately and settle balances or payouts at "final". The withdrawal window to HyperEVM is a separate matter, measured in HyperEVM blocks, and it does not apply to events that stay on Elysium. The bridge guide covers that path.
Budgeting requests
The arithmetic is simple enough to do before you write code. A backfill of the chain's first 2.8 million blocks with one filter is about 281 windows of 10,000 blocks. At one eth_getLogs per second, that is under five minutes of requests, plus any splits. A live tail that re-reads the last finalized gap every five minutes adds a few requests per window. Block reads for timestamps are ordinary reads, at five requests per second on the trial and up to forty on Business. JSON-RPC batches are accepted, and each element counts as one request against the rate.
If the job is "every transaction an address ever sent", events are the wrong tool: an EOA transfer emits no log. For that question, see the batch-history product in the docs, which indexes signed transactions from sequencer batches since genesis.
When this is the wrong approach
- You need every transaction of an address, not every event of a contract. Logs miss plain value transfers and calls that emit nothing. Use Elysium batch history for signed transactions since genesis, and receipts for execution results.
- You need execution traces or internal calls.
debug_*andtrace_*are not available on Elysium mainnet nodes, so no endpoint can serve them; an event-only design cannot reconstruct internal value transfers. - You need sub-second reaction to pending transactions. Elysium has no public mempool and no
newPendingTransactions; the earliest public signal is the block. - You only display a head number. Poll
eth_blockNumberand skip the indexer entirely. - Your data must be final before anyone sees it. Then skip the stream and index from
finalizedonly, accepting the delay shown above.
For the network itself, start at what Kinetiq Elysium is and Elysium's chain IDs. Endpoints, plans and the keyless public URL are on the Elysium RPC page.
Sources
- Block timestamps over the last 100,000 and 1,000,000 blocks, the
finalizedandsafetags, and thel1BlockNumberfield: read from Elysium mainnet through triport.io, 2026-11-09 00:12 UTC. eth_getLogsrange, size cap and error codes, and rate categories: Elysium limits and the eth_getLogs reference, checked 2026-11-09.- WebSocket behaviour (logs 577 of 577, newHeads in order, no
removedflag, close code 1013): Triport live tests through triport.io, 2026-11-08. - Elysium's architecture and execution target: Kinetiq docs, what is Elysium, read 2026-11-08.