<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yuto Nakamura</title>
    <description>The latest articles on DEV Community by Yuto Nakamura (@yutonakamuradev).</description>
    <link>https://dev.to/yutonakamuradev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4070025%2Fd6961c20-7e6c-455d-a855-9db0bab6f486.png</url>
      <title>DEV Community: Yuto Nakamura</title>
      <link>https://dev.to/yutonakamuradev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yutonakamuradev"/>
    <language>en</language>
    <item>
      <title>How EIP-1559 gas estimation actually works under the hood</title>
      <dc:creator>Yuto Nakamura</dc:creator>
      <pubDate>Mon, 10 Aug 2026 11:47:28 +0000</pubDate>
      <link>https://dev.to/yutonakamuradev/how-eip-1559-gas-estimation-actually-works-under-the-hood-n60</link>
      <guid>https://dev.to/yutonakamuradev/how-eip-1559-gas-estimation-actually-works-under-the-hood-n60</guid>
      <description>&lt;p&gt;Most gas estimation libraries give you a single number and call it a day. If you've ever wondered how that number is calculated — or why it's sometimes wrong — here's what's actually happening underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The EIP-1559 fee model
&lt;/h2&gt;

&lt;p&gt;Before EIP-1559, gas pricing was a blind auction. You guessed a price, and miners picked the highest bids. Since EIP-1559 (August 2021), every block has a &lt;strong&gt;baseFee&lt;/strong&gt; set by the protocol and a &lt;strong&gt;priorityFee&lt;/strong&gt; (tip) you choose to incentivize inclusion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total fee per gas = baseFee + priorityFee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The baseFee is burned. The priorityFee goes to the validator. You set &lt;code&gt;maxFeePerGas&lt;/code&gt; as the maximum you're willing to pay — any difference between your max and the actual baseFee + tip is refunded.&lt;/p&gt;

&lt;h2&gt;
  
  
  How baseFee changes
&lt;/h2&gt;

&lt;p&gt;The baseFee adjusts every block based on how full the previous block was. The target is 50% utilization:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;estimateNextBaseFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gasUsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gasLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gasLimit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gasUsed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;baseFee&lt;/span&gt;  &lt;span class="c1"&gt;// exactly 50% full — no change&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gasUsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Block was more than 50% full — baseFee goes up&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gasUsed&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseFee&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;baseFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Block was less than 50% full — baseFee goes down&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;gasUsed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseFee&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;baseFee&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;change&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key number is &lt;strong&gt;8&lt;/strong&gt;. The baseFee can change by at most 12.5% per block (1/8). This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A completely full block (100% gas used) increases baseFee by 12.5%&lt;/li&gt;
&lt;li&gt;An empty block (0% gas used) decreases it by 12.5%&lt;/li&gt;
&lt;li&gt;A half-full block keeps it the same&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the formula every estimator should use. Libraries that just read &lt;code&gt;eth_gasPrice&lt;/code&gt; and add a buffer are ignoring half the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting historical data
&lt;/h2&gt;

&lt;p&gt;The RPC method &lt;code&gt;eth_feeHistory&lt;/code&gt; returns baseFee and priorityFee data for recent blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eth_feeHistory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x14&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// 20 blocks&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;latest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// newest block&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;// priority fee percentiles we want&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// Returns:&lt;/span&gt;
&lt;span class="c1"&gt;// baseFeePerGas: [block_n, block_n+1, ..., block_n+21]  (one extra for next block estimate)&lt;/span&gt;
&lt;span class="c1"&gt;// reward: [[p10, p25, p50, p75], ...]  per-block priority fees at each percentile&lt;/span&gt;
&lt;span class="c1"&gt;// gasUsedRatio: [0.45, 0.67, ...]  how full each block was&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extra baseFee at the end of the array is the protocol's own estimate of the next block's baseFee. But I found it's more accurate to calculate it yourself from the latest block's gasUsed ratio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building speed tiers
&lt;/h2&gt;

