DEV Community

Yuto Nakamura
Yuto Nakamura

Posted on

How I found a 32x performance bug hiding in a UTF-8 decoder

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.

The culprit wasn't the JSON parser. It was the UTF-8 decoder that ran before JSON.parse ever saw the data.

The setup

The service fetches binary response bodies as Uint8Array and converts them to strings before parsing. The conversion function looked something like this:

function toUtf8String(bytes) {
  // Step 1: decode bytes into code points
  const codePoints = [];
  let i = 0;
  while (i < bytes.length) {
    const c = bytes[i++];
    if (c >> 7 === 0) {
      codePoints.push(c);
      continue;
    }
    // ... handle multibyte sequences
    codePoints.push(decodedValue);
  }

  // Step 2: convert code points to string
  return codePoints
    .map(cp => String.fromCharCode(cp))
    .join('');
}
Enter fullscreen mode Exit fullscreen mode

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.

But there's a hidden cost.

The problem

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

  1. Creates an Array<number> with 19.6 million elements (one per byte)
  2. Maps that array into 19.6 million single-character strings
  3. Joins them all into one final string

That's three massive allocations for what should be a simple byte-to-string conversion.

I measured the actual cost:

Custom UTF-8 decoder:
  Time: 928ms
  Heap: 794 MB

TextDecoder:
  Time: 10ms
  Heap: 47.6 MB
Enter fullscreen mode Exit fullscreen mode

32x slower. 16x more memory. 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.

Why it happens

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

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 TextDecoder API.

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

The fix

// Before
function bodyToString(body) {
  return toUtf8String(body);
}

// After
function bodyToString(body) {
  return new TextDecoder('utf-8', { fatal: true }).decode(body);
}
Enter fullscreen mode Exit fullscreen mode

That's it. One line.

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

Verifying the fix

After the change, the same scenario that crashed the process now completes in 31ms using 62 MB of heap:

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
Enter fullscreen mode Exit fullscreen mode

I also verified that TextDecoder 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.

The lesson

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

Before writing a custom version of anything that the platform already provides natively, ask:

  1. Does the native API cover my use case?
  2. If I need error handling, can I get it through configuration (like { fatal: true })?
  3. What's the allocation profile at 10x and 100x my current input size?

If the native API works, use it. The performance difference isn't 10% — it can be 3,000%.

What happened next

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.

Sometimes the highest-impact contribution is the smallest diff.


I'm Yuto — I build developer tools for the crypto ecosystem at pulsadev.dev. If you're interested in lightweight TypeScript tooling for EVM, check out @pulsadev/multicall and @pulsadev/abi-utils.

Top comments (0)