Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 14 min read

How We Scanned 1 Million Solana Token Accounts Without Timing Out

Our Holder Snapshot tool answers a simple question: who holds a token, and how much do they hold? Projects use it to airdrop tokens to another community, calculate governance voting power, or check whether a token claim

Our Holder Snapshot tool answers a simple question: who holds a token, and how much do they hold?

Projects use it to airdrop tokens to another community, calculate governance voting power, or check whether a token claiming to have 10,000 holders actually has 10,000 distinct owners.

For most tokens, this is straightforward. We fetch the token accounts, discard the empty ones, group balances by owner, and return the results.

Then we tried it with BONK.

With roughly a million token accounts to process, our first implementation fell apart. The scan timed out long before it could finish. We were fetching pages sequentially, and each page was taking longer to retrieve than the previous one.

Fixing pagination turned out to be only part of the problem. We also had to deal with a rate limit shared with other users, requests that could hang indefinitely, token balances larger than JavaScript's safe integer range, and CPU-intensive operations that froze the browser tab.

Here's how we redesigned the scanner and what we learned from testing it against a token of this size.

1. Why Counting Token Holders Is More Complicated Than It Looks

On Solana, a wallet doesn't hold a token balance directly. The balance lives in a token account, which records the mint, owner, and amount.

A wallet usually has an associated token account for each mint, but it can have multiple token accounts for the same token. Empty accounts can also remain on-chain until someone closes them.

That means counting token accounts isn't the same as counting holders.

To calculate the actual holder count, we need to:

  1. Fetch every token account belonging to the mint.
  2. Discard accounts with a zero balance.
  3. Group the remaining accounts by owner.
  4. Sum the balances belonging to each owner.

The grouping itself is easy. Retrieving every account efficiently is the difficult part.

We previously covered another aspect of this problem in Why Solana Holder Counts Differ.

2. Why Ordinary Pagination Didn't Scale

Our first implementation used getProgramAccounts with a memcmp filter on the mint.

This returns matching accounts in a single response. That works well for smaller datasets, but a token with a million accounts can produce an enormous response. Many RPC providers limit or disable this method for large mints.

We needed another way to retrieve the data.

Switching to the DAS API

Helius provides a paginated alternative through its Digital Asset Standard (DAS) API: getTokenAccounts.

It returns up to 1,000 accounts per request, including fields such as the owner, amount, and frozen status. We can also exclude zero-balance accounts.

Our initial implementation simply requested page 1, then page 2, then page 3, continuing until the list was exhausted.

const LIMIT = 1000;

for (let page = 1; ; page++) {
  const accounts = await getTokenAccounts({
    mint,
    page,
    limit: LIMIT,
  });

  addPage(accounts);

  if (accounts.length < LIMIT) {
    break;
  }
}

This is a simplified illustration of our original approach, not the complete production implementation.

It worked for smaller tokens. BONK exposed its limitations.

Through our production proxy, a request for page 60 took 12.8 seconds. Requests around page 200 and beyond timed out after 15 seconds because of an upstream database timeout. After we passed 500,000 accounts, the API refused the request and recommended cursor-based pagination.

The underlying problem was offset pagination.

To return page N, a database may have to walk past the rows that precede it. As the page number increases, the work can become more expensive.

With a million accounts, we're looking at more than 1,000 pages. If later pages take increasingly long to retrieve, the entire scan becomes impractical.

We needed a way to move through the dataset without repeatedly paying the cost of skipping records we'd already retrieved.

3. Switching to Keyset Pagination

Helius's getTokenAccounts supports a cursor parameter, along with after and before bounds on account addresses.

These let us use keyset pagination instead of relying on page numbers.

Rather than asking for page 500, we can ask for the next 1,000 accounts after a particular address.

The cost doesn't grow in the same way as offset pagination because we continue from a known position.

However, a cursor alone wouldn't solve everything. Each request would still depend on the previous one, since we need the previous page's final cursor before requesting the next page.

With more than 1,000 pages to process, we wanted to parallelize the scan.

The after and before parameters gave us another option: split the address space into independent ranges and scan them concurrently.

One important detail: address ordering

Solana addresses are represented as Base58 strings, but the API sorts addresses by their underlying 32 bytes.

Sorting the strings lexicographically doesn't necessarily produce the same order as sorting those bytes. If we built our range boundaries using string comparisons, we could introduce gaps or overlapping ranges.

We used PublicKey from @solana/web3.js to convert addresses into bytes and estimate their positions in the address space.

import { PublicKey } from "@solana/web3.js";

// Approximate position in the address space, from 0 to 1.
const position = (address) => {
  const b = new PublicKey(address).toBytes();

  return (
    b[0] / 256 +
    b[1] / 65536 +
    b[2] / 16777216 +
    b[3] / 4294967296
  );
};

// Build an address whose first byte is `byte`.
const edge = (byte) => {
  const b = new Uint8Array(32);
  b[0] = byte;

  return new PublicKey(b).toBase58();
};

The position function gives us an approximate location between zero and one based on the first four bytes.

The edge function constructs an address at a specific byte boundary.

Together, they provide the building blocks for dividing the address space into independently scannable ranges.

4. Estimating the Token's Size Before Scanning Everything

Before starting an expensive scan, we wanted to know whether a token had a few hundred accounts or several million.

The first page gives us a useful estimate without requiring another RPC call.

Associated token accounts are program-derived addresses generated using SHA-256, while many other accounts are random keypairs or addresses derived by other programs. In practice, their addresses are distributed across the address space well enough to make a rough estimate useful.

If the first 1,000 accounts reach a fraction (p) of the way through the address space, we estimate the total number of accounts as:

$$
\text{Estimated accounts} \approx \frac{1000}{p}
$$

Here's how we use the first page:

const LIMIT = 1000;

const first = await call({});
addPage(first);

if (first.length < LIMIT) {
  return;
}

const firstEnd = first.at(-1).address;
const firstPos = position(firstEnd);

const estimate = LIMIT / firstPos;

if (estimate > MAX_SCAN_ACCOUNTS * 1.1) {
  throw tooMany(estimate);
}

This estimate serves three purposes.

First, it lets us finish small scans immediately. Most tokens have fewer than 1,000 accounts, so we can return their results after a single request.

Second, it lets us reject oversized scans early. We set our limit at 1.5 million estimated accounts, roughly what we're willing to process in a browser tab. USDC has around 9.8 million token accounts, so we can identify it as too large after the first request instead of making users wait several minutes.

Third, it improves the progress indicator. We can compare the number of accounts retrieved against the estimated total instead of presenting the number fetched as though it were the final count.

The estimate isn't an exact count, and we never use it as a substitute for scanning the actual accounts. It's an early signal that helps us decide whether to continue and gives users a reasonable idea of how much work remains.

5. Splitting the Address Space Into 16 Lanes

With the range boundaries in place, we divided the address space into 16 lanes based on the first byte:

  • Lane 0: 0x00–0x0f
  • Lane 1: 0x10–0x1f
  • ...
  • Lane 15: 0xf0–0xff

Each lane uses after to continue from the last address it retrieved and before to stay within its upper boundary.

Since the initial request already covered the beginning of the address space, lane 0 starts where that request ended.

Here's the core implementation:

const LANES = 16;

async function lane(i) {
  const lo = i / LANES;
  const hi = (i + 1) / LANES;

  // The first request has already covered this lane.
  if (firstPos >= hi) return;

  let after =
    firstPos > lo
      ? firstEnd
      : edge(i * (256 / LANES));

  const before =
    i === LANES - 1
      ? undefined
      : edge((i + 1) * (256 / LANES));

  for (;;) {
    const page = await call(
      before ? { after, before } : { after }
    );

    addPage(page);

    if (page.length < LIMIT) return;

    after = page.at(-1).address;
  }
}

await Promise.all(
  Array.from({ length: LANES }, (_, i) => lane(i))
);

Each lane processes its own range. The lanes run concurrently through Promise.all, while requests within an individual lane remain sequential because every request needs the previous page's final address.

The underlying RPC call is a thin wrapper around getTokenAccounts:

await rpcCall(endpoint, "getTokenAccounts", {
  mint,
  limit: 1000,
  displayOptions: {
    showZeroBalance: false,
  },
  ...range,
});

Because each lane covers a separate range, accounts shouldn't appear in multiple lanes. In our BONK run, we didn't see any duplicate accounts.

Parallelizing the scan solved the sequential-pagination bottleneck, but it introduced another problem: we could suddenly send many requests at once.

That brings us to rate limiting.

6. Managing a Rate Limit Shared With Other Users

Our 16 lanes could easily generate more requests than our Helius plan allowed.

We started receiving HTTP 429 responses at around 6–8 DAS calls per second. The limit was shared across everyone using the site, including our Telegram bot.

Letting a single large scan consume the entire request budget would affect other users, so we needed a rate limiter shared by all lanes rather than a separate limit for every worker.

An adaptive token bucket

We implemented a token bucket that:

  • Starts at eight requests per second.
  • Reduces the rate to 70% whenever a request receives a 429 response.
  • Increases the rate by 0.1 requests per second after each successful request.

