back

Rick ·

The real cost of running an Ethereum app

Dev tooling

Most conversations about what an Ethereum app costs to run are conversations about the RPC bill. Which provider, which plan, how many requests, whether the free tier survives a launch.

So I metered one. A counting proxy in front of every JSON-RPC call, a pinned mainnet fork for the transactions, a public endpoint for the log queries, a real browser for the front end, and every measured unit priced against the vendors' own published rates.

At 100,000 monthly users, a carefully built version of this app pays its node provider $104 a month and spends $2,747 a month on gas it sponsors for its users.

The bill everyone argues about is the small one.

That result depends entirely on the app sponsoring transactions, which not every app does, and on gas being cheap right now, which it is: the median mainnet base fee over the 7,200 blocks I sampled was 0.0764 gwei. So the interesting question is not the total. It is which units you are buying, how many of each a single user session consumes, and which of those numbers a developer actually controls.

Where an Ethereum app's money actually goes: a diagram of one application in three rows. One browser session makes 94 JSON-RPC calls in a naive implementation or 33 in a careful one, and downloads 2.00 MB from the frontend host on a cold visit or 12.2 kB on a repeat visit. Those calls reach the node provider, which meters them as 1,234 Alchemy compute units, 7,370 Infura credits or 1,880 QuickNode credits per session, and reads Ethereum mainnet on the app's behalf at no separate chain fee. The same provider serves the indexer, which makes 432,000 calls a month following the chain and 2,160 requests to backfill thirty days, writing 13.7 GB of logs into a database billed per gigabyte. A write in that visit reaches a bundler and paymaster, where it costs 113,930 gas on its own or 49,441 gas at five operations per bundle, which is $0.0317 at the median fee, plus the service's own margin, drawn from the paymaster's deposit.

The app, and how it was metered

The workloads are those of an ordinary token dashboard: a landing page that shows a few global numbers, a connected view that lists a wallet's balances, an open tab, and a write.

Everything on the read side ran against a mainnet fork pinned at block 25,984,000, with an Anvil node behind a proxy that logged every call, its parameters and its response size. The write workloads used viem 2.56.5 on Node 22.17.0. The gas measurements ran on the same fork against the deployed contracts: real USDC, the real ERC-4337 v0.8 EntryPoint at 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108, and the deployed Simple7702Account implementation at 0xe6Cae83BdE06E4c305530e199D7217f42808555B. The log queries ran against a public endpoint, because the point of those was to find out what a provider will actually serve.

The dollar figures come from two places, and they are worth separating. Gas is priced from measurement: 7,200 blocks of mainnet base fees read through eth_feeHistory, and ETH at $2,419.25 from Chainlink's ETH/USD feed read on the fork at 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419. Everything else is priced from vendor pages read on 2026-09-15 and recorded with their URLs.

The measurement rests on one small piece of code, and it is the part worth stealing. A proxy that forwards JSON-RPC upstream and writes one row per call tells you what your own app consumes in an afternoon:

  // One log row per JSON-RPC call, so a batch of five counts as five calls -
  // none of the providers in this article publishes a batch discount - while
  // the `batch` column preserves how many HTTP requests those calls shared.
  for (const call of calls) {
    log.push({
      tag,
      method: call?.method ?? "<unparsed>",
      params: JSON.stringify(call?.params ?? []).slice(0, 300),
      batch: calls.length,
      reqBytes: body.length,
      resBytes: resBody.length,
      ms,
      at: started,
      error,
    });
  }

The only subtlety is that loop: a batch of five calls arrives as one HTTP request and has to be counted as five, because that is how it will be billed.

node scripts/proxy.mjs --port 8578 --upstream http://127.0.0.1:8577

Point your app's transport at the proxy instead of at your provider, exercise the flows you care about, and read the log back from /__dump. Every request figure in this article came out of that file.

A session is not a request

Here is what each workload cost in calls, measured rather than counted from the source:

Workload JSON-RPC calls HTTP requests Response bytes
Landing page, no wallet 5 5 646
Connected dashboard, five tokens read one at a time 18 18 2,321
The same dashboard with viem's HTTP batching on 18 2 2,346
The same dashboard through Multicall3 4 4 5,748
ENS reverse lookup for the connected address 1 1 359
One open tab, 60 seconds 14 14 644
One write, sent and confirmed 11 11 304,083

Three of those rows deserve attention.

Batching does not reduce what you are billed. Turning on viem's HTTP batching took the dashboard from 18 requests to 2, and left the call count at 18. Alchemy's compute-unit reference states the rule plainly — "batch: CU of method # times called" — and none of the three providers below publishes a batch discount. Batching is a latency and connection optimisation. It is not a cost optimisation.