&lt;p&gt;Here's where it gets practical. A "slow" transaction needs a lower priority fee than a "fast" one. The question is: how much lower?&lt;/p&gt;

&lt;p&gt;The approach that works: take the priority fee percentiles from the last 20 blocks and use them as tiers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10th percentile → slow     (bottom 10% of tips still got included)
25th percentile → standard (bottom quarter)
50th percentile → fast     (median tip)
75th percentile → instant  (top quarter)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you also need to adjust &lt;code&gt;maxFeePerGas&lt;/code&gt; per tier. A "slow" transaction can use a tight maxFee (just above current baseFee), while "instant" should buffer for baseFee increases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;maxPriorityFeePerGas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;percentile10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextBaseFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;percentile10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// tight — no buffer&lt;/span&gt;
  &lt;span class="na"&gt;estimatedSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blockTime&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;maxPriorityFeePerGas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;percentile75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextBaseFee&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;percentile75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 50% baseFee buffer&lt;/span&gt;
  &lt;span class="na"&gt;estimatedSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blockTime&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 150% baseFee buffer on "instant" means even if the next few blocks are completely full and baseFee spikes, your transaction still gets in. For "slow", you're betting baseFee stays roughly flat — if it rises, your transaction waits longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monotonic ordering problem
&lt;/h2&gt;

&lt;p&gt;On some chains (notably Avalanche), the priority fee percentiles don't increase monotonically. The 50th percentile can be lower than the 25th if there are weird fee distributions in recent blocks.&lt;/p&gt;

&lt;p&gt;This breaks user expectations. If "fast" shows a lower fee than "standard", people lose trust in the estimator. The fix is simple — enforce ordering after computing the raw percentiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;standard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;standard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;standard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;standard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxFeePerGas&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't see any existing library handle this. Most return raw percentiles and let the consumer deal with inversions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MetaMask sometimes overestimates
&lt;/h2&gt;

&lt;p&gt;MetaMask uses its own gas fee controller with a similar approach, but it has access to a proprietary API that analyzes the mempool. For most developers building their own applications, you don't have access to that API.&lt;/p&gt;

&lt;p&gt;The good news: for 95% of use cases, &lt;code&gt;eth_feeHistory&lt;/code&gt; percentiles are accurate enough. The mempool analysis matters most during extreme congestion — gas wars, popular NFT mints, etc. For normal transactions, historical percentiles predict the next few blocks well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence intervals
&lt;/h2&gt;

&lt;p&gt;One feature I found missing everywhere: telling the user how confident the estimate is.&lt;/p&gt;

&lt;p&gt;If baseFee has been stable for the last 50 blocks, the estimate is highly reliable. If it's been spiking wildly, the same median number could be off by 3x.&lt;/p&gt;

&lt;p&gt;The implementation is straightforward — compute the spread between the 5th and 95th percentile of recent baseFees and express it as a confidence score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p95&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;p50&lt;/span&gt;  &lt;span class="c1"&gt;// relative spread&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// 95 = very stable, estimate is reliable&lt;/span&gt;
&lt;span class="c1"&gt;// 40 = volatile, estimate is a rough guide&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives the consumer a way to decide: at 90% confidence, auto-submit. At 40% confidence, show a warning and let the user adjust.&lt;/p&gt;

&lt;h2&gt;
  
  
  L2 cost: the hidden fee
&lt;/h2&gt;

&lt;p&gt;On L2 chains (Optimism, Base, Arbitrum), the gas price you see is only half the story. Every L2 transaction also pays an &lt;strong&gt;L1 data fee&lt;/strong&gt; — the cost of posting your transaction data to Ethereum mainnet for security.&lt;/p&gt;

&lt;p&gt;For a typical ERC-20 transfer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;L2 execution fee: ~0.000001 ETH&lt;/li&gt;
&lt;li&gt;L1 data fee: ~0.000050 ETH&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The L1 data fee is &lt;strong&gt;50x&lt;/strong&gt; the L2 execution fee. If your estimator only shows L2 gas, your users think the transaction costs almost nothing, then get surprised by the actual cost.&lt;/p&gt;