This follows the general principle of additive-increase, multiplicative-decrease (AIMD), which is also used by TCP congestion-control algorithms.

Here's the core of the rate limiter:

function pacer(max) {
  let rate = max;
  let tokens = max;
  let last = Date.now();

  return {
    async take() {
      for (;;) {
        const now = Date.now();

        tokens = Math.min(
          max,
          tokens + ((now - last) / 1000) * rate
        );

        last = now;

        if (tokens >= 1) {
          tokens -= 1;
          return;
        }

        await sleep(((1 - tokens) / rate) * 1000);
      }
    },

    slower() {
      rate = Math.max(1, rate * 0.7);
      tokens = Math.min(tokens, 0);
    },

    faster() {
      rate = Math.min(max, rate + 0.1);
    },
  };
}

You might wonder why we kept 16 lanes when the rate limiter only allows around eight requests per second.

Because concurrency and throughput aren't the same thing.

A request might take a second or longer to return. Having enough work in flight helps ensure the limiter doesn't sit idle while waiting for a lane to become available.

Sixteen lanes kept requests ready without allowing them to bypass the shared rate limit. Adding more lanes would mostly create a longer queue.

Handling non-JSON error responses

We also ran into a smaller but important issue with HTTP 429 responses.

Our original code assumed every response contained JSON and called res.json() directly. The upstream rate-limit response was plain text, so JSON parsing failed before we could inspect the HTTP status.

We changed the response handling to read the body as text first and parse it only when possible:

const text = await res.text();

let j = null;

try {
  j = JSON.parse(text);
} catch {
  // Could be a plain-text 429 or an error page.
}

if (res.status === 429 || !j) {
  const e = new Error(
    res.status === 429
      ? "Rate limited"
      : `RPC returned ${res.status}`
  );

  e.code = res.status;
  throw e;
}

This lets the retry logic distinguish rate-limit responses from successful JSON responses and react accordingly.

Without this change, the code could fail while trying to parse the very response it needed to handle.

7. Putting a Timeout on Every Request

Another issue appeared during testing: fetch doesn't time out by default.

We had one request that stayed stuck for 300 seconds, holding its lane while the other 15 lanes finished their work.

Even with parallelism, a single request that never returns can keep the entire scan waiting.

We added an AbortController with a 25-second timeout to every request, along with exponential backoff and a maximum of eight attempts:

for (let attempt = 1; ; attempt += 1) {
  await pace.take();

  const ac = new AbortController();

  const timer = setTimeout(
    () => ac.abort(),
    25_000
  );

  try {
    const r = await rpcCall(
      endpoint,
      "getTokenAccounts",
      params,
      { signal: ac.signal }
    );

    pace.faster();

    return r?.token_accounts || [];
  } catch (e) {
    if (attempt >= 8) throw e;

    if (e.code === 429) {
      pace.slower();
    }

    await sleep(
      Math.min(8000, 400 * 2 ** attempt)
    );
  } finally {
    clearTimeout(timer);
  }
}

If a lane eventually fails after exhausting its retries, we set a shared stopped flag. The other lanes check that flag before making their next request and stop as well.

There's no point finishing a scan if we already know we can't produce a complete result. Cancelling the remaining work also avoids wasting the shared RPC budget.

8. Aggregating Results Without Keeping Every Account in Memory

Once the network side was under control, we looked at how we stored the results.

We didn't need to retain every raw page after processing it. All we needed was the total balance and the number of token accounts belonging to each owner.

So we folded each page directly into a Map keyed by owner.

const byOwner = new Map();

function add(owner, amount /* BigInt */) {
  if (amount <= 0n) return;

  const existing = byOwner.get(owner);

  if (existing) {
    existing.amount += amount;
    existing.accounts += 1;
  } else {
    byOwner.set(owner, {
      amount,
      accounts: 1,
    });
  }
}

This keeps the aggregated results instead of retaining an additional copy of the entire raw account list.

Why we used BigInt for token balances

The BigInt type is important here.

JavaScript numbers can't represent every integer exactly above Number.MAX_SAFE_INTEGER. Token balances can easily exceed that limit when expressed in raw base units.

BONK has five decimals and a supply of roughly 88 trillion tokens. Its raw supply is:

8,799,435,602,096,182,068

That's about 977 times Number.MAX_SAFE_INTEGER.

If we added large balances using ordinary JavaScript numbers, lower digits could silently disappear and produce incorrect totals.

We therefore keep raw amounts as BigInt throughout aggregation and convert them into display-friendly numbers only at the end, when preparing each owner's balance for presentation.

The principle is simple: preserve exact integer arithmetic during the part of the pipeline where accuracy matters most.