Multicall is the cost optimisation, and it costs bytes. Routing the same fifteen ERC-20 reads through Multicall3 collapsed 18 calls into 4, but the response grew from 2,321 to 5,748 bytes. Nobody bills you for those bytes directly. They still have to cross the network before your page can render.

One open tab is a meter that never stops. viem's default polling interval is 4,000 ms, and an idle tab made 14 eth_blockNumber calls in 60 seconds. A five-minute session costs 70 calls on top of everything the page actually did — more than the page itself. The whole session, landing page plus connected dashboard plus ENS lookup plus five idle minutes, is 94 calls, and 70 of them are the poll. Poll once per block instead and the same five minutes costs 23.

The 300 kilobyte confirmation

The write workload is the one that surprised me. Eleven calls, and 304,083 bytes of response — of which 301,374 bytes were a single eth_getBlockByNumber.

The parameters explain it: ["0x18c7c00", true]. While it waits for a receipt, viem watches the block number, and for every new block it sees it fetches that block with all of its transactions, so it can detect a replacement transaction. On a fork whose head is a real mainnet block, that is a real mainnet block body.

I sampled 20 recent mainnet blocks to see how large that is in general. The median full block body is 473 kB against 22 kB for the same block requested without transactions — 20 blocks with a median of 264.5 transactions each, and full bodies ranging from 103,643 to 1,010,421 bytes.

So each block observed during a confirmation wait costs roughly half a megabyte of egress on the provider's side and half a megabyte of download on the user's. At 1,000 users taking two sponsored actions each per month, that one feature moves 2.5 GB. The same month's traffic without it is 0.04 GB.

It is not a bug. Replacement detection is a real feature, and a wallet that lets users speed up transactions needs it. But it is worth knowing that it is the single largest object your app downloads, and that eth_getBlockByNumber is billed at 20 compute units on Alchemy and 80 credits on Infura whether it returns 22 kB or 1 MB.

Every request shape in this article is viem's, and a different client makes different ones; ethers vs viem covers where the two libraries diverge.

The same session, three very different meters

Provider pricing pages advertise requests and credits, and the weights behind them are published: Alchemy's, Infura's and QuickNode's. What they actually sell is a weighting function, and the three major ones disagree about almost every method:

Method Alchemy CU Infura credits QuickNode credits
eth_chainId 0 5 20
eth_blockNumber 10 80 20
eth_call 26 80 20
eth_getBalance 20 80 20
eth_getBlockByNumber 20 80 20
eth_estimateGas 20 300 20
eth_getLogs 60 255 20

QuickNode's Ethereum pricing is a flat 20 credits for all methods, with 2x and 4x multipliers for its advanced APIs and a short list of large calls. Alchemy and Infura weight each method individually, and they do not agree about which methods are expensive: Infura charges 15x more for eth_estimateGas than for eth_chainId, Alchemy charges nothing at all for eth_chainId, and QuickNode charges the same for both.

Run one naive session through those three functions and you get 1,234 compute units, 7,370 Infura credits, and 1,880 QuickNode credits. The careful version — Multicall3 and a 12-second poll — gives 403, 2,517, and 667.

The units are not comparable to each other. The ratios within each column are the useful part: the careful implementation is 3.1x cheaper on Alchemy, 2.9x on Infura, 2.8x on QuickNode. Whichever provider you pick, the same three changes are worth about the same multiple.

Indexing: the request count is cheap, the data is not

To find out what it costs to read an app's history out of the chain, I backfilled a 300-block window of USDC Transfer events at five chunk sizes, then did the same for a lower-volume contract, the Uniswap v3 USDC/WETH 0.05% pool.

Chunk size Requests Logs found Response bytes Wall clock
1 block 300 29,624 18,844,365 142.4 s
10 blocks 30 29,927 19,026,928 14.2 s
50 blocks 6 29,927 19,026,040 3.7 s
100 blocks 3 29,927 19,025,929 2.6 s
300 blocks 1 — — failed

Four things fall out of that table.

The correctness check is the log count, and it caught something. Three of the 300 single-block requests failed with server overloaded, retry later after their retries, and the run finished with 29,624 logs instead of 29,927. That is 1.01% of the history quietly missing, from a backfill that exited successfully. A backfill that treats a failed range as anything other than fatal produces a database that looks complete.

The one-request version does not exist. Asking for all 300 blocks at once returned ranges over 10000 blocks are not supported on free plan from one endpoint and query returned more than 10000 results from another. The block-range limit is the advertised one; the result cap is the one that actually stops you. QuickNode documents a 5-block range limit for eth_getLogs on its free trial and 10,000 blocks on paid plans, and on this contract 100 blocks already produce around 10,000 logs.

