Aptos Historical Transaction Replay
Replay Aptos transactions from a stored ledger version with GetTransactions. Covers starting_version, bounded ranges, checkpoint resumption, and backfill throughput.
Early access — not yet in production
The Aptos Transaction Stream is not generally available. Email support@dwellir.com to join the early-access list.
Replay is the same RPC as a live tail, with starting_version set. Use it to
backfill a new processor, recover after an outage, or rebuild a table from a
schema change.
There is no separate replay service or endpoint — see the Transaction Stream Overview for the service definition.
Confirm your replay range before you plan a backfill
How far back you can replay depends on the retention of the nodes serving your account, and deep historical range is provisioned rather than assumed. Confirm the available range with support@dwellir.com before designing a backfill that depends on it. Do not assume replay from genesis is available.
Replay From a Version
const request = {
starting_version: '100000000',
};
const stream = client.GetTransactions(request, metadata);
stream.on('data', (response) => {
for (const tx of response.transactions) {
processTransaction(tx);
}
checkpoint.observe(response);
});A replay stream does not end when it reaches the head of chain. It transitions into a live tail on the same connection, which is what makes catch-up-then-tail a single code path rather than two.
Bound the Range
To replay a fixed window rather than continuing into the tail, set
transactions_count:
// Replay exactly 5,000,000 transactions, then the stream completes.
const request = {
starting_version: '100000000',
transactions_count: '5000000',
};transactions_count counts transactions, not versions or messages. Use it when
you want the stream to terminate on its own; cancel client-side when your stopping
condition depends on decoded content.
Tune the Batch Size
Backfills are the one case where batch_size matters. The tail sends roughly one
message per block regardless, but a replay can fill batches, and larger batches
mean fewer metered messages for the same number of transactions.
const request = {
starting_version: resumeFrom.toString(),
batch_size: 1000, // Default and maximum. Values above 1000 are rejected.
};Since the billed unit is the message, leaving batch_size at its default of 1000
is also the cheapest setting for a bulk backfill. Reduce it only if large batches
cause memory pressure in your consumer.
Resume Without Gaps
Replay jobs are long enough that they will be interrupted. Store progress from
processed_range.last_version and restart from the version after it:
class ReplayJob {
async run(fallbackStart: bigint, endVersion: bigint) {
const stored = await this.loadCheckpoint();
const start = stored === null ? fallbackStart : stored + 1n;
if (start > endVersion) return;
const stream = client.GetTransactions(
{ starting_version: start.toString() },
metadata
);
stream.on('data', async (response) => {
const last = BigInt(response.processed_range.last_version);
for (const tx of response.transactions) {
if (BigInt(tx.version) > endVersion) {
stream.cancel();
return this.finish();
}
await this.process(tx);
}
await this.saveCheckpoint(last > endVersion ? endVersion : last);
});
}
}Commit the decoded rows and the checkpoint in one database transaction. A checkpoint written ahead of its data turns any crash into a silent gap that only surfaces much later as missing history.
Splitting Work Across Workers
Ledger versions are contiguous and ordered, so a range splits cleanly across workers — each opens its own bounded stream over a disjoint slice:
async function parallelReplay(start: bigint, end: bigint, workers: number) {
const span = (end - start) / BigInt(workers);
await Promise.all(
Array.from({ length: workers }, (_, i) => {
const from = start + span * BigInt(i);
const to = i === workers - 1 ? end : from + span - 1n;
return new ReplayJob(`replay_${i}`).run(from, to);
})
);
}Two caveats before reaching for this:
- Every worker is a separate metered stream. Splitting a backfill across eight workers does not change the transaction count, but it does add per-message overhead and eight concurrent consumers against one rate limit.
- The rate limit is per account, not per key, so parallel workers compete with each other and with your production traffic. See Rate Limits.
Give each worker its own checkpoint row, and treat the backfill as complete only when every slice has committed.
Throughput Tips
- Bulk insert per batch, not per transaction.
- Drop non-essential indexes during a large backfill and rebuild after.
- Decode only what you store. Full transaction payloads are large, and parsing fields you discard is usually the bottleneck before the network is.
- Apply a server-side filter if the backfill targets specific accounts or functions. It cuts bytes, parsing, and metered messages together.
- Run backfills off-peak when they share an account with production traffic.
Related Guides
- Transaction Stream Overview — service definition and metering
- Real-Time Streaming — tailing after catch-up
- Custom Processors — decoding and write patterns
- Rate Limits — plan limits and per-account scope
Real-Time Streaming
Tail the head of the Aptos chain with aptos.indexer.v1.RawData/GetTransactions. Covers batch handling, server-side filters, checkpointing from processed_range, and reconnect strategy.
Custom Processors
Architecture patterns for Aptos Transaction Stream processors - batch handling, event decoding, idempotent writes, checkpoint ordering, and failure isolation.