9. Classifying Wallets and Program-Owned Addresses

The largest holder of a token isn't necessarily a person.

It might be a liquidity pool, a launch curve, a token lock, or another program.

Many of these accounts are represented by program-derived addresses (PDAs). These addresses are deliberately derived so they don't lie on the Ed25519 curve, meaning they don't have a corresponding private key that someone can use to sign transactions directly.

Solana's PublicKey.isOnCurve method lets us distinguish on-curve addresses from off-curve ones.

The problem is that running this check a million times is expensive.

In our measurements, each check took around 57 microseconds. Multiply that by a million addresses, and you're looking at nearly a minute of CPU time.

Running all of that on the main thread freezes the page, even after the network requests have finished.

Moving CPU-heavy work into Web Workers

We moved this work into Web Workers once the list exceeds 20,000 owners.

The implementation uses the available hardware concurrency, reserves a core where possible, and caps the worker count at eight:

export async function onCurveFlags(addrs) {
  if (
    addrs.length < 20_000 ||
    typeof Worker === "undefined"
  ) {
    return onMainThread(addrs);
  }

  const n = Math.min(
    8,
    Math.max(1, (navigator.hardwareConcurrency || 4) - 1)
  );

  const size = Math.ceil(addrs.length / n);
  const slices = [];

  for (let i = 0; i < addrs.length; i += size) {
    slices.push(addrs.slice(i, i + size));
  }

  // Fall back to the main thread if a worker fails.
  const parts = await Promise.all(
    slices.map((s) =>
      inWorker(s).catch(() => onMainThread(s))
    )
  );

  const flags = new Uint8Array(addrs.length);

  let at = 0;

  for (const p of parts) {
    flags.set(p, at);
    at += p.length;
  }

  return flags;
}

Each worker decodes Base58 addresses and uses @noble/curves directly, then transfers its Uint8Array back to the main thread.

For smaller lists, or browsers without worker support, we use the main-thread implementation and yield every 2,000 checks so the browser can continue painting and responding to user input.

Once we've identified off-curve owners, we make getMultipleAccounts calls in batches of 100 to identify the programs behind them.

This lets us label accounts such as Raydium pools, pump.fun bonding curves, token locks, and multisigs instead of presenting every address as an ordinary wallet.

10. The Results

We tested the finished implementation against BONK on October 13, 2026, using headless Chromium and our production build.

Here's what the scan returned:

Metric Result
Token accounts with a balance Approximately 1.025 million
Distinct owners 1,020,085
Wallet addresses 1,006,273
Program-owned addresses 13,812
getTokenAccounts calls Approximately 1,110
Total scan time 3 minutes, 44 seconds
Worker count for classification 7
Duplicate accounts observed 0
Page errors 0

At a steady rate of eight requests per second, 1,110 calls would take roughly 2 minutes and 19 seconds.

The remaining time came from rate-limit backoffs against the shared request budget and the work required to classify and label the owners.

For RPC providers that don't support the DAS method, the scanner has fallback paths: Helius's paginated getProgramAccountsV2, followed by the standard RPC method getParsedProgramAccounts.

These alternatives have their own limitations, but they let us support providers with different capabilities rather than making the entire tool depend on a single API.

11. What We'd Do Differently Next Time

The biggest lesson is that a solution that works for a few thousand accounts doesn't necessarily scale to a million.

Our original pagination strategy was perfectly reasonable for small lists, but its cost grew with the size of the dataset. If a list can reach hundreds of thousands of records, we'd look for keyset pagination from the start and verify the ordering of the underlying keys before designing parallel ranges.

We'd also estimate the workload before committing to it.

The first page gave us enough information to decide whether a scan was worth attempting, reject oversized tokens early, and display a more useful progress estimate.

On the networking side, we learned to pace requests against the shared API budget rather than setting an independent concurrency limit for every worker. A fixed concurrency limit can either leave capacity unused or overwhelm the provider. An adaptive rate limiter gives us a better way to respond to changing conditions.

Finally, network performance isn't the whole story when the work runs in a browser.

Every fetch needs a timeout. Retries need sensible limits. CPU-heavy operations need to move off the main thread when the dataset gets large enough.

And using BigInt for raw token balances was equally important: a fast scan isn't useful if the numbers it returns aren't exact.

Try It Yourself

The Holder Snapshot tool is part of Solstack, our collection of Solana token tools.

Paste a mint address to start a scan. The scan and first 200 rows are free.

If you've worked on large paginated datasets, RPC rate limiting, or browser-side data processing, we'd be interested to hear how you approached similar problems. Feel free to leave questions or feedback in the comments.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.