JavaScript Basics: Serialize Local Data with Node V8
JSON is convenient for interchange, but a local Node.js cache may contain a Map, a BigInt, or repeated references. The node:v8 serialization API turns supported JavaScript values into a Buffer and reconstructs them later. These examples explore what survives that round trip and how to put a small versioned cache on disk.
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. The final example creates and removes its own temporary cache directory. 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. Round-trip values that need more than plain JSON
Save this as serialize-rich-cache-values.mjs:
import assert from 'node:assert/strict';
import { serialize, deserialize } from 'node:v8';
const original = {
totals: new Map([['queued', 3]]),
labels: new Set(['daily', 'local']),
sequence: 9007199254740993n,
};
const bytes = serialize(original);
const restored = deserialize(bytes);
assert.ok(Buffer.isBuffer(bytes));
assert.deepEqual(restored, original);
assert.notEqual(restored, original);
assert.throws(() => serialize({ run: () => 1 }));
console.log(`queued: ${restored.totals.get('queued')}`);
console.log(`labels: ${[...restored.labels].join(',')}`);
console.log(`sequence: ${restored.sequence}`);
console.log('function value: rejected');Run it:
node serialize-rich-cache-values.mjsOutput:
queued: 3
labels: daily,local
sequence: 9007199254740993
function value: rejectedThe restored object has working Map and Set methods, and the large integer remains exact. The function rejection checks a boundary rather than assuming every value is supported. serialize() and deserialize() use a format compatible with the structured-clone model; this is a data snapshot, not a saved running program.
2. Preserve cycles and repeated references within the copy
Save this as serialize-reference-graph.mjs:
import assert from 'node:assert/strict';
import { serialize, deserialize } from 'node:v8';
const task = { label: 'export' };
const original = { first: task, second: task };
original.self = original;
const restored = deserialize(serialize(original));
assert.equal(restored.self, restored);
assert.equal(restored.first, restored.second);
assert.notEqual(restored.first, task);
restored.first.label = 'checked';
assert.equal(restored.second.label, 'checked');
assert.equal(task.label, 'export');
console.log(`cycle preserved: ${restored.self === restored}`);
console.log(`shared inside copy: ${restored.first === restored.second}`);
console.log(`original label: ${task.label}`);Run it:
node serialize-reference-graph.mjsOutput:
cycle preserved: true
shared inside copy: true
original label: exportBoth fields still point to one task inside the reconstructed graph. They do not point back into the original graph. Editing one restored field therefore affects the other restored reference but not the source task. These identity assertions distinguish preserving relationships from merely copying equal-looking field values.
3. Validate a versioned local cache after reading it
Save this as read-versioned-v8-cache.mjs:
import assert from 'node:assert/strict';
import { serialize, deserialize } from 'node:v8';
import { mkdtemp, writeFile, readFile, rm, rmdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
function decodeCache(bytes) {
if (bytes.length > 64 * 1024) throw new Error('Cache exceeds example limit');
const value = deserialize(bytes);
if (value?.formatVersion !== 1) throw new Error('Unsupported cache version');
if (!Number.isSafeInteger(value.itemCount) || value.itemCount < 0) {
throw new Error('Invalid item count');
}
return value;
}
const directory = await mkdtemp(join(tmpdir(), 'v8-cache-example-'));
const filename = join(directory, 'summary.bin');
try {
await writeFile(filename, serialize({ formatVersion: 1, itemCount: 3 }), {
flag: 'wx',
});
const restored = decodeCache(await readFile(filename));
assert.equal(restored.itemCount, 3);
assert.throws(() => decodeCache(serialize({ formatVersion: 2 })),
/Unsupported cache version/);
assert.throws(() => decodeCache(serialize({ formatVersion: 1, itemCount: -1 })),
/Invalid item count/);
assert.throws(() => decodeCache(Buffer.alloc(0)));
console.log(`cached items: ${restored.itemCount}`);
console.log('wrong version, invalid count, and empty input: rejected');
} finally {
await rm(filename, { force: true });
await rmdir(directory);
}
console.log('temporary cache removed');Run it:
node read-versioned-v8-cache.mjsOutput:
cached items: 3
wrong version, invalid count, and empty input: rejected
temporary cache removedA successful binary decode does not prove that the data fits your application. The formatVersion field and count check enforce this example’s small cache contract. The unique directory and exclusive write avoid replacing an existing file; finally cleans up after success or a failed assertion. The length check runs before decoding, but readFile has already loaded the file, so it is not a file-reading memory limit.
Practical limits
Node documents the serialized format as backward-compatible for disk storage, but equal values can produce different bytes. Do not use serialized-byte equality as semantic equality or as a canonical content identifier. Keep application schema versions separate from the binary format. This is Node/V8-oriented storage, not a cross-language protocol, encryption, or authentication. The examples trust locally generated input; a byte-length check alone does not make arbitrary external data safe or cheap to decode.
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 -- serialize-rich-cache-values.mjs serialize-reference-graph.mjs read-versioned-v8-cache.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.