Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
RPC Troubleshooting13 min read

Sui queryTransactionBlocks: Cursor Pagination Without Gaps or Duplicates

A resumable, de-duplicating walk of suix_queryTransactionBlocks that survives cursor invalidation and proves continuity with a digest watermark.

TL;DR

Sui's list endpoints, including suix_queryTransactionBlocks, return a page envelope of { data, nextCursor, hasNextPage } rather than an offset and total count. Cursors are opaque tokens: you pass them back verbatim and never construct, decode, or arithmetic-advance them. Forward pagination walks in ascending order, and descendingOrder flips the traversal direction, but the cursor still encodes the position. Because results are read at a specific checkpoint or epoch and cursors can be invalidated across prune boundaries, a long walk must persist its last cursor, tolerate invalidation with a checkpoint-bounded restart, and de-duplicate by transaction digest. This article builds a runnable, resumable paginator and shows how to prove continuity with a digest watermark.

The suix_queryTransactionBlocks page envelope and its cursor contract

Sui JSON-RPC list endpoints return a uniform page envelope: an array of results in data, an opaque nextCursor, and a boolean hasNextPage. The Sui JSON-RPC API Reference for suix_queryTransactionBlocks documents this shape, and the same contract applies to suix_queryEvents and sui_getCheckpoints. Your paginator should treat the envelope as the only source of truth about where the walk is.

The cursor is opaque. The Sui Documentation on cursor pagination describes cursors as tokens to be passed back verbatim; you must not parse them, add to them, or synthesize one from a digest or sequence number. Any code that does arithmetic on a cursor is relying on an implementation detail that can change without notice.

Forward pagination uses ascending order by default. Setting descendingOrder flips the traversal direction, but the cursor still encodes the position within that traversal, so you must keep the same ordering for the entire walk. Mixing directions mid-walk is a common source of gaps and repeats.

  • data: the page of transaction blocks for this request.
  • nextCursor: opaque token for the next page; null when the walk is exhausted.
  • hasNextPage: whether another page exists; do not infer it from data.length.
  • descendingOrder: flips traversal direction; keep it constant for a given walk.

Why cursor pagination replaces offset pagination for transaction blocks

Offset pagination asks for limit rows starting at offset. On a live ledger, new transaction blocks are appended continuously, so the row that was at offset 1000 when you started may be at offset 1005 by the time you request the next page. That shift produces both gaps (rows skipped) and duplicates (rows seen twice). Cursor pagination avoids this because the cursor anchors the next read to a position in the ordered result set rather than to a count.

Offset pagination also degrades as the offset grows: the backend must skip an increasing number of rows before returning the page. Cursor pagination lets the backend resume from an indexed position, which is why it is the recommended pattern for ledger-scale enumeration. The trade-off is that you cannot jump to an arbitrary page or compute a total count from the envelope alone.

For transaction-block enumeration, the practical consequence is that a resumable walk is a state machine: persist the cursor, request the next page, append the results, and repeat until hasNextPage is false. The OnFinality Learn hub collects related Sui RPC patterns if you want the broader context before building this one.

  • Offset: position by count; unstable under concurrent appends; degrades with large offsets.
  • Cursor: position by opaque token; stable under appends; no random access or total count.
  • For a full-ledger walk, cursor pagination is the only continuity-safe option.

A minimal forward walk with suix_queryTransactionBlocks

Start with the smallest correct loop: request a page, append data, read nextCursor, and stop when hasNextPage is false. This example uses the Sui TypeScript SDK against a Sui endpoint; substitute your own endpoint URL. The Sui RPC guide in RPC Assistant covers endpoint selection and method availability if you are choosing a provider.

Notice that the loop never inspects the cursor. It stores it and sends it back unchanged. That is the whole contract. If you find yourself wanting to know what is inside the cursor, you are probably trying to solve a problem that belongs in your own watermark logic instead.

import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';

const client = new SuiClient({ url: getFullnodeUrl('mainnet') });

async function walkAll(filter = {}) {
  const out = [];
  let cursor = null;
  let hasNextPage = true;

  while (hasNextPage) {
    const page = await client.queryTransactionBlocks({
      filter,
      cursor,
      limit: 50,
      order: 'ascending',
      options: { showEffects: true },
    });

    out.push(...page.data);
    cursor = page.nextCursor;
    hasNextPage = page.hasNextPage;
  }

  return out;
}

walkAll({}).then((txs) => console.log('blocks:', txs.length));

Filtering by input object, changed object, and transaction kind

suix_queryTransactionBlocks accepts a filter object that narrows the result set before pagination. The documented filter variants include InputObject, ChangedObject, FromAddress, ToAddress, FromAndToAddress, TransactionKind, and MoveFunction. Filtering server-side is both faster and more correct than filtering client-side after a full walk, because the cursor then tracks the filtered result set rather than the unfiltered ledger.

