Skip to content

πŸ› Bug Report β€” Runtime APIs: structuredClone() and Durable Object storage serialize streams as {} with typescript_implemented_streamsΒ #8046

Open

Description

@james-elicx

Description

With typescript_implemented_streams, Web Streams objects are serialized as empty plain objects instead of being rejected. structuredClone(new ReadableStream()) returns {}, and so does postMessage(). Durable Object storage.put("key", stream) succeeds and stores {}.

The C++ implementation throws DataCloneError for all of these, and the spec agrees: streams are transferable but not serializable. Every stream-related class I tried does this:

  • ReadableStream, WritableStream, TransformStream
  • readers, writers and controllers
  • ByteLengthQueuingStrategy, CountQueuingStrategy
  • TextDecoderStream, CompressionStream
  • IdentityTransformStream, FixedLengthStream
  • a Response body

Nested values serialize too: structuredClone({ a: [stream] }) gives { a: [{}] }. The serializer appears to treat the TypeScript classes as ordinary objects with no own enumerable properties.

The silent {} is the problem. A Durable Object that persists a value containing a stream now loses the data without any error. JS RPC is not affected: passing a TransformStream, reader or writer over RPC still throws DataCloneError, and streams are still transferred.

Reproduction

worker.js:

import { DurableObject } from "cloudflare:workers";

export class Store extends DurableObject {
  async save() {
    await this.ctx.storage.put("body", new ReadableStream());
    return JSON.stringify(await this.ctx.storage.get("body"));
  }
}

export default {
  async test(ctrl, env) {
    let failed = false;
    for (const value of [new ReadableStream(), new WritableStream(), new TransformStream(), new CompressionStream("gzip")]) {
      const name = value.constructor.name;
      try {
        const clone = structuredClone(value);
        console.log(`structuredClone(${name}) returned ${JSON.stringify(clone)}`);
        failed = true;
      } catch (error) {
        console.log(`structuredClone(${name}) threw ${error.name}`);
      }
    }
    try {
      const stored = await env.STORE.get(env.STORE.idFromName("x")).save();
      console.log(`storage.put(ReadableStream) stored ${stored}`);
      failed = true;
    } catch (error) {
      console.log(`storage.put(ReadableStream) threw ${error.name}`);
    }
    if (failed) throw new Error("a stream was serialized");
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [(name = "main", worker = .main)],
  # typescript_implemented_streams only starts with this autogate.
  autogates = ["workerd-autogate-per-isolate-javascript-bootstrap"],
);

const main :Workerd.Worker = (
  modules = [(name = "worker", esModule = embed "worker.js")],
  compatibilityDate = "2026-10-27",
  compatibilityFlags = ["typescript_implemented_streams"],
  durableObjectNamespaces = [(className = "Store", uniqueKey = "store")],
  durableObjectStorage = (inMemory = void),
  bindings = [(name = "STORE", durableObjectNamespace = "Store")],
);
workerd test --experimental config.capnp

With the flag (workerd 2026-10-27, @cloudflare/workerd-darwin-arm64@1.20261027.1):

structuredClone(ReadableStream) returned {}
structuredClone(WritableStream) returned {}
structuredClone(TransformStream) returned {}
structuredClone(CompressionStream) returned {}
storage.put(ReadableStream) stored {}
[ FAIL ] main

With compatibilityFlags = [] (C++ streams):

structuredClone(ReadableStream) threw DataCloneError
structuredClone(WritableStream) threw DataCloneError
structuredClone(TransformStream) threw DataCloneError
structuredClone(CompressionStream) threw DataCloneError
storage.put(ReadableStream) threw DataCloneError
[ PASS ] main

Expected behavior

Serializing a stream, reader, writer, controller, queuing strategy or transform stream throws DataCloneError, as it does with the C++ implementation. That covers structuredClone(), postMessage() and Durable Object storage.

Activity

  1. jasnell commented on Oct 27, 2026

    @jasnell
    Collaborator

    This is another known item that we need to decide more generally on. C++ impls of APIs will throw exceptions if they are structuredClone'd... TypeScript impls do not and instead come through as empty ordinary objects. We can have them throw but I'm not sure it's worthwhile.

    @guybedford any opinions?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions