A 6.47 MB JS String Occupied 15.4 MB of Memory

open-connector (opens in a new tab) is an open-source project we work on. It wraps 1,464 third-party APIs (Slack, GitHub, Feishu, and others) behind a single action interface. Each third party is a provider. Which actions it has, and the JSON Schema for each action’s input and output, live in a JSON file. Those files are the catalog. The service loads them into memory at startup.

We were recently cutting resident memory on its Bun single-file binary, taking RSS after startup from 378 MiB down to 293 MiB (open-connector#490 (opens in a new tab)). The heap’s largest single object turned out to be a JSON string: 6,471,221 characters, 6.47 MB as UTF-8, but 15.4 MB in JavaScriptCore’s heap. The reason was that 49 of those 6.47 million characters sit outside Latin-1.

I then looked at the Cloudflare Workers heap. Same cause: two characters made a 14.5 MB script occupy 27.6 MiB. Writing it down.

Environment:

  • macOS arm64
  • Bun 1.4.0 (JavaScriptCore)
  • Node 26.7.0 (V8)
  • esbuild 0.28.x, wrangler 4.127.1

The symptom

At startup the service JSON.stringifys a summary of every provider in the catalog (name, description, the list of actions, no schemas) and caches that string as the /api/providers response body. Every later request sends it as-is. That string is providerSummariesJson, 6,471,221 characters.

While looking at memory I used heapStats() from bun:jsc to compare the heap before and after loading the catalog, then nulled this string on its own. That confirmed it accounted for 15.4 MB by itself.

The catalog can be loaded two ways. By default, every provider’s JSON Schema is read into memory at startup. It can also keep only names, descriptions, and action lists, and read a schema from its file the first time some action actually needs it. In that second mode the whole JS heap is 26.4 MiB, and this one string is more than half of it.

6.47 MB of text occupying 15.4 MB is roughly 2x. Scan the string for code points above U+00FF:

const tally = new Map();
for (const ch of text) {
if (ch.codePointAt(0) > 0xff) tally.set(ch, (tally.get(ch) ?? 0) + 1);
}
chars: 6471221, non-Latin-1 code points: 49
U+2014 "—" x13
U+2022 "•" x12
U+2192 "→" x3
U+8868 "表" x2
U+5E7F "广" x2
...

49 characters: 13 em dashes, 12 bullets, 3 arrows, and 21 CJK characters from a few providers whose descriptions are in Chinese. Those 49 come from 17 provider files. The other 1,447 files are pure ASCII. Those 49 characters made every character in the string occupy two bytes.

Why two bytes

The ECMAScript spec says a string is a sequence of 16-bit code units. Engines do not actually store two bytes per character. V8 has SeqOneByteString and SeqTwoByteString. JavaScriptCore’s StringImpl has an is8Bit() 8-bit form and a 16-bit form. The rule is the same: if every character in the string is at most U+00FF, store one byte per character. If any character is above that, store the whole string two bytes per character. In V8’s source that ceiling is kMaxOneByteCharCode = 0xFF.

The ceiling is Latin-1, not ASCII. é (U+00E9) is not ASCII, but its code point is still at most U+00FF, so it stays one byte. What actually forces the whole string to two bytes is anything above U+00FF: an em dash (U+2014), a bullet (U+2022), an arrow (U+2192), CJK, emoji. Mix one CJK character into 6.47 million others and the whole string doubles.

A script to check. Eight million ASCII characters, then one é or one on the end, and see how much the heap grows:

demo.mjs
import v8 from "node:v8";
function heap() {
globalThis.gc();
globalThis.gc();
return v8.getHeapStatistics().used_heap_size;
}
function build(extra) {
const s = "abcdefgh".repeat(1_000_000) + extra;
s.charCodeAt(s.length - 1); // flatten the rope, see below
return s;
}
const base = heap();
const ascii = build("");
const h1 = heap();
const latin = build("é");
const h2 = heap();
const cjk = build("");
const h3 = heap();
console.log(`8,000,000 x ASCII : heap +${((h1 - base) / 1048576).toFixed(1)} MiB`);
console.log(`same + one "é" (U+00E9) : heap +${((h2 - h1) / 1048576).toFixed(1)} MiB`);
console.log(`same + one "中" (U+4E2D) : heap +${((h3 - h2) / 1048576).toFixed(1)} MiB`);
globalThis.keep = [ascii, latin, cjk];
$ node --expose-gc demo.mjs
8,000,000 x ASCII : heap +7.6 MiB
same + one "é" (U+00E9) : heap +7.6 MiB
same + one "中" (U+4E2D) : heap +15.3 MiB

Swap heap() for Bun.gc(true) plus process.memoryUsage().heapUsed and Bun 1.4.0 prints the same three numbers: 7.6, 7.6, 15.3. Both engines agree.

Status: [] Blocking enabled
Gravity: [] Update overdue
UTF-16 code units
56
UTF-8
60 B
Above Latin-1
2: ✓ U+2713, ✗ U+2717
Engine representation
2 bytes per unit (UTF-16)
Heap (payload only)
112 B = 56 × 2
Status: [\u2713] Blocking enabled
Gravity: [\u2717] Update overdue
UTF-16 code units
66
UTF-8
66 B
Above Latin-1
none
Engine representation
1 byte per unit (Latin-1)
Heap (payload only)
66 B = 66 × 1

Characters above Latin-1 are marked in red.

V8 can also print the type:

$ node --allow-natives-syntax -e '%DebugPrint("abc" + "é"); %DebugPrint("abc" + "中")'
DebugPrint: 0xd0faee56981: [String] in OldSpace: "abc\xe9"
- type: SEQ_ONE_BYTE_STRING_TYPE
DebugPrint: 0xd0faee56999: [String] in OldSpace: u"abc\u4e2d"
- type: SEQ_TWO_BYTE_STRING_TYPE

That charCodeAt in the script is required. a + b does not copy immediately in either engine. It builds a rope (V8 calls it ConsString, JSC calls it JSRopeString), and each half keeps its original representation. The eight million ASCII characters are still one-byte at that point. Index it, run a regexp, call indexOf, or write it as an HTTP response body, and the engine flattens the rope into a contiguous buffer. That is when the widest character decides the width of the whole thing. Without that line, all three numbers are 7.6.

%DebugPrint can show the rope itself:

$ node --allow-natives-syntax -e 'const s = "abcdefgh".repeat(4) + "中"; %DebugPrint(s)'
DebugPrint: 0x1be004ae841: [String]: uc"abcdefghabcdefghabcdefghabcdefgh\u4e2d"
- type: CONS_TWO_BYTE_STRING_TYPE

The c in the prefix means cons. The moment of concatenation, V8 already tags this rope as two-byte, because the right half is two-byte. The ASCII characters on the left still sit in their original one-byte buffer. The actual recopy at two bytes happens when the rope is flattened.

Both engines’ rules are in the source. V8’s String class comments restate the spec, then give the one-byte ceiling and the two sequential character types (string.h (opens in a new tab), unicode.h (opens in a new tab)):

v8/src/objects/string.h
// The String abstract class captures JavaScript string values:
//
// Ecma-262:
// 4.3.16 String Value
// A string value is a member of the type String and is a finite
// ordered sequence of zero or more 16-bit unsigned integer values.
V8_OBJECT class String : public Name {
// ...
// Max char codes.
static const int32_t kMaxOneByteCharCode = unibrow::Latin1::kMaxChar;
static const int kMaxUtf16CodeUnit = 0xffff;
};
V8_OBJECT class SeqOneByteString : public SeqString {
static const bool kHasOneByteEncoding = true;
using Char = uint8_t;
};
V8_OBJECT class SeqTwoByteString : public SeqString {
static const bool kHasOneByteEncoding = false;
using Char = uint16_t;
};
v8/src/strings/unicode.h
class Latin1 {
public:
static const uint16_t kMaxChar = 0xff;
};

In WebKit, one StringImpl carries a flag, and the data pointer is a union. A given string is only one of the two at a time (StringImpl.h (opens in a new tab)):

WebKit/Source/WTF/wtf/text/StringImpl.h
static constexpr const unsigned s_hashFlag8BitBuffer = 1u << 2;
// ...
bool is8Bit() const { return m_hashAndFlags & s_hashFlag8BitBuffer; }
// ...
std::atomic<uint32_t> m_refCount;
unsigned m_length;
union {
const Latin1Character* m_data8;
const char16_t* m_data16;
};

JSON.stringify does not escape non-ASCII. It only escapes quotes, backslashes, control characters, and lone surrogates. So if the JSON contains a Chinese description, the entire JSON.stringify result is two-byte, whether or not the rest is ASCII.

JSON.parse is not affected. Each string value it produces is its own string and picks a representation on its own:

$ node --allow-natives-syntax -e 'const o = JSON.parse(`{"a":"abcdefghabcdefgh","b":"abcdefghabcdefgh中"}`); %DebugPrint(o.a); %DebugPrint(o.b)'
DebugPrint: 0x19f36672e771: [String]: "abcdefghabcdefgh"
- type: SEQ_ONE_BYTE_STRING_TYPE
DebugPrint: 0x19f36672e791: [String]: u"abcdefghabcdefgh\u4e2d"
- type: SEQ_TWO_BYTE_STRING_TYPE

So after the catalog is parsed into objects, only the description fields on those 17 providers that contain CJK or em dashes are two-byte. Everything else stays one-byte. The doubling happens only on the serialized blob.

The fix

This string has one job: go out as an HTTP response body, unchanged. It has to become UTF-8 bytes on the way out anyway, so encode it at startup and keep a Uint8Array on the heap:

src/catalog-store.ts
return {
providerSummariesJson,
// TextEncoder rather than Buffer: the Cloudflare Workers build shares this function.
providerSummariesJson: new TextEncoder().encode(providerSummariesJson),
providerSummariesEtag: weakEtag(providerSummariesJson),

A Uint8Array lives in an ArrayBuffer. Counted in bytes, 6.47 MB is 6.47 MB.

/api/providers sends an ETag. Browsers and CDNs use it to decide whether a cached copy is still valid. The value is a hash of the JSON string, over UTF-16 code units, not UTF-8 bytes. Hash the bytes instead and every client that already cached this endpoint will treat the body as new and pull 6.47 MB again. So the hash still runs on the string, and the string can be dropped afterwards. Before the change the ETag was W/"62be35-aa331d99". After the change it is still that value, and the response body is byte-for-byte identical.

The result: with only names, descriptions, and action lists kept in memory, the JSC heap went from 26.4 MiB to 17.2 MiB. The default path, which also keeps schemas in memory, went from 74.7 MiB to 65.5 MiB. On macOS, though, ps still reported the same RSS. Bun’s allocator is mimalloc, which does not return freed pages to the OS right away, so the number ps shows does not drop with them.

If you have to keep a string, another option is to write the non-ASCII characters as \uXXXX in the JSON text. That is still valid JSON, the parsed values do not change, the 49 characters cost five extra characters each, and the whole string goes back to one byte. I tried it on this catalog (the string had grown to 6,472,664 characters) and hit a snag:

const escaped = json.replace(/[\u0100-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
json : 6,472,664 chars, heap +12.34 MiB, SEQ_TWO_BYTE_STRING_TYPE
escaped = json.replace(...) : 6,472,909 chars, heap +12.35 MiB, SEQ_TWO_BYTE_STRING_TYPE
JSON.parse(JSON.stringify(escaped)) : 6,472,909 chars, heap +6.17 MiB, SEQ_ONE_BYTE_STRING_TYPE

After escaping there is no code point above U+00FF left, but the result of replace() is still two-byte. Both engines explain why. V8’s replace with a callback goes through Runtime_RegExpReplaceRT. The result is built from substrings of the original and each replacement (runtime-regexp.cc (opens in a new tab)):

v8/src/runtime/runtime-regexp.cc
RUNTIME_FUNCTION(Runtime_RegExpReplaceRT) {
// ...
const bool functional_replace = IsCallable(*replace_obj);
// ...
IncrementalStringBuilder builder(isolate);
// ...
if (position >= next_source_position) {
builder.AppendString(
factory->NewSubString(string, next_source_position, position));
builder.AppendString(replacement);

NewSubString taken from a two-byte string is still two-byte. It keeps the parent’s representation and does not look at the contents. Once IncrementalStringBuilder has appended a two-byte piece it switches to two-byte and never switches back. If the subject is two-byte, the result is two-byte.

JSC’s replace always ends in jsSpliceSubstringsWithSeparators. It only looks at the source string’s flag and each replacement’s flag (StringPrototypeInlines.h (opens in a new tab)):

WebKit/Source/JavaScriptCore/runtime/StringPrototypeInlines.h
bool allSeparators8Bit = true;
for (int i = 0; i < separatorCount; i++) {
totalLength += separators[i].length();
if (separators[i].length() && !separators[i].is8Bit())
allSeparators8Bit = false;
}
// ...
if (source.is8Bit() && allSeparators8Bit) {
std::span<Latin1Character> buffer;
auto impl = StringImpl::tryCreateUninitialized(totalLength, buffer);

source.is8Bit() is a flag set when the source string was created. The 49 characters that were replaced are gone from the result, but the flag is not, so the result still allocates a 16-bit buffer. Both engines do the same thing: a string derived from another string inherits its width. To make the engine pick a representation from the contents, you have to build a new string, for example JSON.parse(JSON.stringify(escaped)), or encode to bytes and decode back. Then it is 6.17 MiB. Bun prints the same three numbers.

We did not take this path. Storing bytes is more direct, and there is another reason: keeping a string means encoding it again on every response. TextEncoder.encode() on 6.47 MB measured 3.1 ms on the macOS arm64 machine above. Bytes can be written straight to the socket.

One more measurement pitfall. In Node, a large string from TextDecoder.decode() is EXTERNAL_TWO_BYTE_STRING_TYPE. The data sits outside the V8 heap. v8.getHeapStatistics().used_heap_size does not see it. You have to look at process.memoryUsage().external. The first time I measured it, heap growth was 0. I had just missed this.

Script source on Cloudflare Workers

The same open-connector codebase also runs on Cloudflare Workers. Each Worker script runs in a V8 isolate. The isolate heap is capped at 128 MB. Go over that and the whole isolate is torn down and rebuilt. After shrinking the binary I checked how much this side actually used: start the service with wrangler dev, then send Runtime.getHeapUsage and HeapProfiler.takeHeapSnapshot over the inspector port.

The production shape (wrangler deploy --minify) sits at 88.7 MiB at steady state. The largest node in the snapshot is a 27.63 MiB string: the entire Worker script source. The script file itself is 14.5 MB.

V8 keeps script source on the heap for as long as the isolate lives, because compilation is lazy. Load time only pre-parses. A function is compiled the first time it runs, and bytecode for functions that have not run in a while can be dropped and recompiled from source on the next call. So the source has to stay.

Which representation the source uses when it enters the isolate is in workerd (the open-source Workers runtime), in modules-new.c++ (opens in a new tab):

workerd/src/workerd/jsg/modules-new.c++
// The source text of an ES module in the representation handed to V8 for
// compilation. V8 has no internal UTF-8 string representation — strings are
// either one-byte (Latin-1) or two-byte (UTF-16). Worker bundle sources arrive
// as UTF-8 bytes, so each module's source is encoded once, lazily, on first
// compile, and the result is shared by every isolate that compiles the module:
//
// * Pure-ASCII source (the overwhelmingly common case — bundlers typically
// escape non-ASCII): the original buffer directly backs a one-byte external
// string. Zero copies.
// * Non-ASCII source whose code points all fit in Latin-1: transcoded once to
// a one-byte buffer, matching the representation V8 itself would choose for
// the same text.
// * Anything else (CJK, emoji, ...): transcoded once to UTF-16.
// ...
EncodedSource transcodeSource(kj::ArrayPtr<const char> source) {
if (simdutf::validate_utf8(source.begin(), source.size())) {
// Valid UTF-8. Prefer the half-size Latin-1 representation when every code
// point permits it. The buffer is sized exactly, so with already-validated
// input a zero return can only mean some code point exceeds U+00FF.
auto latin1 =
kj::heapArray<char>(simdutf::latin1_length_from_utf8(source.begin(), source.size()));
if (simdutf::convert_utf8_to_latin1(source.begin(), source.size(), latin1.begin()) != 0) {
return {.repr = kj::arc<OwnedAscii>(kj::mv(latin1))};
}
auto utf16 =
kj::heapArray<uint16_t>(simdutf::utf16_length_from_utf8(source.begin(), source.size()));
// ...
return {.repr = kj::arc<OwnedUtf16>(kj::mv(utf16))};
}
// ...
}

The comment mentions that bundlers typically escape non-ASCII. The old module registry uses v8::String::NewFromUtf8, with the same representation rule. 14,483,642 characters times 2 is exactly 27.63 MiB.

Source does not have to be stored two-byte. Scan the 14.5 MB script and there are only two code points above U+00FF: a (U+2717) and a (U+2713), both in the regexes the Pi-hole provider uses to parse CLI output:

src/providers/pi_hole/runtime.ts
function readGravityStatus(text: string): string | null {
if (/\[\]|\berror\b|\bfatal\b|\bfailed\b/i.test(text)) {
return "failed";
}
if (/\[\]\s*done|\bdone\.?\s*$/im.test(text.trimEnd())) {
return "success";
}
return null;
}

The rest of the codebase is full of Chinese strings. Why only these two left? Because esbuild’s default charset (opens in a new tab) is ascii. When it prints a string literal, characters above 0xFF become \uXXXX, and 0x80 to 0xFF become \xXX. A regexp literal in the printer is one p.print(e.Value), copied as-is (js_printer.go (opens in a new tab)):

js_printer.go: string literals
// Is this an unpaired low surrogate or four-digit hex escape?
case (c >= firstLowSurrogate && c <= lastLowSurrogate) || (p.options.ASCIIOnly && c > 0xFF):
js = append(js, '\\', 'u', hexChars[c>>12], hexChars[(c>>8)&15], hexChars[(c>>4)&15], hexChars[c&15])
// Can this be a two-digit hex escape?
case p.options.ASCIIOnly:
js = append(js, '\\', 'x', hexChars[c>>4], hexChars[c&15])
js_printer.go: regexp literals
case *js_ast.ERegExp:
// ...
p.addSourceMapping(expr.Loc)
p.print(e.Value)

The docs say the same: non-ASCII in regexes is not escaped, because esbuild does not parse regex contents. So:

const ok = "[✓] done";
const re = /\[\]\s*done/;
// esbuild --minify
const o="[\u2713] done",e=/\[\]\s*done/;

Those two characters made V8 store the entire 14.5 MB script two-byte, 27.63 MiB. So the regexes got handwritten escapes too (open-connector#493 (opens in a new tab)):

src/providers/pi_hole/runtime.ts
function readGravityStatus(text: string): string | null {
if (/\[\]|\berror\b|\bfatal\b|\bfailed\b/i.test(text)) {
if (/\[\u2717\]|\berror\b|\bfatal\b|\bfailed\b/i.test(text)) {
return "failed";
}
if (/\[\]\s*done|\bdone\.?\s*$/im.test(text.trimEnd())) {
if (/\[\u2713\]\s*done|\bdone\.?\s*$/im.test(text.trimEnd())) {
return "success";
}
return null;
}

The regexes mean the same thing. The source text is pure ASCII again. Same machine, same build, measured before and after:

isolate heap, before first request : 40.56 MiB -> 26.74 MiB
isolate heap, steady state after full GC: 88.69 MiB -> 74.82 MiB

The 13.8 MiB saved is exactly the size of the script file. I also added a test that walks every regexp literal under src/ and reports file and line if it sees a character above U+00FF. If anyone puts an arrow in a regex literal again, CI fails first.

Two more notes:

  • The Bun single-file binary mentioned earlier ships 30 MB of JS and does not have this problem. When the target is bun, Bun’s bundler escapes all non-ASCII, including inside regexp literals, and the output is pure ASCII. Point the same file at browser or node and it is copied as-is, like esbuild:

    $ bun build --minify --target=bun in.js
    var o="[\u2713] done",e=/\[\u2713\]\s*done/;export{o as ok,e as re};
    $ bun build --minify --target=browser in.js
    var o="[✓] done",e=/\[✓\]\s*done/;export{o as ok,e as re};

    So in the binary, the thing that doubled was a data string. On Workers, it was the source.

  • A wrangler dev build without --minify has 2,909 non-Latin-1 characters, most of them in comments, and the source string is 58 MiB. Measure Worker memory on the production shape. The dev numbers are not useful.

How to check

To see whether a string will be stored two-byte:

/[^\u0000-\u00ff]/u.test(s);

On V8 you can print the type: node --allow-natives-syntax, then %DebugPrint(s), and look for SEQ_ONE_BYTE_STRING_TYPE or SEQ_TWO_BYTE_STRING_TYPE. In a DevTools heap snapshot, if a string’s shallow size is about twice its length, it is two-byte.

Places this shows up:

  • Large JSON response bodies cached in memory. Anything with a Chinese description or an em dash is almost certainly two-byte.
  • Runtimes that keep source on the heap, such as a Workers V8 isolate. Bundlers escape string literals. They do not escape regexes or comments.
  • i18n catalogs, templates, Markdown, any large text that is non-ASCII by nature.

Two easy traps: operations that derive a new string from an old one, such as replace() and slice(), keep the old string’s width. After escaping you have to build a new string for the change to take. External strings from TextDecoder are not in V8’s heap stats. Include external when you look at the heap.

Closing

The catalog now stores a Uint8Array. The Worker regexes are written with \uXXXX. With only names, descriptions, and action lists kept in memory, the JSC heap went from 26.4 MiB to 17.2 MiB. The Worker isolate at steady state went from 88.7 MiB to 74.8 MiB.

Reply to this post on X (opens in a new tab)View as Markdown