Using JS String Builtins

This page answers one question: a WebAssembly module that works with lots of text — especially one compiled from a garbage-collected language like Kotlin, Dart or Java — spends much of its time converting between JavaScript strings and its own string representation. The JS String Builtins proposal lets Wasm operate on JavaScript strings directly. You want to know what it provides, how modules use it, and how to deploy it alongside engines that lack it.

Prerequisites

  • [ ] Familiarity with reference types (externref) and, ideally, Wasm GC.
  • [ ] A toolchain that can target the builtins (for example recent Kotlin/Wasm, Dart, or hand-written WAT for experiments).
  • [ ] Browsers with the feature for testing (current Chromium and Firefox; check Safari’s status).

The string problem

Wasm has no string type. Languages compiled to Wasm represent strings in their own way — UTF-8 bytes in linear memory (Rust, C++), or arrays of 16-bit code units in Wasm GC heap objects (Java-like languages). Every time such a string crosses into JavaScript — to set DOM text, to call a web API, to receive input — it is converted: copied, transcoded, allocated anew. For text-heavy UI code, those conversions dominate.

The obvious alternative — keep strings as JavaScript strings and hold them as externref — used to require calling imported JavaScript functions for every operation (length, charCodeAt, concat, substring), each an indirect call into JavaScript with overhead. JS String Builtins standardises a set of such functions under the wasm:js-string import namespace that the engine provides itself. Because the engine knows exactly what they are, it can implement them as fast intrinsics — often inlined — rather than generic calls into JavaScript.

Converting strings versus using JS string builtins Converting strings copies and transcodes text whenever it crosses between Wasm's own representation and JavaScript, allocating on both sides. With JS string builtins, the module holds JavaScript strings as externref and calls engine-provided wasm:js-string functions, which engines can inline, avoiding conversions entirely. own string representation copy + transcode at boundary allocations both sides costly for text-heavy UI fine for compute wasm:js-string builtins JS strings held as externref engine intrinsics, inlined no conversion to call web APIs text-heavy UI

Step 1 — import builtins in the module

A module imports builtin functions from the wasm:js-string namespace with the expected signatures:

(module
  (import "wasm:js-string" "length" (func $len (param externref) (result i32)))
  (import "wasm:js-string" "charCodeAt" (func $charCodeAt (param externref i32) (result i32)))
  (import "wasm:js-string" "concat" (func $concat (param externref externref) (result (ref extern))))
  (func (export "countA") (param $s externref) (result i32)
    (local $i i32) (local $n i32) (local $count i32)
    (local.set $n (call $len (local.get $s)))
    (block $done
      (loop $next
        (br_if $done (i32.ge_u (local.get $i) (local.get $n)))
        (if (i32.eq (call $charCodeAt (local.get $s) (local.get $i)) (i32.const 65))
          (then (local.set $count (i32.add (local.get $count) (i32.const 1)))))
        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (br $next)))
    (local.get $count)))

The proposal defines functions such as cast, test, fromCharCodeArray, intoCharCodeArray, fromCharCode, fromCodePoint, charCodeAt, codePointAt, length, concat, substring, equals and compare — see the proposal for exact signatures.

Step 2 — enable builtins at compile time

Builtins are opt-in when compiling: pass the builtins option so the engine supplies them instead of expecting them in the import object:

const { instance } = await WebAssembly.instantiateStreaming(fetch("text.wasm"), {}, { builtins: ["js-string"] });
console.log(instance.exports.countA("BANANA AND A BAG"));   // 4

If the engine does not support the option, it ignores it and expects the imports to be provided normally — which is what makes a fallback possible.

Step 3 — provide a JavaScript polyfill for older engines

Because unsupported engines treat wasm:js-string imports as ordinary imports, you can supply JavaScript implementations with the same behaviour:

const jsStringPolyfill = {
  length: (s) => s.length,
  charCodeAt: (s, i) => s.charCodeAt(i),
  concat: (a, b) => a + b,
  // ... the other functions the module imports
};
const { instance } = await WebAssembly.instantiateStreaming(
  fetch("text.wasm"),
  { "wasm:js-string": jsStringPolyfill },
  { builtins: ["js-string"] },
);

Engines with builtins use their intrinsics (and ignore your import object for that namespace); older engines call your functions. The polyfill is slower but correct, so one module works everywhere that supports externref.

Loading a module that uses JS string builtins The loader passes the builtins option and a polyfill import object. An engine that supports builtins provides wasm:js-string functions itself as fast intrinsics. An engine without support ignores the option and uses the JavaScript polyfill. The same module runs in both, faster where builtins exist. instantiate with builtins option + polyfill imports engine supports builtins? feature check yes: engine intrinsics inlined calls no: JS polyfill ordinary imports same module everywhere correct results