That coincidence is worth stating, because it looks exactly like a truncated response: the 100-block window ending at the fork block returned precisely 10,000 logs on all four public endpoints I probed. It is a genuine count. Splitting the same window in two returned 5,092 and 4,908, and the totals at chunk sizes 10, 50 and 100 all agree at 29,927.

Scaled to thirty days, the request count barely matters and the volume does. At 99.76 logs per block, thirty days of that contract is 21,547,440 logs and, at 636 bytes per log, 13.7 GB. Fetching it in 100-block chunks takes 2,160 requests — 129,600 Alchemy compute units, well inside the free tier. Fetching it one block at a time takes 216,000 requests and, at the 275 ms per request I measured, about 16.5 hours.

Following the chain costs more than backfilling it. An indexer reading one contract needs a block and a log query per block: 432,000 calls a month at 12-second blocks. On Alchemy that is 17.28M compute units, inside the 30M free tier. On Infura it is 72.36M credits, or 2.41M a day against a 3M daily free allowance. On QuickNode it is 8.64M credits against a 10M monthly free allowance — so the indexer alone, with no users at all, is 86% of that free tier.

The alternative is to buy the index rather than build it. The Graph's Subgraph Studio gives 100,000 queries a month free and $2 per 100,000 after that, which for an app serving three subgraph queries per session is $0.40 a month at 10,000 users and $22 at 100,000. Running it yourself means paying for the database instead: Neon's metered plan is $0.106 per compute-hour and $0.35 per GB-month, and the 13.7 GB above is one contract for one month before indexes.

Sponsorship is the expensive part

If your app pays for its users' transactions, this is where the money goes. I ran the same action — one USDC transfer — five ways on the fork.

The same transfer, paid for five different ways. A horizontal bar chart of gas used with the dollar cost at the median mainnet fee beside each bar. An EOA transfer to a recipient that already holds USDC costs 45,148 gas, $0.0125. The same transfer the first time for that recipient costs 62,248 gas, $0.0173. Sponsored through ERC-4337 with five operations per bundle it costs 49,441 gas per action, $0.0137. Sponsored one operation per bundle it costs 113,930 gas, $0.0317. The first sponsored operation, which also carries the EIP-7702 delegation, costs 141,018 gas, $0.0392. Measured on a mainnet fork at block 25,984,000 with ETH at $2,419.25.

The plain transfer costs 45,148 gas when the recipient already holds the token and 62,248 when it does not — the difference between rewriting a storage slot and creating one. That 17,100 gas gap is worth remembering separately: onboarding a user always costs more than serving one.

Sponsoring the same transfer through ERC-4337 costs 113,930 gas for the transaction that carries it — 2.52x the transfer it wraps. The first operation, which also carries the EIP-7702 authorization that turns the user's account into a smart account, costs 141,018.

And then the number that changes the decision: five operations in one bundle cost 247,203 gas in total, or 49,441 each. Batching brings sponsorship from 2.52x a plain transfer down to 1.10x. The EntryPoint's own accounting agrees — the first operation in that bundle reported 77,421 gas used and the remaining four 41,686, 41,685, 41,686 and 41,685, because they share one transaction's intrinsic cost and arrive at contracts that are already warm.

At the median fee over the 24 hours I sampled, those are $0.0317 and $0.0137 per sponsored action. At the 90th-percentile base fee, $0.0931 and $0.0404. At the highest base fee in the sample, $0.3706 and $0.1608.

Two operational notes that the gas numbers do not show. The bundler's transaction used 141,018 gas while the EntryPoint charged the paymaster for 108,801 — the 32,217 gas difference is the bundler's unreimbursed overhead, which a commercial bundler covers by setting preVerificationGas higher. And that margin is quoted openly: Pimlico's pricing page lists a 10% surcharge on sponsored gas on mainnet and quotes around $0.0075 per user operation and $0.0105 sponsored. The surcharge is the part to carry into a model: 10% on top of whatever the gas costs on the day.

Hosting is bytes, and requests, and mostly bytes you already sent

For the front end I loaded two public Ethereum app front ends in Chrome, twice each, and recorded what a visit transfers. The numbers are stable across passes:

Front end Cold visit Repeat visit
app.ens.domains 70 requests, 2.00 MB 70 requests, 12.2 kB
app.aave.com 238 requests, 5.42 MB 238 requests, 0.12 MB

Neither page had a wallet connected, so this is the floor rather than the whole cost.