A subtlety: the cursor is tied to the filter you used. If you change the filter mid-walk, the cursor no longer refers to the same ordered result set, and continuity is undefined. Persist the filter alongside the cursor so a resumed walk uses the identical query. This is also why a checkpoint-bounded restart must re-apply the same filter.

For object-centric indexing, ChangedObject is usually what you want when tracking an object's mutation history, while InputObject captures transactions that consumed the object. The distinction matters for de-duplication because a single transaction can appear under multiple filters.

  • InputObject: transactions that used the object as an input.
  • ChangedObject: transactions that mutated the object.
  • TransactionKind: restrict to a programmable transaction, consensus commit prologue, and so on.
  • MoveFunction: restrict to calls of a specific package::module::function.

Persisting the cursor and filter as resumable state

A resumable walk needs durable state: the last cursor, the filter, the ordering, and a watermark. Store them together so a restart cannot accidentally pair a cursor with a different filter. A small JSON record in a file, Redis key, or database row is sufficient. The API service page describes how OnFinality exposes Sui RPC endpoints if you are wiring this into a hosted pipeline.

The watermark is the continuity proof. Record the last transaction digest you appended, plus the checkpoint or epoch the page was read at if the response exposes it. On resume, you compare the first digest of the new page against the watermark: if it matches, the walk is continuous; if it does not, you have a gap or a rewind and must reconcile.

Persist after every page, not at the end. A crash mid-walk should lose at most one page of progress, and the watermark tells you exactly where you were.

import fs from 'node:fs';

const STATE = './sui-walk-state.json';

function loadState() {
  if (!fs.existsSync(STATE)) return null;
  return JSON.parse(fs.readFileSync(STATE, 'utf8'));
}

function saveState(state) {
  fs.writeFileSync(STATE, JSON.stringify(state, null, 2));
}

// state shape:
// {
//   "cursor": "opaque-token-or-null",
//   "filter": { "ChangedObject": "0xabc..." },
//   "order": "ascending",
//   "watermarkDigest": "...",
//   "watermarkCheckpoint": "12345678",
//   "seen": ["digest1", "digest2"]
// }

Handling cursor invalidation with a checkpoint-bounded restart

Cursors can be invalidated. The Sui documentation notes that results are read at a specific checkpoint or epoch and that cursors may not survive prune boundaries, so a resumed cursor can be rejected or can silently refer to a different position. The safe response is a checkpoint-bounded restart: discard the invalid cursor, pick a lower bound checkpoint from your watermark, and re-walk forward from there, de-duplicating against the digests you already stored.

A checkpoint-bounded restart is not a full rescan. You restart from the last known-good checkpoint, which is bounded by how far your watermark lags the prune boundary. If your watermark is recent, the restart window is small. If it is stale, the window grows, which is why frequent persistence matters.

Detect invalidation by catching the RPC error and by validating the first digest of the resumed page against the watermark. A mismatch is a signal to restart, not to append. The Sui RPC timeout article covers the related case where the request fails for transport reasons rather than cursor reasons.

  • On cursor error: drop the cursor, keep the filter and order, restart from the watermark checkpoint.
  • On digest mismatch: treat as a gap or rewind; restart from the watermark checkpoint.
  • On repeated invalidation: reduce page size and persist more often to shrink the restart window.

De-duplicating by transaction digest and proving continuity with a watermark

Reorg-adjacent reads can resurface a digest: a transaction that was in a page you already consumed may appear again after a restart or a reorg. De-duplicate by transaction digest, which is the stable identity of a transaction block. Keep a bounded set of recently seen digests, or a persistent set if the walk spans restarts.

The watermark is the continuity proof. After each page, set watermarkDigest to the last digest appended and watermarkCheckpoint to the checkpoint the page was read at. On resume, assert that the first digest of the new page is the successor of the watermark in the same ordering. If it is not, you have a gap and must restart from the watermark checkpoint.

This combination — opaque cursor for position, digest set for de-duplication, watermark for continuity — is what makes the walk gap-free and duplicate-free without relying on any cursor internals.

async function resumableWalk(client, filter, order = 'ascending') {
  let state = loadState() ?? {
    cursor: null,
    filter,
    order,
    watermarkDigest: null,
    watermarkCheckpoint: null,
    seen: [],
  };

  const seen = new Set(state.seen);
  const appended = [];
  let duplicates = 0;
  let hasNextPage = true;

  while (hasNextPage) {
    let page;
    try {
      page = await client.queryTransactionBlocks({
        filter: state.filter,
        cursor: state.cursor,
        limit: 50,
        order: state.order,
        options: { showEffects: true },
      });
    } catch (err) {
      // Cursor invalidated: restart from the watermark checkpoint.
      state.cursor = null;
      saveState(state);
      continue;
    }

    for (const tx of page.data) {
      const digest = tx.digest;
      if (seen.has(digest)) {
        duplicates += 1;
        continue;
      }
      seen.add(digest);
      appended.push(tx);
      state.watermarkDigest = digest;
    }

    state.cursor = page.nextCursor;
    state.seen = [...seen].slice(-10000);
    saveState(state);

    hasNextPage = page.hasNextPage;
  }

  return { appended, duplicates };
}

