Contents
Node.js 27 alpha upgrades V8 from 14.6 to 15.6, and V8 15.6 writes structured clone data in wire format version 16. Node.js 26.13.0, 24.22.2 and 22 only understand up to version 15. They reject any buffer with a newer version in its header, before looking at the payload, with Error: Unable to deserialize cloned data due to invalid or unsupported version.
That format is what v8.serialize(), structuredClone(), postMessage and child process IPC with serialization: 'advanced' produce. The Node.js team’s notes on V8 changes in 27 list it under potentially breaking changes: older releases fail to read the new format, while Node.js 27 still reads data from older releases.
The Node.js v8 docs describe the format as “backward-compatible (i.e. safe to store to disk)”. Both halves of that sentence are accurate, and the first half is the important one. Backward compatible means new readers accept old data. Nothing promises that old readers accept new data, and that is exactly the direction a canary or a rollback exercises.
What the version bump is
The changelog lives as a comment at the top of V8’s value-serializer.cc. At the v27.0.0-alpha.0.0.0 tag it ends with:
// Version 16: don't truncate JSArrayBuffer's and JSArrayBufferView's lengths
// and offsets to 32-bit values and write full size_t's instead,
// make sure deserializer is ready to handle 64-bit values even on
// 32-bit architectures. Allow serialization of resizable
// ArrayBuffers with maxByteLength larger than 4GB.
kLatestVersion is 16 at that tag. At v26.13.0 and v24.22.2 it is 15.
The feature itself is reasonable. Typed arrays and resizable buffers over 4 GB can now go through structuredClone() and postMessage(). The cost is that changing how a tag is encoded forces a version bump, and the reader treats the version as a gate on the whole buffer. This is the header check, identical in the 26 and 27 source trees:
Maybe<bool> ValueDeserializer::ReadHeader() {
if (position_ < end_ &&
*position_ == static_cast<uint8_t>(SerializationTag::kVersion)) {
ReadTag().ToChecked();
if (!ReadVarintLoop<uint32_t>().To(&version_) ||
version_ > kLatestVersion) {
isolate_->Throw(*isolate_->factory()->NewError(
MessageTemplate::kDataCloneDeserializationVersionError));
return Nothing<bool>();
}
}
return Just(true);
}
So the failure does not depend on whether your data contains a large buffer. A plain object written by Node 27 is just as unreadable on Node 26 as a 5 GB Float64Array.
The header is easy to see. The version tag is 0xFF followed by the version as a varint. On Node 22, v8.serialize({ a: 1 }) starts with ff 0f. Under wire format 16 the second byte is 0x10. Feeding Node 22 a hand-made buffer that starts ff 10 throws the version error above, which is the same check Node 26 will run on real Node 27 output.
Where the bytes cross versions
Inside one process nothing changes. A worker thread and its parent run the same V8, so postMessage and structuredClone are unaffected. The risk is anywhere serialized bytes outlive the process that wrote them or travel to a process built from a different Node binary:
- Values stored with
v8.serialize()in Redis, Memcached, a database column or a file, then read by another service or another deploy. - Build and test caches on a shared disk or restored from a CI cache, when jobs run on more than one Node major.
- IPC with
serialization: 'advanced'where the child is launched with a differentexecPaththan the parent.fork()uses the parent’s binary by default, so this only bites when you point it elsewhere.
Jest is a useful example of how this degrades when the reader is careful. On Jest’s main branch, its haste map cache, in CacheManager.ts, persists with serialize() from node:v8 and reads inside a try. A failed deserialize() returns an empty map, which means a full file crawl. The cache file name is a hash of the Jest version, roots, extensions, ignore pattern and similar options in getCacheFilePath. The Node version is not one of the inputs.
Put a Node 26 job and a Node 27 job in one matrix sharing that cache directory. Node 27 reads the old file fine and writes version 16. The next Node 26 run cannot read it, rebuilds from scratch, and writes version 15. Nothing fails. One leg of the matrix just stops getting a warm cache, and the only signal is a slower run. Code that calls v8.deserialize() without that try gets an exception instead, which is louder and arguably better.
The rollback problem
The same V8 file says this directly about its own format:
// WARNING: Increasing this value is a change which cannot safely be rolled
// back without breaking compatibility with data stored on disk.
That warning is aimed at browser storage such as IndexedDB, but it applies to a Node service the same way. Say a service caches sessions in Redis with v8.serialize(). You canary Node 27 on a slice of instances. Every entry the canary writes is unreadable by the rest of the fleet, so the error rate on Node 26 instances tracks how much the canary has written, not anything the canary did wrong. Roll the canary back and the Node 26 fleet is left with every entry written during the window. Your rollback plan for the runtime now depends on cache TTLs.
Treat this upgrade like a schema migration. Readers must be able to handle the new format before any writer produces it, and here no Node 26 reader ever will. That leaves three workable options: keep v8-serialized data out of shared storage, partition it by format version, or make old readers treat newer data as a miss.
A guard to deploy before the upgrade
The third option is a few lines. Read the version from the header and skip anything newer than what this process writes:
const v8 = require('node:v8');
function wireFormatVersion(buf) {
if (buf.length < 2 || buf[0] !== 0xff) return null;
let version = 0, shift = 0, i = 1, byte;
do {
byte = buf[i++];
version += (byte & 0x7f) * 2 ** shift;
shift += 7;
} while (byte & 0x80 && i < buf.length);
return version;
}
const LOCAL_WIRE_VERSION = wireFormatVersion(v8.serialize(null));
function readCached(buf) {
const v = wireFormatVersion(buf);
if (v === null || v > LOCAL_WIRE_VERSION) return undefined; // cache miss
return v8.deserialize(buf);
}
On Node 22 this reports LOCAL_WIRE_VERSION as 15, returns { a: 1 } for its own output, and returns undefined for an ff 10 buffer. Ship it on Node 26 before any Node 27 instance writes, and a mixed fleet degrades to cache misses rather than exceptions.
For cache keys, prefer LOCAL_WIRE_VERSION over v8.cachedDataVersionTag(). The Node.js notes say the tag now changes whenever Node applies patches on top of V8, not only on V8 version changes. That is correct for compiled code caches, which really are tied to the exact build, and wasteful for structured clone data, which only changes when the wire version does.
If the data leaves the process and has a consumer you do not deploy together with the producer, the better fix is an explicit format such as JSON, MessagePack or Protocol Buffers. The format’s own header comment names its use case: a browser persisting values, for example in IndexedDB, and reading them back later, possibly after an upgrade. It does not promise anything after a downgrade, which makes it a poor contract between services that upgrade on their own schedules.
The same Node.js 27 notes mention two other changes in this area. The built-in module compile cache and vm.Script cached data may be invalidated more often across patch and minor releases. And the default old-generation heap limit becomes half of physical or cgroup memory, clamped between 256 MB and 4 GB, so an 8 GB machine gets 4 GB instead of 2 GB. If you size containers around the old default, set --max-old-space-size explicitly before testing 27.
Sources
- Notable V8 changes from Node.js 26 to 27 (Alpha), Node.js blog, 2026-11-05. Primary. Serialization format change,
cachedDataVersionTag()behavior, heap limit. value-serializer.ccat v27.0.0-alpha.0.0.0 and at v26.13.0. Primary. Version history comment,kLatestVersion,ReadHeader().- Node.js
v8module docs, Serialization API. Primary. Compatibility statement,readHeader()andgetWireFormatVersion(). - Node.js
child_processdocs, Advanced serialization. Primary. IPC uses thenode:v8serialization API. - Jest
CacheManager.tsandjest-haste-map/src/index.ts. Source. Haste map cache read and write path, cache file name inputs.