The shape matters more than the totals. A repeat visit to either app transfers almost nothing — ENS drops from 2.00 MB to 12.2 kB, a 164x reduction — and yet the request count does not move at all. Vercel's published Pro pricing bills both dimensions: $0.15 per GB after the first terabyte, and $2 per million edge requests after the first ten million. Caching solves the first and does nothing about the second.

For an ENS-sized front end at four sessions per user per month, the hosting bill is $0 at 1,000 users (inside the Hobby plan's 100 GB and 1M requests), $20 at 10,000, and $56 at 100,000. For an Aave-sized one it is $190 at 100,000, and 95.2 million edge requests is where that number comes from, not the 685 GB.

The bill

Putting the measured units against the published rates, for an app where every user has four sessions a month and takes two sponsored actions:

Monthly cost per active user, stacked by cost centre, for three scales and two implementations. At 1,000 users the naive implementation costs $0.063 per user and the careful one $0.027, all of it sponsored gas. At 10,000 users the naive implementation costs $0.068 — sponsored gas plus a small RPC segment and a small hosting segment — and the careful one $0.030. At 100,000 users the naive implementation costs $0.067 and the careful one $0.029. In every pair the sponsored gas segment is the overwhelming majority of the bar.

1,000 users 10,000 users 100,000 users
RPC, naive $0 $22.24 $282.49
RPC, careful $0 $4.37 $103.85
Sponsored gas, one op per bundle $63.31 $633.08 $6,330.84
Sponsored gas, five per bundle $27.47 $274.73 $2,747.31
Hosting, ENS-sized front end $0 $20 $56
Total, naive $63.31 $675.32 $6,669.33
Total, careful $27.47 $299.11 $2,907.16

RPC figures are the cheapest of the three providers at that volume, which at every scale here is Alchemy; the same workload on QuickNode is $465.52 and on Infura $425.00 at 100,000 naive users.

Per active user per month, that is $0.067 naive and $0.029 careful, and it barely moves with scale. The free tiers absorb the small app; the plan steps absorb the large one; the gas scales linearly and dominates both.

Two caveats on the RPC column specifically. With a browser wallet, eth_accounts and eth_sendTransaction are answered by the wallet's own provider, not yours, so those two calls per write are excluded from every figure above. And Infura's allowance is daily rather than monthly, so a monthly average understates what a peak day needs.

What this does not tell you

This is one app shape, on one chain, at one moment.

The gas figures are the most perishable thing here. A median base fee of 0.0764 gwei makes sponsorship cheap in absolute terms; the maximum in the same 24-hour sample was 17x that, and a genuinely busy day would move every dollar figure in the sponsorship section without changing a single gas number.

The read workloads are a dashboard. An app doing on-chain simulation before every quote, or reading a large storage layout, or serving a chart from historical state, spends its calls on much heavier methods than eth_call against an ERC-20.

The page-weight measurements are of other people's front ends, loaded anonymously with no wallet connected, and priced against Vercel's list rates rather than what those teams actually pay. They are a size reference, not a bill. I dropped a third front end from the results because it did not load reproducibly under automation.

The indexing figures use a very high-volume contract as the upper bound and a moderate one as the lower. Your own contract is probably far closer to the 2.06 logs per block of the pool than the 99.76 of USDC — the pool's thirty days is 0.4 GB, not 13.7.

And the model's user behaviour — four sessions, five idle minutes, two sponsored actions — is an assumption, not a measurement. The per-unit costs are the durable part. Substitute your own frequencies.

What I would actually optimise

If you are building this app, the measurements point at a clear order.

Decide whether you sponsor, and how much. It is between 91% and 100% of the bill in every scenario I modelled. A free action for a user costs between one and four cents at today's fees, and that number is a product decision before it is an engineering one.

If you sponsor, batch. Five operations per bundle took the per-action gas from 2.52x a plain transfer to 1.10x. Nothing else in this article is a 2.3x saving on the largest line item.

Then fix the polling. An idle tab at viem's default 4-second interval costs more calls than the page that opened it. Polling once per block is a one-line change that removes 47 of the 94 calls in a five-minute session.

Then batch the reads. Multicall3 turned 18 calls into 4. Combined with the polling change it is roughly a 3x cut across all three providers' meters.

Then look at the provider, last. At 100,000 users it is worth 2.2x between the cheapest and dearest of the three, and by that point the first three changes have already made it the smallest number on the page.

None of that is specific to Ethereum, which is sort of the point. The costs that surprised me were the ordinary software costs — a polling default, a library fetching more than it needs, a backfill that swallowed its errors — and not the chain. The chain was the cheapest thing in the bill, and the only part of it nobody sends you an invoice for.