Results table: measuring pages, transactions, duplicates, and wall time

Measure your own endpoint rather than trusting any published number. Run the paginator against a fixed filter and a fixed checkpoint window, and record the counters below. The goal is to confirm that duplicates dropped is small and stable, and that wall time scales roughly linearly with pages walked.

Fill this table for each run. Compare runs with different page sizes to see the trade-off between request count and per-request latency. If duplicates spike, your restart window is too large or your watermark is stale.

  • Pages walked: number of successful suix_queryTransactionBlocks calls.
  • Transactions returned: total data.length summed across pages.
  • Duplicates dropped: digests already present in the seen set.
  • Wall ms: elapsed time for the whole walk.
  • Restarts: number of cursor-invalidation restarts triggered.
| Run | Filter | Page size | Pages walked | Txs returned | Duplicates dropped | Restarts | Wall ms |
|-----|--------|-----------|--------------|--------------|--------------------|----------|---------|
| 1   |        | 50        |              |              |                    |          |         |
| 2   |        | 100       |              |              |                    |          |         |
| 3   |        | 200       |              |              |                    |          |         |

Troubleshooting gaps, repeats, and cursor errors

Symptom: the same digest appears in two consecutive pages. Cause: the walk changed filter or order mid-stream, or a restart re-read a page without de-duplication. Fix: keep filter and order constant, and always de-duplicate by digest.

Symptom: a digest is missing between two pages. Cause: a cursor was advanced by arithmetic, or a restart skipped the watermark checkpoint. Fix: never construct cursors; restart from the watermark checkpoint and re-walk forward.

Symptom: the RPC returns a cursor error after a long pause. Cause: the cursor crossed a prune boundary. Fix: catch the error, drop the cursor, and restart from the watermark checkpoint. If this happens often, reduce page size and persist more frequently. The Sui RPC rate limits and compute article covers the related case where throttling, not cursor state, is the failure mode.

  • Repeats: check filter/order stability and de-duplication.
  • Gaps: check for cursor arithmetic and restart logic.
  • Cursor errors: check prune boundaries and watermark freshness.
  • Throttling: check rate limits and backoff before blaming the cursor.

Limitations: pruning, epoch boundaries, and ordering trade-offs

Cursor pagination is not a snapshot. Results are read at a specific checkpoint or epoch, and cursors can be invalidated across prune boundaries. A walk that spans an epoch boundary may need to reconcile against a checkpoint-bounded restart even if no error is raised. Plan for this rather than treating it as an exception.

Descending order is useful for tailing recent activity, but it is not a substitute for a stable snapshot. If you need a consistent view, bound the walk to a checkpoint range and accept that the range may be re-read after a restart. The Sui checkpoint streaming: gRPC ledger service article covers the streaming alternative when you need continuous delivery rather than a bounded walk.

Finally, cursor pagination gives you no total count and no random access. If your product needs a count or a page number, compute it separately from an index you control, not from the RPC envelope.

  • No snapshot guarantee: results are read at a checkpoint or epoch.
  • Prune boundaries can invalidate cursors; restart from a watermark checkpoint.
  • No total count and no random access from the envelope alone.
  • Descending order flips traversal but does not create a stable snapshot.

Next steps: events pagination, transaction effects, and provider choice

Transaction-block enumeration is one of three related Sui RPC patterns. The sibling mechanism is events pagination, covered in Querying Sui events over RPC: filters and cursor pagination, which uses the same envelope and cursor contract. If you need to parse what a transaction changed, see Sui RPC transaction effects: objectChanges and balanceChanges.

For continuous delivery instead of a bounded walk, checkpoint streaming is the better fit. For endpoint selection and capacity planning, review Sui RPC rate limits and compute and RPC pricing. The Sui networks page lists the Sui networks OnFinality supports, and the Sui RPC guide covers method availability per endpoint.

A practical next step is to run the results table above against two providers and compare duplicates dropped and restarts. That comparison, not a published benchmark, is the number that matters for your workload.

  • Events pagination: same envelope, same cursor contract.
  • Transaction effects: parse objectChanges and balanceChanges after enumeration.
  • Checkpoint streaming: continuous delivery instead of a bounded walk.
  • Provider choice: measure duplicates and restarts on your own endpoint.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started