Passing Structured Data and Transferables

What survives chrome.runtime.sendMessage in MV3: the structured clone rules, why Map and Date behave differently from class instances, how to move binary data, and why transferables do not transfer.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

A message that works in development fails for one user with “Could not serialize message” or, worse, arrives with fields quietly missing. Extension messaging is not postMessage and not JSON either — it uses structured cloning with extension-specific limits, which means Date survives, a class instance loses its prototype, and an ArrayBuffer is copied rather than transferred. This page is part of Message Passing Architecture and maps out exactly what crosses the boundary.

Root cause: cloning, not referencing

Every extension message is serialised, copied into the receiving context and deserialised. Nothing is shared: the receiver gets its own copy, mutating it has no effect on the sender, and anything that cannot be represented without its original scope — a function, a DOM node, a proxy — cannot be represented at all. Class instances are the subtle case: the data survives, the prototype does not, so methods disappear and instanceof fails on the far side.

What happens to a value on its way across the boundaryPlain data is cloned faithfully, class instances arrive as plain objects, and functions or DOM nodes cause the send to fail outright.Plain dataprimitives, arrays, plain objectsidentical on arrivalBuilt-in structured typesDate, Map, Set, RegExp, ArrayBuffercloned, still usableClass instancesprototype droppedsilent — methods vanishFunctions, DOM nodes, proxiescannot be representedthe send rejects
The middle layer is the dangerous one: it succeeds, so nothing tells you the methods are gone until something calls one.

Step 1. Learn the table once

Most serialisation bugs come from assuming JSON semantics, where Date becomes a string and Map becomes an empty object. Structured cloning is richer, but extension messaging adds its own restriction: transferables are not transferred.

What survives an extension messageHow common value types behave when sent with runtime.sendMessage, compared with JSON serialisation.ValueExtension messagePlain JSONNotesstring, number, booleanIdenticalIdenticalSafeundefined in an objectKey keptKey droppedDiffersDateStays a DateBecomes a stringDiffersMap, SetClonedBecomes {}DiffersArrayBufferCopiedBecomes {}Cost is realClass instancePlain objectPlain objectMethods lostFunctionSend failsKey droppedNever worksDOM nodeSend failsSend failsSerialise it first
The right-hand column is why porting code from a JSON-based transport surprises people in both directions.

Step 2. Send data, not objects with behaviour

The fix for the silent case is to make the wire format explicit. Convert to a plain shape before sending and rebuild on arrival — the conversion functions also become the one place a schema change has to be handled.

 1// shared/wire.js — one module both sides import
 2export function toWire(record) {
 3  return {
 4    id: record.id,
 5    title: record.title,
 6    // A Date survives cloning, but an explicit epoch is easier to validate and log.
 7    createdAt: record.createdAt.getTime(),
 8    tags: [...record.tags],          // a Set would clone, but an array is cheaper to inspect
 9  };
10}
11
12export function fromWire(wire) {
13  return new Record({
14    id: wire.id,
15    title: wire.title,
16    createdAt: new Date(wire.createdAt),
17    tags: new Set(wire.tags),
18  });
19}

Execution context: Shared module imported by the sender and the receiver. Making the boundary explicit costs a few lines and removes the whole class of “why is record.isStale() not a function” bug, because the receiving side never sees a half-reconstructed instance. It also keeps the message loggable: a plain object prints usefully in the console, where a partially cloned instance does not.

Step 3. Move binary data without paying twice

ArrayBuffer clones rather than transfers in extension messaging — the third argument that carries a transfer list in worker.postMessage does not exist here. For a few kilobytes the copy is irrelevant; for megabytes it is not, and the answer is to move the bytes through storage or a blob URL and send only a handle.

 1// background.js — send a handle, not the payload
 2export async function stashBinary(bytes) {
 3  const id = `blob:${crypto.randomUUID()}`;
 4  // storage.session keeps it in memory and out of the profile on disk.
 5  await chrome.storage.session.set({ [id]: Array.from(new Uint8Array(bytes)) });
 6  return id;
 7}
 8
 9export async function claimBinary(id) {
10  const held = (await chrome.storage.session.get(id))[id];
11  if (!held) return null;
12  await chrome.storage.session.remove(id);   // single-use handle
13  return new Uint8Array(held).buffer;
14}

Execution context: Extension service worker; the receiver calls claimBinary with the handle it was sent. Storing a byte array rather than the buffer itself is deliberate — chrome.storage serialises with the same structured-clone rules, and an array round-trips predictably across every engine. For anything large enough that this matters, prefer a blob URL created in an offscreen document and send the URL, which avoids materialising the bytes twice. Note also that storage.session is the right area here: the handle is scratch data by definition.