&lt;p&gt;On OP Stack chains (Optimism, Base), you can query the L1 data fee from a predeployed contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GAS_ORACLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x420000000000000000000000000000000000000F&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l1Fee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ethCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GAS_ORACLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getL1Fee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txData&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Arbitrum, the mechanism is different — you query &lt;code&gt;ArbGasInfo&lt;/code&gt; at &lt;code&gt;0x06C&lt;/code&gt; for the L1 base fee estimate and calculate from calldata size.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;I packaged all of this into a standalone gas oracle with zero dependencies. Four speed tiers, confidence intervals, trend analysis, L2 cost separation, 15 chains out of the box.&lt;/p&gt;

&lt;p&gt;The motivation was straightforward: every existing gas oracle package on npm is either dead (last updated 2021-2022), locked into a specific ecosystem (MetaMask's controller needs 12 internal dependencies), or requires an external API key.&lt;/p&gt;

&lt;p&gt;If you're building a wallet, a trading bot, or any dApp that sends transactions, the gas price matters. Getting it wrong means either wasted money or stuck transactions.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Yuto — I build developer tools for the crypto ecosystem at &lt;a href="https://pulsadev.dev" rel="noopener noreferrer"&gt;pulsadev.dev&lt;/a&gt;. The gas oracle is at &lt;a href="https://github.com/pulsadev/gas-oracle" rel="noopener noreferrer"&gt;@pulsadev/gas-oracle&lt;/a&gt;, along with packages for &lt;a href="https://github.com/pulsadev/multicall" rel="noopener noreferrer"&gt;multicall&lt;/a&gt;, &lt;a href="https://github.com/pulsadev/abi-utils" rel="noopener noreferrer"&gt;ABI encoding&lt;/a&gt;, and &lt;a href="https://github.com/pulsadev/tx-decoder" rel="noopener noreferrer"&gt;transaction decoding&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>crypto</category>
      <category>ethereum</category>
    </item>
    <item>
      <title>How I found a 32x performance bug hiding in a UTF-8 decoder</title>
      <dc:creator>Yuto Nakamura</dc:creator>
      <pubDate>Sun, 09 Aug 2026 17:54:49 +0000</pubDate>
      <link>https://dev.to/yutonakamuradev/how-i-found-a-32x-performance-bug-hiding-in-a-utf-8-decoder-ojp</link>
      <guid>https://dev.to/yutonakamuradev/how-i-found-a-32x-performance-bug-hiding-in-a-utf-8-decoder-ojp</guid>
      <description>&lt;p&gt;Last week I was profiling a Node.js service that processes large JSON-RPC responses. Three concurrent 18.7 MB responses crashed the process with an out-of-memory error — on a machine with 1 GB of heap.&lt;/p&gt;

&lt;p&gt;The culprit wasn't the JSON parser. It was the UTF-8 decoder that ran &lt;em&gt;before&lt;/em&gt; &lt;code&gt;JSON.parse&lt;/code&gt; ever saw the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The service fetches binary response bodies as &lt;code&gt;Uint8Array&lt;/code&gt; and converts them to strings before parsing. The conversion function looked something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toUtf8String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Step 1: decode bytes into code points&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codePoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;codePoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... handle multibyte sequences&lt;/span&gt;
    &lt;span class="nx"&gt;codePoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decodedValue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: convert code points to string&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;codePoints&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, this is correct. It handles multibyte UTF-8 properly, validates overlong encodings, rejects surrogates — all the things a robust UTF-8 decoder should do.&lt;/p&gt;

&lt;p&gt;But there's a hidden cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;For an 18.7 MB response (which is just ASCII JSON — every byte maps 1:1 to a code point), this function:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates an &lt;code&gt;Array&amp;lt;number&amp;gt;&lt;/code&gt; with &lt;strong&gt;19.6 million elements&lt;/strong&gt; (one per byte)&lt;/li&gt;
&lt;li&gt;Maps that array into &lt;strong&gt;19.6 million single-character strings&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Joins them all into one final string&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's three massive allocations for what should be a simple byte-to-string conversion.&lt;/p&gt;

&lt;p&gt;I measured the actual cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Custom UTF-8 decoder:
  Time: 928ms
  Heap: 794 MB

TextDecoder:
  Time: 10ms
  Heap: 47.6 MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;32x slower. 16x more memory.&lt;/strong&gt; And when three of these run concurrently, the 794 MB × 3 = 2.4 GB of heap pressure crashes any process with a reasonable memory limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;The root cause is the intermediate array. &lt;code&gt;getUtf8CodePoints&lt;/code&gt; pushes one number per byte into a dynamically growing JavaScript array. Then &lt;code&gt;_toUtf8String&lt;/code&gt; calls &lt;code&gt;String.fromCharCode&lt;/code&gt; on each one individually and joins them.&lt;/p&gt;

&lt;p&gt;For small strings (a few KB), the overhead is negligible. But the cost scales linearly with input size, and the constant factor is enormous compared to the native &lt;code&gt;TextDecoder&lt;/code&gt; API.&lt;/p&gt;

&lt;p&gt;The native &lt;code&gt;TextDecoder&lt;/code&gt; avoids all of this. It decodes directly from &lt;code&gt;Uint8Array&lt;/code&gt; to &lt;code&gt;string&lt;/code&gt; in a single C++ call inside the runtime — no intermediate arrays, no per-character string allocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bodyToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;toUtf8String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bodyToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. One line.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;{ fatal: true }&lt;/code&gt; option preserves the existing behavior of throwing on invalid UTF-8 — which is what the custom decoder did via its error callback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the fix
&lt;/h2&gt;

&lt;p&gt;After the change, the same scenario that crashed the process now completes in 31ms using 62 MB of heap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before (custom decoder):
  3× 18.7 MB concurrent: OOM CRASH (256 MB heap limit)

After (TextDecoder):
  3× 18.7 MB concurrent: 31ms, 62 MB heap — no crash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also verified that &lt;code&gt;TextDecoder&lt;/code&gt; produces identical output to the custom decoder for all valid UTF-8 inputs — including emoji, CJK characters, 4-byte sequences, and edge cases like null bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;Custom implementations of standard operations can hide extraordinary costs at scale. The UTF-8 decoder I found was &lt;em&gt;correct&lt;/em&gt; — it passed every test. But its allocation pattern made it a time bomb for large inputs.&lt;/p&gt;

&lt;p&gt;Before writing a custom version of anything that the platform already provides natively, ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does the native API cover my use case?&lt;/li&gt;
&lt;li&gt;If I need error handling, can I get it through configuration (like &lt;code&gt;{ fatal: true }&lt;/code&gt;)?&lt;/li&gt;
&lt;li&gt;What's the allocation profile at 10x and 100x my current input size?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the native API works, use it. The performance difference isn't 10% — it can be 3,000%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened next
&lt;/h2&gt;

&lt;p&gt;I submitted a pull request to the library with the fix. The change touched one file, three lines. All existing tests pass, and the fix eliminates OOM crashes on large responses while maintaining the same error behavior.&lt;/p&gt;

&lt;p&gt;Sometimes the highest-impact contribution is the smallest diff.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Yuto — I build developer tools for the crypto ecosystem at &lt;a href="https://pulsadev.dev" rel="noopener noreferrer"&gt;pulsadev.dev&lt;/a&gt;. If you're interested in lightweight TypeScript tooling for EVM, check out &lt;a href="https://github.com/pulsadev/multicall" rel="noopener noreferrer"&gt;@pulsadev/multicall&lt;/a&gt; and &lt;a href="https://github.com/pulsadev/abi-utils" rel="noopener noreferrer"&gt;@pulsadev/abi-utils&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>javascript</category>
      <category>node</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
