logo

Parsing and Displaying Token Balances (ERC-20): The Small Detail That Breaks Wallet UIs

A computer screen displaying an image of a computer alongside another computer, illustrating a tech setup.

You can build a clean transfer flow, ship it, and still get a surprising complaint: “My balance is wrong.”

The chain says the funds arrived. A block explorer agrees. Your app shows zero, or a number that looks off by a few zeros, or a value that flickers between two formats as the screen refreshes. That’s when you remember the uncomfortable truth of ERC-20 UX: balances are simple on-chain, and surprisingly easy to misrepresent in a UI. So the real work is less about fetching a number and more about turning it into something a human can trust.

What an ERC-20 “balance” actually is on-chain

The cleanest way to make balanced displays reliable is to treat them as a translation layer. You’re translating a contract’s storage convention into a human-facing unit system. If the translation rules are explicit, your UI becomes predictable.

balanceOf returns an integer, and the integer is the product

In the ERC-20 standard, balanceOf(address) returns a uint256. That value is the token’s internal unit count, not a decimal string meant for people. The standard stays intentionally minimal, and it doesn’t prescribe how user interfaces should display amounts. 

This is where many implementations drift. A developer sees “18 decimals are common” and treats it as a law of nature. Another developer tests with a token that uses 6 decimals and adds a special case. A third developer stores balances in JavaScript Number and gets a rounding surprise the first time a user has a large holding.

A stable mental model helps: the integer returned by balanceOf is the authoritative truth. Everything else is presentation.

decimals() is optional, so your UI needs a fallback plan

The ERC-20 spec defines decimals() as an optional method. It’s a usability aid, and interfaces must not assume it exists. In practice, most modern tokens implement it, and many follow the metadata extension pattern used in libraries such as OpenZeppelin’s IERC20Metadata.

Optional does not mean rare. Optional means “your app needs a plan for absence, failure, and inconsistency.” A token can revert on decimals(). A proxy can upgrade and change behavior. A token can return a value that surprises you. A chain can fork, and your cached metadata can become stale. None of these are daily events, and they show up exactly when users least want uncertainty.

A practical approach is to define one fallback rule that is consistent across the product. For example, treat missing decimals as 18 for display, while surfacing the raw unit count in debug views. The choice matters less than the consistency, because consistency is what keeps support conversations short.

The balance pipeline in a UI: Raw call to formatted string

A good balance pipeline separates “data acquisition” from “formatting.” That separation keeps you from mixing network edge cases with locale formatting bugs, and it makes the system testable with snapshots.

A minimal JSON-RPC read: eth_call with balanceOf(address)

Under the hood, fetching a balance is usually an eth_call against the token contract with ABI-encoded calldata for balanceOf(address). Ethereum’s JSON-RPC docs describe eth_call as a read-only smart contract execution used for functions like ERC-20 balanceOf. 

If you use a library like ethers.js or web3.js, it will encode calldata and decode the return value for you. The value you receive is still a big integer representing token base units. That’s the point where you decide how your code represents quantities internally.

Units and precision: Choosing BigInt/BigNumber early

The fastest way to create display bugs is to convert a uint256 to a floating-point number and hope it behaves. JavaScript’s Number is a 64-bit floating-point type. It can’t safely represent large integers, and it will silently lose precision. ERC-20 balances can exceed that range in perfectly normal use cases, even if the user holds only a few thousand tokens, depending on decimals and token supply distribution.

Your signing layer already treats amounts as exact integers. Your display layer should do the same. That often means one of these patterns:

  • BigInt end-to-end, then string formatting at the boundary.
  • Library big integer types, then formatting utilities at the boundary.

ethers.js documents formatUnits as a tool to format a big integer into a human string using a specified decimal count, and parseUnits as the inverse for user input. web3.js provides utilities in web3.utils for unit conversion and BN-based handling, and it explicitly treats these as utility primitives for dapp development. 

There’s also a “gotcha” that shows up in real codebases: conversion helpers that accept numbers may still break when the numeric literal becomes large, and JavaScript switches to scientific notation. A long-standing web3.js issue thread illustrates the risk of relying on implicit casting when converting large values. Even if your product never sends 1000 ETH in one go, the lesson transfers: keep big values as strings or big integers.

Rounding rules that match user expectations

People don’t read balances the way machines do. A raw string like 0.000000000000000123 is accurate, and it’s also unhelpful. A display like 0.00 is tidy, and it’s also misleading when the user has funds.

This is where a balance pipeline becomes a product decision. Decide the rules once, then implement them everywhere. To keep this concrete, here is a single numbered flow you can apply regardless of whether you fetch balances via ethers.js, web3.js, or raw JSON-RPC:

  • Fetch the raw integer amount via eth_call to balanceOf, keep it as a big integer type.
  • Resolve decimals from decimals() with a timeout and a fallback, then cache the result per (chainId, tokenAddress). 
  • Convert to a decimal string using an exact unit formatter such as formatUnits(raw, decimals), then avoid floating-point math during formatting. 
  • Apply display policy: choose significant digits for tiny values, cap decimals for medium values, and show separators for large values.
  • Store both forms in state: raw integer for logic and auditing, formatted string for UI, and keep the mapping stable across screens.

This flow looks almost boring. That’s a compliment. Boring is what makes balances trustworthy.

Metadata strategy: Decimals, symbol, and the problems with “just fetch it”

Token metadata feels like an easy win: call symbol(), call decimals(), call name(), and display a friendly label. Reality is that metadata is one of the least reliable parts of the ERC-20 ecosystem, partly because the base standard keeps it optional.

IERC20Metadata exists, reality remains messy

