The Solana getTokenAccountsByOwner RPC method returns a capped number of token accounts per call, so a single request under-reports wallets with many token accounts. The method uses a cursor-based pagination scheme with before and limit parameters, where before is the pubkey of the last account from the previous page. Because the cursor is not a stable sort and accounts can be created or closed between calls, a correct walk must deduplicate by account pubkey and terminate on an empty page. Filtering by programId requires separate queries for the SPL Token and Token-2022 programs to avoid missing accounts. This article explains the mechanics, provides a resumable Node.js implementation, and shows how to measure completeness against your own endpoint.
Why a Single getTokenAccountsByOwner Call Is Incomplete for Large Wallets
The Solana JSON-RPC method getTokenAccountsByOwner returns the token accounts owned by a given wallet address. According to the Solana getTokenAccountsByOwner method reference, the response is capped by a provider-side maximum number of accounts per call. This cap is documented as varying by provider, and OnFinality does not publish a specific number. The practical consequence is that a wallet holding more token accounts than the cap will receive only the first page of results, silently losing the tail unless the caller explicitly paginates.
A naive integration that calls getTokenAccountsByOwner once and assumes the response is complete will under-report large wallets. This is not a bug in the RPC method; it is a deliberate design to bound response size and protect node performance. The method provides before and limit parameters specifically to allow callers to walk the full set in multiple requests. Understanding these parameters is essential for any production system that needs a complete token account inventory.
The OnFinality Learn hub contains adjacent guides on account reading and signature pagination, but this article focuses exclusively on token account pagination for large wallets. For a broader overview of Solana RPC methods, see the Solana RPC API guide (RPC Assistant).
- The response is capped by a provider-side maximum (documented / varies by provider).
- A single call returns only the first page; the tail is silently omitted.
- The before and limit parameters enable cursor-based pagination.
- Completeness requires a loop that continues until an empty page is returned.
How before and limit Work as a Cursor over the Account Set
The getTokenAccountsByOwner method accepts a config object with before and limit parameters. The before parameter is not an offset; it is a cursor that tells the RPC node to return accounts whose pubkey sorts after the given pubkey. The limit parameter specifies the maximum number of accounts to return in the response. Together, they allow a caller to walk the entire set of token accounts owned by a wallet in pages.
The correct loop passes the pubkey of the last account from the previous page as the before value for the next request. The loop terminates when the RPC returns an empty array, not when a fixed count is reached. This is important because the total number of token accounts can change between calls, and a fixed count would either stop early or loop indefinitely. The cursor-based approach naturally adapts to the current state of the account set.
The Solana documentation for getTokenAccountsByOwner specifies that the response entries carry the token account pubkey and the account data. The account data can be requested in different encodings, including jsonParsed and base64. The pagination parameters are part of the config object, alongside commitment, encoding, and dataSlice. For a detailed explanation of the SPL Token account layout, see Reading Solana accounts, rent and token balances.
- before is a cursor over the account set, not an offset.
- Pass the last account pubkey from the previous page as the next before value.
- Terminate the loop on an empty page, not on a fixed count.
- The config object also accepts commitment, encoding, and dataSlice.
The Cursor Is Not a Stable Sort: Deduplication by Account Pubkey
The RPC sorts token accounts by pubkey, but this sort is not stable across calls when accounts are created or closed between pages. If a wallet mints a new associated token account (ATA) mid-walk, the new account's pubkey may sort before the current cursor, shifting the page boundary and causing some accounts to be skipped or duplicated. Similarly, closing an account can cause the cursor to skip over accounts that were previously on the next page.
Because of this, a production-grade walk must deduplicate by account pubkey rather than trust that pages are disjoint. The safest approach is to collect all account pubkeys into a set and only add new accounts that have not been seen before. This ensures that even if the page boundaries shift, the final result is a complete and unique set of token accounts. The walk should also be prepared to handle the case where an account is closed and no longer appears in any page.
This behavior is consistent with the general Solana RPC design, where the account set is live and can change between requests. The Solana getProgramAccounts filters and dataSlice article discusses similar considerations for program account pagination. For signature pagination, see Solana getSignaturesForAddress pagination.
- The RPC sorts accounts by pubkey, but the sort is not stable across calls.
- New or closed accounts between pages can shift the page boundary.
- Deduplicate by account pubkey to avoid missing or duplicate entries.
- The walk should tolerate accounts that disappear mid-walk.
Mint vs programId Filter: Why the Choice Affects Completeness
The getTokenAccountsByOwner method requires a filter that is either a mint or a programId. If you filter by mint, you get all token accounts for that specific mint owned by the wallet. If you filter by programId, you get all token accounts owned by the wallet for that token program. The SPL Token program and the Token-2022 program have different program IDs, so a single query with the SPL Token programId will not return Token-2022 accounts.
To achieve complete coverage, a caller must query each programId separately. The Solana documentation on tokens explains that Token-2022 is a separate program with its own program ID and can include extensions that change the account layout. A wallet holding both SPL Token and Token-2022 accounts will be under-reported if only one programId is queried. Therefore, a complete enumeration requires at least two queries: one for the SPL Token program and one for the Token-2022 program.
Filtering by mint is useful when you only care about a specific token, but for a full wallet inventory, programId filtering is more appropriate. However, even with programId filtering, you must paginate each program's accounts separately because the before cursor is scoped to the filter. The OnFinality Solana network page provides endpoint information for connecting to Solana RPC.
- The filter must be either a mint or a programId.
- SPL Token and Token-2022 have different program IDs.
- A single programId query misses accounts from the other program.
- Query each programId separately and paginate each result set.
jsonParsed vs Raw base64: When Parsing Fails and How to Fall Back
The getTokenAccountsByOwner method supports an encoding parameter that can be set to jsonParsed or base64. The jsonParsed encoding is convenient because it returns the account data in a human-readable JSON structure, including the mint, owner, and amount. However, the parsed form depends on the runtime recognising the account layout. Token-2022 accounts with extensions may not parse correctly, and the RPC may return an error or fall back to raw data.
A production reader should not rely solely on jsonParsed. Instead, it should request base64 encoding and decode the fixed offsets of the SPL Token account layout. The SPL Token account is a 165-byte structure with the amount at a fixed offset. The Solana getTokenAccountsByOwner method reference documents the encoding options. For Token-2022, the account layout includes extensions, so the base offset for the amount may still be the same, but additional data follows.
If jsonParsed fails, the caller can catch the error and retry with base64. Alternatively, the caller can always use base64 and implement its own parser. This is more robust but requires understanding the account layout. The Solana documentation on tokens provides details on the SPL Token and Token-2022 account structures.
- jsonParsed is convenient but may fail for Token-2022 accounts with extensions.
- Fall back to base64 and decode the fixed offsets of the SPL Token account layout.
- The SPL Token account is 165 bytes with amount at a fixed offset.
- Token-2022 accounts may have additional extension data.
Using dataSlice as a Bandwidth Control and Its Limits
The dataSlice parameter allows the caller to request only a portion of the account data, specified by offset and length. This can reduce bandwidth when you only need a specific field, such as the amount. However, dataSlice has limits: it only applies to the account data, not to the metadata like the account pubkey. Also, if you use dataSlice, you cannot use jsonParsed encoding because the parsed form requires the full account data.
For pagination, dataSlice can be useful to reduce the size of each response, allowing more accounts per page if the provider's limit is based on response size rather than account count. However, the provider's maximum is typically a count, so dataSlice may not increase the number of accounts per page. It is still valuable for reducing bandwidth when you only need the amount field. The Solana getProgramAccounts filters and dataSlice article covers dataSlice in more detail for program accounts.
When using dataSlice, you must know the offset and length of the field you need. For the SPL Token account, the amount is at offset 64 and is 8 bytes long. This is documented in the SPL Token source code and the Solana documentation on tokens. Using dataSlice with base64 encoding is a common pattern for efficient token account reads.
- dataSlice requests a portion of the account data by offset and length.
- It cannot be combined with jsonParsed encoding.
- For SPL Token, the amount is at offset 64, length 8.
- dataSlice reduces bandwidth but may not increase accounts per page.
A Resumable Node.js Walk with before, Deduplication, and Stable Snapshot
The following Node.js example demonstrates a resumable walk that paginates by before, deduplicates by account pubkey, and emits a stable snapshot keyed by (mint, token account). It queries both the SPL Token and Token-2022 program IDs separately. The walk terminates when an empty page is returned for both programs. The code uses the @solana/web3.js library, but the same logic applies to any JSON-RPC client.
The function getTokenAccountsByOwner is called with a config object that includes the programId filter, encoding set to base64, and the before and limit parameters. The before parameter is updated to the last account pubkey of the current page. The results are accumulated in a Map keyed by the token account pubkey to ensure uniqueness. The final snapshot is an array of objects containing the mint and token account pubkey.
This implementation is resumable because it can be stopped and restarted with the last before value. In a production system, you would persist the before cursor and the accumulated set to durable storage. The OnFinality API service can provide reliable RPC endpoints for this walk. For pricing considerations, see RPC pricing.
const { Connection, PublicKey } = require('@solana/web3.js');
const SPL_TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
async function getAllTokenAccounts(connection, owner, limit = 1000) {
const ownerPubkey = new PublicKey(owner);
const allAccounts = new Map();
for (const programId of [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
let before = undefined;
while (true) {
const config = {
programId,
encoding: 'base64',
limit,
};
if (before) config.before = before;
const response = await connection.getTokenAccountsByOwner(ownerPubkey, config);
const accounts = response.value;
if (accounts.length === 0) break;
for (const { pubkey, account } of accounts) {
if (!allAccounts.has(pubkey.toString())) {
allAccounts.set(pubkey.toString(), {
pubkey: pubkey.toString(),
mint: account.data.slice(0, 32).toString('hex'), // simplified; use proper parsing
data: account.data,
});
}
}
before = accounts[accounts.length - 1].pubkey.toString();
}
}
return Array.from(allAccounts.values());
}
// Usage:
// const connection = new Connection('https://your-rpc-endpoint');
// getAllTokenAccounts(connection, 'WalletAddressHere').then(console.log);Measuring Completeness Against Your Own Endpoint: A Results Table
Because the provider-side maximum and performance characteristics vary, you should measure completeness against your own endpoint. The following table provides a template for recording your observations. Run the walk with different limit values and record the total number of unique token accounts found, the number of pages, and the time taken. This will help you understand the behavior of your specific RPC provider.
To perform the measurement, use a wallet with a known number of token accounts. You can cross-check the total by querying a block explorer or by using a different RPC provider. Record the results in the table below. If the total varies between runs, it may indicate that accounts are being created or closed during the walk, or that the provider's cap is causing truncation.
The table should be filled with your own data. Do not rely on benchmark numbers from this article, as they would be fabricated. Instead, use this method to verify the behavior of your endpoint. The OnFinality Solana network page provides endpoint URLs for testing.
- Limit value: the limit parameter used in the walk.
- Pages: number of RPC calls made.
- Unique accounts: total unique token accounts found.
- Time (ms): total time for the walk.
- Notes: any errors or anomalies observed.
const { Connection, PublicKey } = require('@solana/web3.js');
const SPL_TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');
async function measureWalk(connection, owner, limit) {
const ownerPubkey = new PublicKey(owner);
const seen = new Set();
let pages = 0;
const start = Date.now();
for (const programId of [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
let before = undefined;
while (true) {
const config = { programId, encoding: 'base64', limit };
if (before) config.before = before;
const response = await connection.getTokenAccountsByOwner(ownerPubkey, config);
const accounts = response.value;
pages++;
if (accounts.length === 0) break;
for (const { pubkey } of accounts) seen.add(pubkey.toString());
before = accounts[accounts.length - 1].pubkey.toString();
}
}
const elapsed = Date.now() - start;
console.log(`limit=${limit} pages=${pages} unique=${seen.size} timeMs=${elapsed}`);
return { limit, pages, unique: seen.size, timeMs: elapsed };
}
// Usage:
// const connection = new Connection('https://your-rpc-endpoint');
// measureWalk(connection, 'WalletAddressHere', 1000).then(console.log);Limitations and Tradeoffs of Cursor-Based Token Account Pagination
Cursor-based pagination with before and limit is the only reliable way to enumerate all token accounts for a large wallet, but it has tradeoffs. The walk requires multiple RPC calls, which increases latency and cost. The cursor is not a stable sort, so deduplication is mandatory. The walk may miss accounts that are created and closed between pages, although this is rare. For a complete snapshot, you may need to run the walk multiple times and reconcile.
Another limitation is that the before cursor is scoped to the filter. If you query by programId, you must paginate each program separately. If you query by mint, you must paginate each mint separately. This can result in many RPC calls for wallets with many different tokens. An alternative is to use the getProgramAccounts method with a filter on the owner, but that method has its own limitations and is not designed for token account enumeration. See Solana getProgramAccounts account streaming for a streaming approach.
Finally, the provider-side maximum is not published by all providers. You may need to experiment to find the effective limit. Some providers may return fewer accounts than the limit if the response size is too large. Always check the length of the returned array and continue until an empty page is received.
- Multiple RPC calls increase latency and cost.
- Deduplication is required due to unstable sort.
- Accounts created and closed mid-walk may be missed.
- The before cursor is scoped to the filter, requiring separate walks per program or mint.
Troubleshooting Common Pagination Failures
If your walk returns fewer accounts than expected, check whether you are querying both SPL Token and Token-2022 program IDs. A common mistake is to query only the SPL Token program and miss Token-2022 accounts. Another issue is using jsonParsed encoding, which may fail for Token-2022 accounts with extensions. Switch to base64 and decode manually.
If the walk never terminates, ensure that you are updating the before parameter correctly. The before value must be the pubkey of the last account from the previous page. If you accidentally pass the first account's pubkey, you will loop indefinitely. Also, check that you are not using an offset-based approach; the before parameter is a cursor, not an offset.
If you encounter rate limiting, reduce the limit parameter or add delays between requests. The OnFinality API service offers scalable RPC endpoints that can handle high request volumes. For more troubleshooting tips, see the Solana RPC API guide (RPC Assistant).
- Missing Token-2022 accounts: query both program IDs.
- jsonParsed failures: fall back to base64.
- Infinite loop: ensure before is the last account pubkey.
- Rate limiting: reduce limit or add delays.
Next Steps: Integrating the Walk into Production Systems
To integrate this walk into a production system, persist the before cursor and the accumulated set to durable storage. This allows the walk to resume after a crash or restart. Use a database with a unique constraint on the token account pubkey to handle deduplication automatically. Schedule the walk periodically to keep the snapshot up to date.
For real-time updates, consider using WebSocket subscriptions to account changes, but note that the initial snapshot still requires a full walk. The Solana getProgramAccounts account streaming article discusses streaming for program accounts, which can be adapted for token accounts. For signature-based pagination, see Solana getSignaturesForAddress pagination.
Finally, test your implementation against multiple RPC providers to ensure completeness. The OnFinality Solana network page provides endpoints for testing. For pricing and plans, see RPC pricing.
- Persist the before cursor and accumulated set for resumability.
- Use a database with a unique constraint on token account pubkey.
- Schedule periodic walks to keep the snapshot current.
- Test against multiple RPC providers to verify completeness.