Two ways to get a megabyte from A to BSending the buffer copies it through the message channel; sending a handle keeps one copy and passes a short string.sendMessage(buffer)clone on sendCopy in the channelserialise + deserialiseSecond copy on arrivalpeak memory doublesthe handle path avoids both copiesStash oncestorage.sessionsendMessage(handle)a short stringClaim and removesingle use
The handle path costs one string in the message and one read on the far side, whatever the payload size.

Step 4. Fail loudly at the boundary

A serialisation failure is reported as a rejected promise from sendMessage, which is easy to lose in a handler that does not await. Validate before sending so the error names the offending field.

 1// shared/assert-cloneable.js
 2export function assertCloneable(value, path = "message") {
 3  if (value === null || typeof value !== "object") {
 4    if (typeof value === "function") throw new TypeError(`${path} is a function`);
 5    return;
 6  }
 7  if (value instanceof Node) throw new TypeError(`${path} is a DOM node`);
 8  if (value.constructor && ![Object, Array, Date, Map, Set, RegExp, ArrayBuffer].includes(value.constructor)) {
 9    // Not fatal, but the far side will lose the prototype — say so during development.
10    console.warn(`${path} is a ${value.constructor.name}; it will arrive as a plain object`);
11  }
12  for (const [key, child] of Object.entries(value)) assertCloneable(child, `${path}.${key}`);
13}

Execution context: Shared module, called in development builds before every send. instanceof Node is only meaningful in a context that has a DOM, so the check is a no-op in the worker and does its work in content scripts and extension pages — which is where DOM nodes leak into messages in the first place. Strip the call in production, or gate it behind a build flag, since walking a large payload on every send is not free.

Step 5. Validate the shape on arrival, not just on send

Cloning guarantees the value arrived intact; it guarantees nothing about the value being what this version of your code expects. After an update the two sides of a message can be running different builds — a content script injected before the update is still the old code until its page reloads — so the receiver needs a version tag and a shape check.

 1// shared/protocol.js
 2export const PROTOCOL = 3;
 3
 4export function envelope(type, payload) {
 5  return { v: PROTOCOL, type, payload };
 6}
 7
 8export function openEnvelope(message) {
 9  if (typeof message !== "object" || message === null) return null;
10  if (message.v !== PROTOCOL) {
11    // An older content script from before the last update. Tell it to reload rather than
12    // trying to interpret a shape that no longer exists.
13    return { type: "PROTOCOL_MISMATCH", their: message.v ?? null };
14  }
15  if (typeof message.type !== "string") return null;
16  return message;
17}

Execution context: Shared module imported by both sides of every message. The mismatch branch is the part worth copying: a stale content script is a normal state after an update, not an error, and the useful response is to ask it to re-inject rather than to log a serialisation complaint. Because the envelope is a plain object it also survives cloning unchanged, which keeps the version check itself immune to the problems the rest of this page describes.

Cross-browser variation

  • Chrome 120+: structured clone with no transfer list. Serialisation failures reject with “Could not serialize message” naming no field, which is why the assertion above is worth having.
  • Firefox 121+: the same clone semantics under browser.runtime.sendMessage. Historically Firefox was stricter about some exotic types, so validate against Firefox as well as Chrome if you send anything beyond plain data.
  • Safari 17+: clone semantics match, but error text differs again and large payloads are noticeably slower — the handle pattern in step 3 matters more here.
  • All engines: undefined object properties are preserved, unlike JSON. Code that relies on JSON.stringify dropping them will see the key arrive with an undefined value.

Verification

  1. Send a Date, a Map and a class instance in one message. Log typeof, instanceof and the constructor name on the far side; the first two should survive and the third should arrive as a plain object.
  2. Send a function-valued property and confirm sendMessage rejects rather than silently dropping it.
  3. Send a 5 MB ArrayBuffer directly, then again via a handle, and compare peak memory in the browser task manager.
  4. Send an object with an explicitly undefined property and confirm the key exists on arrival.

FAQ

Why does my class instance lose its methods?

Because cloning copies data, not prototypes. Rebuild the instance on arrival from a plain wire shape, as in step 2 — there is no option that preserves the prototype across the boundary.

Can I transfer an ArrayBuffer instead of copying it?

Not in extension messaging: there is no transfer list. Within a page you can transfer to a Web Worker, but the extension message channel always copies, so route large payloads around it.

Is JSON.stringify a safer wire format?

It is a narrower one. Stringifying gives you predictable behaviour at the cost of turning dates into strings and maps into empty objects, and it adds a parse on both sides. An explicit wire shape gives you the same predictability without the double encoding.

What is the size limit for one message?

There is no documented hard cap, but throughput and memory make multi-megabyte messages a bad idea in every engine. Treat anything above a few hundred kilobytes as a candidate for the handle pattern.

Other Core APIs & Cross-Browser Data Management Resources