JavaScript Basics: Build and Read Immutable Blobs
A Blob packages bytes without exposing a writable view of its contents. It is useful when an API expects a single binary object assembled from strings or typed arrays. Start with small in-memory payloads before connecting them to files or HTTP requests.
These three independent examples were executed on Linux with Node.js v24.19.0. Use Node.js 24 for this tutorial; only that installed version was tested. They use built-in modules and assertions, with no package installation. They make no network requests and leave no data files behind. The output below is the observed output.
Prepare a scratch directory
You need Bash, Node.js on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep the same terminal open and save each named file as UTF-8 in the new directory. The .mjs extension selects an ES module.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Take a snapshot of mutable bytes
Save this as snapshot-blob-bytes.mjs:
import assert from 'node:assert/strict';
import { Blob } from 'node:buffer';
const source = new Uint8Array([79, 75]);
const payload = new Blob([source, '\n'], { type: 'text/plain' });
source.fill(88);
const text = await payload.text();
assert.equal(text, 'OK\n');
assert.equal(payload.size, 3);
assert.equal(payload.type, 'text/plain');
assert.deepEqual([...source], [88, 88]);
console.log(`text: ${text.trimEnd()}`);
console.log(`bytes: ${payload.size}; type: ${payload.type}`);
console.log(`source after edit: ${source.join(',')}`);Run it:
node snapshot-blob-bytes.mjsOutput:
text: OK
bytes: 3; type: text/plain
source after edit: 88,88The source array changes to two X bytes, but the Blob still contains OK and a newline. The Blob constructor copies typed-array inputs and encodes string parts as UTF-8. The type is a label, not validation that the payload really matches that media type.
2. Slice by byte offsets, not character positions
Save this as slice-blob-byte-ranges.mjs:
import assert from 'node:assert/strict';
import { Blob } from 'node:buffer';
const payload = new Blob(['AéB'], { type: 'text/plain' });
const middle = payload.slice(1, 3, 'text/plain');
const incomplete = payload.slice(1, 2);
assert.equal(payload.size, 4);
assert.equal(await middle.text(), 'é');
assert.equal(middle.size, 2);
assert.equal(await incomplete.text(), '\uFFFD');
assert.equal(incomplete.type, '');
assert.equal(await payload.text(), 'AéB');
console.log(`whole bytes: ${payload.size}`);
console.log(`middle text: ${await middle.text()}`);
console.log('split UTF-8 sequence: replacement character');Run it:
node slice-blob-byte-ranges.mjsOutput:
whole bytes: 4
middle text: é
split UTF-8 sequence: replacement characterThe character é occupies two UTF-8 bytes here. The end offset is exclusive: bytes 1 and 2 form the complete character. Keeping only byte 1 produces invalid UTF-8, which text() replaces during decoding. slice() creates another Blob without altering the original. Supply the new type explicitly when you need it.
3. Read a fresh copy or consume a stream
Save this as read-blob-copies-and-streams.mjs:
import assert from 'node:assert/strict';
import { Blob } from 'node:buffer';
const payload = new Blob([new Uint8Array([10, 20]), new Uint8Array([30])]);
const copy = new Uint8Array(await payload.arrayBuffer());
copy[0] = 99;
assert.deepEqual([...await payload.bytes()], [10, 20, 30]);
const collected = [];
for await (const chunk of payload.stream()) {
for (const byte of chunk) collected.push(byte);
}
assert.deepEqual(collected, [10, 20, 30]);
console.log(`edited copy: ${copy.join(',')}`);
console.log(`streamed bytes: ${collected.join(',')}`);
console.log(`still readable: ${(await payload.bytes()).join(',')}`);Run it:
node read-blob-copies-and-streams.mjsOutput:
edited copy: 99,20,30
streamed bytes: 10,20,30
still readable: 10,20,30Changing the arrayBuffer() copy leaves the original intact. The stream() method exposes a Web ReadableStream; this Node.js example consumes it with asynchronous iteration. Do not assume a particular chunk size. The byte-by-byte loop deliberately avoids that assumption, and the final read checks that the Blob remains usable.
Practical limits
These payloads are intentionally tiny. arrayBuffer(), bytes(), and text() materialize the complete contents; collecting a stream into an array does too. Process chunks incrementally for larger inputs. A Blob is not a file path and these examples never save one to disk. For strict text validation, use a fatal TextDecoder rather than accepting replacement characters from text().
Clean up
In the same terminal, remove only the three named source files, return to your starting directory, and remove the now-empty scratch directory:
rm -- snapshot-blob-bytes.mjs slice-blob-byte-ranges.mjs read-blob-copies-and-streams.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.