JavaScript Basics: Handle UTF-8 Chunks with TextEncoder and TextDecoder
Text and byte chunks do not have matching boundaries. A chunk can end halfway through a UTF-8 character, and a destination buffer may be too small for the next character. TextEncoder and TextDecoder provide explicit ways to handle both cases.
These examples were tested on Linux with Node.js v24.19.0. They use built-in APIs only, require no network access, and include assertions that check the results. These APIs are available globally in Node.js. The WHATWG Encoding Standard defines incremental decoding and error behavior; Node’s utility reference documents its implementation.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep using the same terminal. Save each named source file as UTF-8; .mjs selects ES modules. Each example runs independently.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Encode into limited space
Save this as encode-into-byte-storage.mjs:
import assert from 'node:assert/strict';
const encoder = new TextEncoder();
const text = 'A🍵B';
const first = new Uint8Array(4);
const progress = encoder.encodeInto(text, first);
assert.deepEqual(progress, { read: 1, written: 1 });
assert.deepEqual(Array.from(first), [65, 0, 0, 0]);
const remaining = text.slice(progress.read);
const second = new Uint8Array(5);
const rest = encoder.encodeInto(remaining, second);
assert.deepEqual(rest, { read: 3, written: 5 });
assert.equal(new TextDecoder().decode(second), '🍵B');
assert.deepEqual(encoder.encodeInto('🍵', new Uint8Array(3)),
{ read: 0, written: 0 });
console.log(`First pass: read ${progress.read}, wrote ${progress.written}`);
console.log(`Second pass: read ${rest.read}, wrote ${rest.written}`);
console.log(new TextDecoder().decode(second));Run it:
node encode-into-byte-storage.mjsOutput:
First pass: read 1, wrote 1
Second pass: read 3, wrote 5
🍵Bread counts consumed UTF-16 code units; written counts UTF-8 bytes. The first destination has only three bytes left after A, so the four-byte emoji is left for another call. Use read to advance the source and use only the written portion of a destination. A loop must handle zero progress, usually by providing more space. TextEncoder always writes UTF-8.
2. Decode characters split across chunks
Save this as decode-split-utf8.mjs:
import assert from 'node:assert/strict';
const chunks = [
Uint8Array.from([0xe2, 0x82]),
Uint8Array.from([0xac, 0x20, 0xf0, 0x9f]),
Uint8Array.from([0x8d, 0xb5]),
];
const decoder = new TextDecoder('utf-8', { fatal: true });
const pieces = chunks.map(chunk => decoder.decode(chunk, { stream: true }));
pieces.push(decoder.decode());
const text = pieces.join('');
assert.deepEqual(pieces, ['', '€ ', '🍵', '']);
assert.equal(text, '€ 🍵');
assert.equal(new TextDecoder().decode(new Uint8Array()), '');
console.log(JSON.stringify(pieces));
console.log(text);Run it:
node decode-split-utf8.mjsOutput:
["","€ ","🍵",""]
€ 🍵Keep one decoder for the whole byte sequence. stream: true retains incomplete characters between calls. The final call without that option flushes buffered input and ends the sequence, even when it returns an empty string. This test splits both a euro sign and an emoji; decoding each chunk with a fresh decoder would lose that continuity.
3. Reject malformed and unfinished input
Save this as reject-invalid-utf8.mjs:
import assert from 'node:assert/strict';
const malformed = Uint8Array.from([0xc3, 0x28]);
const repaired = new TextDecoder('utf-8').decode(malformed);
assert.equal(repaired, '\ufffd(');
assert.throws(() => new TextDecoder('utf-8', { fatal: true })
.decode(malformed), TypeError);
const decoder = new TextDecoder('utf-8', { fatal: true });
const pending = decoder.decode(Uint8Array.from([0xe2, 0x82]), { stream: true });
assert.equal(pending, '');
assert.throws(() => decoder.decode(), TypeError);
assert.equal(new TextDecoder('utf-8', { fatal: true })
.decode(Uint8Array.from([0x4f, 0x4b])), 'OK');
console.log(`Replacement decoding: ${JSON.stringify(repaired)}`);
console.log('Malformed bytes rejected');
console.log('Unfinished final character rejected');Run it:
node reject-invalid-utf8.mjsOutput:
Replacement decoding: "�("
Malformed bytes rejected
Unfinished final character rejectedThe default decoder replaces malformed input with the replacement character, U+FFFD. Set fatal: true when damaged input should fail instead. An unfinished character becomes an error only when the sequence ends, which is why the final flush matters. Successful decoding checks the byte encoding; it does not validate a document schema, make text safe for HTML, or establish that a sender is trustworthy.
Clean up
In the same terminal, remove only the named example files, return to your original directory, and remove the empty scratch directory:
rm -- encode-into-byte-storage.mjs decode-split-utf8.mjs reject-invalid-utf8.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.