<?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 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>