Many tokens implement the metadata extension, and libraries such as OpenZeppelin separate the base IERC20 interface from IERC20Metadata, which includes name, symbol, and decimals. That separation is a helpful reminder: metadata is an extension, and you should treat it as best effort.

Two practical failure modes show up frequently. The first is a type mismatch and odd encoding, especially on older tokens, where symbol strings can behave inconsistently. The second is proxy and upgrade patterns, where metadata calls can revert depending on configuration or chain conditions. Your UI should tolerate missing metadata without breaking layout or turning balances into NaN.

A reliable display system treats metadata as an enhancement. The balance still displays using a safe default, and the token address remains available as an identity anchor when symbols collide.

Caching and invalidation that stays safe under chain switches

Caching metadata is almost always necessary for performance. Calling decimals() for every render across dozens of tokens creates an avoidable RPC tax. Caching creates its own failure mode: stale values after a network change, or collisions when the same token address exists on multiple networks with different behavior.

The safest cache key is a composite: chain ID plus contract address. The next step is to decide how long metadata lives. Decimals rarely change for a token, yet “rarely” is still not “never,” and proxies exist. Symbol and name are even more subject to change or inconsistency.

Your product doesn’t need an elaborate invalidation system. It needs a simple one that avoids the worst surprises. For example, clear the metadata cache when the user switches networks, and refresh the metadata on an interval for tokens that have recently failed calls. These are operational rules, and they belong next to your RPC layer, not sprinkled through UI components.

Display rules that prevent support tickets

Once you have raw balances and decimals, you still have the human problem. People judge correctness by what they see. If your display format implies a different value than the chain, you’ll spend engineering time on “bugs” that are really formatting decisions.

Tiny balances, dust, and the “0.00” trap

The worst-case balance display is a confident zero. If the user has a small non-zero balance and your UI shows 0, you’ve created a trust gap. The fix is not “show 18 decimals everywhere,” because that replaces one problem with another.

A common compromise is a threshold indicator. When the balance is non-zero and below the smallest display unit, show a string that communicates presence without implying precision. You can do that with significant digits, or with a < prefix based on your policy. The important part is that the policy is consistent across token lists, detail pages, and confirmation screens.

Large balances and scientific notation avoidance

Large balances bring a different kind of trust issue. Scientific notation looks like a bug in consumer UX, and it can appear unexpectedly if any part of your pipeline touches floating-point conversions.

This is why exact string formatting utilities matter. ethers.js frames formatUnits as display logic that turns a big integer into a string representation without losing precision. If you keep values as big integers and format at the last moment, you avoid scientific notation entirely.

There’s a second issue with large balances: readability. Users scan for separators and stable decimal places. A long ungrouped string is technically correct and practically hostile. Grouping by locale is helpful, and it has consequences for copy-paste workflows, especially if you also show a “copy amount” feature.

Locales, separators, and copy-friendly formatting

Locale formatting is one of those features that feels purely cosmetic until it breaks something. A user sees commas and periods differently depending on locale. If you allow copying, you should decide what you copy: the locale-formatted string or a normalized string.

A clean approach is to separate “display string” from “copy string.” The display string can use locale rules. The copy string stays normalized and machine-friendly. This reduces friction when users paste amounts into other tools, and it reduces support tickets that start with “your app copied the wrong number.”

Performance at scale: Many tokens, many networks, one screen

A token balance screen is deceptively expensive. It often triggers dozens of calls, and the user expects it to load instantly. At scale, the performance work becomes less about one endpoint and more about shaping the whole request pattern.

Batching, multicall, and when each approach pays off

If you fetch balances one-by-one, your bottleneck becomes round-trip and provider rate limits. If you batch too aggressively, one failure can block the whole batch and complicate retries. The right balance depends on your traffic pattern and your UI architecture.

For many apps, multicall-style aggregation is the workhorse. It reduces the number of RPC calls by querying multiple token contracts in a single call. It also centralizes failure handling because you get a single response payload that can be parsed deterministically.

Batching at the JSON-RPC layer can help too, especially for lightweight metadata calls. Ethereum JSON-RPC supports request batching in a single HTTP payload. Some teams use it to reduce overhead when querying multiple balances. The key is to treat batching as a controlled optimization rather than a default, because retry semantics become more complex as soon as you pack many reads into one request.

Keeping re-renders cheap and network reads predictable

A balance screen often fails performance reviews due to UI behavior rather than network behavior. Re-render loops can trigger repeated reads. Component-level effects can refetch metadata unnecessarily. State updates can fragment into many small updates and create a cascade.

A stable design keeps a single “balance store” keyed by chain ID and token address. It fetches in predictable intervals, updates in controlled batches, and exposes derived formatted values to UI components. That keeps the UI responsive and keeps your network reads explainable when someone looks at the logs.

This is also where your caching strategy matters in a quiet way. Cache raw balances only as long as the UI needs them. Cache metadata longer. Cache failures briefly to avoid hammering a token contract that is reverting. These are plain rules, and they prevent the annoying case where one broken token causes the entire balance view to feel slow.

Conclusion

Parsing and displaying ERC-20 balances is a translation task with sharp edges. The chain gives you an integer from balanceOf, and the standard treats decimals() as optional, which means your UI must handle missing metadata gracefully. If you keep balances as exact big integers, format them at the boundary with tools designed for precision, and apply a consistent display policy, balance screens stop being a recurring source of distrust. 

The real win is that once the balance pipeline is stable, everything built on top of it becomes calmer: token lists load predictably, confirmation screens match what users saw earlier, and “wrong balance” tickets turn into rare edge cases instead of a background hum.