Viem vs Ethers.js: Which Library Fits Your Dapp?
There's a specific kind of frustration every frontend integrator lives through at least once: you ship a production build, run it through a bundle analyzer, and realize that your "lightweight" Web3…

There's a specific kind of frustration every frontend integrator lives through at least once: you ship a production build, run it through a bundle analyzer, and realize that your "lightweight" Web3 connection layer is heavier than the rest of the application combined. We hit that wall during a DeFi dashboard rebuild a while back. The page stalled noticeably before becoming interactive on a mid-range Android device, and the offending block was our ethers v5 implementation — wallet connector, formatters, contract factory, a custom BigNumber class, plus a couple of utility modules that had quietly accumulated across eighteen months of maintenance. That bundle wasn't unusual. It was the price of building on one of the most widely deployed Web3 libraries ever written, and the symptom that pushed our team toward a serious look at viem.
The honest question isn't "viem vs ethers.js" as a head-to-head competition, the way a lot of comparisons frame it. Both libraries do the same fundamental job — they let JavaScript talk to an Ethereum node without making you hand-roll JSON-RPC requests — and ethers.js remains actively maintained, deeply documented, and embedded in production infrastructure everywhere from Hardhat plugins to backend signing services. The real question is which mental model, bundle shape, and type system matches the Web3 integration you are actually building. Let's build that answer together.
Architectural Philosophy: Functional Modularity vs. Object-Oriented Design
When you sit down with ethers.js, you are working inside an object-oriented framework that mirrors how Ethereum wallets and accounts have been described since 2016. You construct a JsonRpcProvider, attach it to a Signer, instantiate a Contract with an ABI and an address, and call methods on the resulting object. State lives on instances, methods mutate that state, and idiomatic code reads as a chain of method calls on objects you configured earlier. It is a comfortable model for anyone who came from a Java, Ruby, or classical JavaScript background, and it has earned its place in tutorials, StackOverflow answers, and most of the documentation written about Ethereum over the last decade.
Viem takes a different path. It is functional and modular by design — operations are organized as composable functions and small factories, and you import only the pieces you need. There is no single "Viem object" to instantiate. Instead, you build a PublicClient for read operations (view functions, event logs, block and transaction lookups), a WalletClient for write operations that need a private key or account abstraction, and a TestClient for working against local Anvil or Hardhat nodes. The point of the functional shape is not that every call is a pure computation; RPC reads and transaction submissions obviously perform network I/O. The point is that you compose behavior by passing clients and actions around, the way modern React, Vue, and Svelte codebases have moved toward — small composable pieces rather than a large instantiated framework. The cost of that mental shift is real, and worth it if your team is comfortable with TypeScript and functional patterns. The benefit is that you stop pulling in abstractions you don't actually use.
A practical difference that follows from this philosophy: viem does not carry forward the names Provider and Signer, terms that were tightly coupled to the EIP-1193 thinking of the previous generation. Instead, PublicClient makes explicit what the old Provider did silently — it only reads, it cannot sign, it cannot send transactions. That clarity matters when you audit code that handles user funds, because the type system now reflects the capability boundary instead of relying on convention.
The library you choose is really a choice about which mental model — object-oriented instance state or functional composition — your team will debug at 2 AM when a transaction isn't getting picked up.
Bundle Size and Performance: What Actually Ships to the Browser
Here is where real numbers diverge from the marketing pages. Let's lay them side by side for a typical dapp that imports a wallet connector, a public RPC client, and the contracts module:
| Dimension | Viem | Ethers.js v6 |
|---|---|---|
| Base bundle size (full import) | ~35 kB | ~130–200 kB |
| Tree-shaken bundle (typical surface) | ~15 kB | Usually 80 kB+ |
| BigInt handling | Native bigint | Native bigint |
Address validation (isAddress) | Up to ~40x faster than legacy libraries | Standard implementation |
| Powers wagmi v2 underneath | Yes (direct dependency) | No |
A few things to call out about this table. The first is that bundle size differences are most dramatic when you are building for constrained environments — mobile-first dapps, in-app browsers inside Telegram or Farcaster frames, or any surface where Core Web Vitals directly affect retention. We saw the difference in our own work: after migrating a swap interface from ethers v5 to viem with wagmi v2, the Web3 connection layer went from a chunk that visibly dominated the bundle analyzer output to one we had to look for to confirm it was still shipping, and the page's Time to Interactive on throttled connections improved noticeably as a result.
The second thing to call out is that these numbers depend entirely on how you import. Viem's tree-shaking only works if you import from specific entry points — viem/chains, individual actions from viem/actions, dedicated wallet connector entry points — rather than a barrel import that drags everything in. Ethers v6 has also improved dramatically compared to its v5 incarnation: it shed the BigNumber polyfill, adopted native bigint, and rearchitected for ESM. But its module surface area is still larger, because the Provider, Signer, and Contract abstractions ship by default. If bundle weight matters to your use case, your import patterns matter at least as much as the library choice.
The third, and often overlooked, point: RPC provider latency and network conditions bottleneck performance much more than the client library itself does. The 40x isAddress figure represents the cost of running that single validation function locally — a microsecond-scale operation that almost never appears in user-facing performance traces. What users actually feel is the round-trip to the node: how long eth_getBalance takes, how quickly the wallet popup resolves, whether the transaction is indexed in your subgraph fast enough for the next screen to load. Pick viem for its bundle wins, but don't pick it expecting 40x faster dapps — that math doesn't survive contact with real networks.
Type Safety and Developer Experience: From ABIs to Compile-Time Inference
If bundle weight was the headline benefit that pulled our team toward viem, the type system was the feature that kept us there. The traditional ethers workflow looks like this: you keep your ABI as a JSON artifact, hand it to new Contract(address, abi, signer) at runtime, and rely on TypeChain — a separate code-generation tool — to produce typed bindings that your IDE can read. For teams that ship many contracts and rarely change them, this works well. For teams iterating on interfaces while the frontend is in active development, the friction adds up: every ABI change triggers a regeneration step, build artifacts drift, and small type mismatches slip through as any.
Viem takes a different route that we think meaningfully changes the iteration loop. You declare a contract ABI as a const typed as const, and viem infers function names, argument types, and return types directly at compile time — no code generation step required. The same applies to EIP-712 typed data: viem reads the structure of your domain and types object directly without asking you to run a separate command or wait on a watch script. The practical effect is that ABI changes surface as TypeScript errors in your editor before you save the file — adding a parameter, changing a type, renaming a function, all become red squigglies in the same view where you are writing the code that consumes them. For frontend engineers who measure feedback loops in seconds, this matters more than raw runtime numbers suggest — it changes where you find bugs from "in production" to "in your editor."
There is a real tradeoff we should be honest about. Viem's type magic is best in TypeScript-first codebases. If your dapp is still in JavaScript, or if your team prefers JSON artifacts and runtime ABI loading because contracts live in many separate repositories, the ergonomic gap narrows considerably. Ethers v6 has also added stronger typing for events and overrides, narrowing the historical gap. We wouldn't pick viem over ethers purely on type inference if everything else were equal — but in our experience, everything else usually isn't equal in projects with active contract-frontend iteration and a user journey that depends on correctly typed contract reads.
Compile-time ABI inference is the kind of feature that doesn't show up in benchmarks but saves hours across a sprint — every renamed function becomes a red squiggle before it becomes a runtime bug.
Migration Paths and Ecosystem Compatibility for Legacy Projects
A lot of teams we talk to aren't starting from scratch. They have an existing ethers v5 dapp, contract factories written in 2021, and they need to know whether migration is worth the engineering cost right now. The honest answer is: it depends on where the pain lives.
If you are still on ethers v5, the path of least resistance is to upgrade to ethers v6 first. Ethers v6 was a substantial rewrite — it dropped the custom BigNumber class in favor of native bigint, restructured providers around EIP-1193, modernized the import paths, and added stricter typing. That migration alone fixes a category of subtle bugs around large-number arithmetic and address normalization. It also prepares your codebase for a future viem migration, because the conceptual shape of "client + contract instance" maps cleanly onto viem's PublicClient and WalletClient.
If you are already on ethers v6 and considering viem, the migration is more about retraining instincts than rewriting from scratch. The contracts module in viem is intentionally thin — you pass an ABI, an address, and a client, and you get back a typed object with read and write namespaces. For most dapps, the surface area to translate is small: provider.getBalance becomes publicClient.getBalance, contract.balanceOf(address) becomes contract.read.balanceOf([address]), signer.sendTransaction becomes walletClient.sendTransaction. We usually estimate migration at one to three engineer-weeks for a medium-sized dapp, less if you already use wagmi v1.
The harder call is around legacy dependencies. Hardhat's plugin ecosystem has historically centered on ethers, OpenZeppelin contracts integrate cleanly with either library, and a meaningful slice of community tooling still assumes ethers-style providers. Ethers.js is not going anywhere — it is actively maintained, deeply integrated, and powers enormous amounts of production code that runs without incident. If your dapp is stable, your team has deep ethers fluency, and you have no bundle or type-safety pain driving urgency, the rational choice is to stay on ethers and revisit the conversation in a year or two.
Client-Side Strategy: Replacing Providers and Signers with Modular Clients
The cleanest way to understand viem's contribution is to look at how wagmi v2 is built on top of it. Wagmi's React hooks — useAccount, useBalance, useReadContract, useWriteContract — are thin wrappers around viem clients, and the configuration object you pass to createConfig is essentially a typed mapping of chains, transports, and connectors. When you call useReadContract inside a component, wagmi instantiates a PublicClient under the hood, calls the appropriate action, and memoizes the result around block numbers and wallet connection state. This is what state sync looks like in practice when you migrate a dapp from a manually wired ethers stack — plumbing that used to consume a junior engineer's first sprint on every new feature, now handled once at the configuration layer so the team can stay focused on the user journey.
The practical benefit is that the same client objects work on the server, in tests, and in the browser without modification. You can run publicClient.getBlockNumber() inside a Next.js API route, use the same code inside a Vitest suite that mocks the transport, and hand the resulting data structure to your frontend without translating types. We use this pattern to keep our staging environment honest: the same viem functions read from a forked Anvil node in CI, from a hosted RPC in production, and from a public endpoint during local development. It is the consistency that old provider-based stacks had to reinvent per surface, and it makes graceful degradation across environments far less painful than it used to be.
For teams adopting viem today, a sensible starting strategy looks like this:
1. Start with viem/chains and individual actions from viem/actions — or, equivalently, the methods that come attached to your PublicClient — for read paths you already have, like fetching balances, ENS names, or token metadata from a subgraph's mirror.
2. Replace useEffect blocks that manually wire JsonRpcProvider with publicClient instances lifted into a context provider.
3. Layer wagmi v2 over viem once your read paths are stable — this gives you connection state, reactive balances, and transaction lifecycle hooks for free.
4. Reach for walletClient directly only when wagmi's hooks don't cover a flow — typically for signing EIP-712 typed data, batching multiple transactions in a single wallet confirmation, or for ERC-4337 account-abstraction flows, where you'll want the dedicated bundler and paymaster clients from viem/account-abstraction rather than a plain wallet client.
That sequence usually takes a couple of weeks and keeps your dapp shippable throughout the migration instead of in a frozen rewrite branch.
Which Library Should You Actually Choose?
Here is the working heuristic we use across our client projects, condensed:
- Choose viem if you are starting a new React dapp, you care about ship-time bundle weight, your team is comfortable with TypeScript, and your contracts change frequently enough that compile-time ABI inference is more useful than code generation.
- Stay on ethers v6 if your dapp is stable, your team has years of ethers fluency, your backend services sign transactions and rely on the existing provider abstraction, or your dependencies — Hardhat plugins, OpenZeppelin deployers, specific oracles — are tightly coupled to the ethers API.
- Plan a migration from ethers v5 to ethers v6 first — that's a self-contained win that fixes
bigintedge cases and modernizes your imports, and it sets you up to evaluate viem later without a double migration. - Treat viem as a frontend-first library, but know that it works in Node.js environments too — we run viem in backend scripts and signing services without modification, even though its headline features target browser bundles.
The comparison is less about which library is "better" and more about which tradeoffs your team can absorb. Viem wins on bundle size, type inference, and the wagmi v2 connection story. Ethers wins on ecosystem maturity, legacy compatibility, and the body of StackOverflow answers an engineer can search through at 2 AM. Both are production-ready, actively maintained, and used at scale by teams shipping real value to real users. Pick the one whose tradeoffs match your build, and don't let online debates convince you the other choice is reckless.