Step 4 — string constants

Languages need string literals. A companion mechanism lets modules import string constants by declaring imports from a reserved namespace (configured with the importedStringConstants compile option), so the engine creates the JavaScript strings at instantiation without a separate data-and-decode step. Toolchains that target builtins use it to make literals cheap; hand-written modules rarely need it.

Step 5 — know who benefits

The biggest beneficiaries are Wasm GC languages that implement UI and web APIs in Wasm — Kotlin/Wasm, Dart compiled to Wasm (Flutter web), Java via J2Wasm — where strings constantly flow to and from the DOM. Their toolchains use builtins when targeting engines that support them. For Rust or C++ modules that process text in linear memory as UTF-8, builtins matter less: those modules usually convert at the boundary once per call and work on bytes internally. Choose based on where your strings live and how often they cross.

Moving between JS strings and Wasm arrays

Programs still need to process string contents, not just pass strings along. The builtins include bulk conversions between JavaScript strings and Wasm GC arrays of 16-bit code units: fromCharCodeArray creates a JavaScript string from a range of an (array (mut i16)), and intoCharCodeArray copies a string’s code units into such an array. A parser written in a GC language can therefore pull text into its own array once, scan it with plain array accesses, and produce results — or build output in an array and turn it into a JavaScript string in one call. That keeps per-character work inside Wasm, where it is fast, and boundary work to one bulk operation per string, which is the same trade-off as with linear-memory strings but without UTF-8 transcoding, since both sides use 16-bit code units.

Detecting support explicitly

The polyfill approach makes explicit detection optional, but sometimes you want to know — to choose between two builds, or to report which path users get. A probe module that imports one builtin and is compiled with { builtins: ["js-string"] } and an empty import object succeeds only where the engine provides the builtin; where it does not, instantiation fails with a LinkError because the import is missing. Wrap that in a small function, cache the result, and report it with your telemetry so you can see how many users run the fast path.

Correctness details

Builtins follow JavaScript semantics exactly: strings are sequences of UTF-16 code units, charCodeAt returns code units (not code points), lone surrogates are allowed, and length counts code units. Code ported from languages with UTF-8 or code-point semantics must account for that, the same way it would when calling JavaScript functions. The cast and test builtins check whether an externref actually holds a string, which matters because externref can hold any value.

Expected output

The countA example runs with engine intrinsics in current Chromium and Firefox and with the polyfill elsewhere, producing identical results; a Kotlin/Wasm app compiled with builtins support spends a fraction of its previous time on string conversions in DOM-heavy views; and the loader passes both the builtins option and the polyfill so one module serves all supported browsers.

Gotchas

  • Forgetting the builtins compile option. The engine expects ordinary imports. Pass the option.
  • No polyfill. Older engines fail to link. Provide JavaScript implementations.
  • Expecting gains for UTF-8 linear-memory code. The win is for strings that live as JS strings.
  • Mismatched signatures in imports. Linking fails or the builtin is rejected. Follow the proposal’s signatures.
  • Assuming universal support. Check current browser status and keep fallbacks.
  • Assuming code points. Builtins use UTF-16 code units like JavaScript. Handle surrogates explicitly.

Performance note

In a DOM-heavy benchmark from a GC language, string handling time dropped by about 70% with builtins compared with calling imported JavaScript functions, because engines inline the builtin calls.

Time spent on string operations in a DOM-heavy view Relative time spent on string operations for a GC-language UI compiled to Wasm when converting strings at the boundary, when calling ordinary imported JavaScript string functions, and when using wasm:js-string builtins. relative time on strings convert at boundary 1 × imported JS functions 0.6 × js-string builtins 0.3 ×

Frequently Asked Questions

Is this the stringref proposal? No — stringref proposed a new string type; JS String Builtins reuse JavaScript strings through externref and imports.

Do builtins work outside browsers? They are defined for JavaScript embeddings; non-JS hosts have no JavaScript strings.

Can Rust use them? With manual imports and externref, yes, but most Rust code is better served by UTF-8 in linear memory.

Does wasm-bindgen use them? Check current wasm-bindgen releases; support for newer string mechanisms has been discussed and may evolve.

How can a module scan string contents efficiently? Copy the string into a Wasm GC i16 array once with intoCharCodeArray, scan it inside Wasm, and build output with fromCharCodeArray.

How do I detect builtins support explicitly? Instantiate a probe that imports one builtin with the builtins option and no imports; a LinkError means no support.

What does cast do? It checks that an externref holds a JavaScript string and traps otherwise; test returns whether it does.

Should I report which path users get? Yes — cache the detection result and include it in telemetry to see how many users run the fast intrinsics.

← Back to Post-MVP Wasm Proposals in Practice