mrkeyoor.com_
Sat 08 Aug 22:00 UTC
npmUtilsupdated 08 Aug 2026

turbo-stream

turbo-stream is a JavaScript serialization format built around Web ReadableStream. It can encode values that JSON loses, including undefined, BigInt, Date, Map, Set, URL, RegExp, typed arrays, circular references, repeated references, errors, promises, async iterables, blobs, files, form data, and nested streams. The decoder can return the outer object before slower promises or iterables finish arriving, which makes it useful for server-to-client streaming. It is a transport codec, not an HTTP framework, storage format, or validation layer.

Verdict

A sharp choice for JavaScript-to-JavaScript streaming when nested async values and object identity matter. Prefer JSON or a more widely specified format when interoperability, archival stability, or simple payload inspection matters more.

API stability3/5Version 3 exposes a pleasingly small public surface: encode(), decode(), two option objects, and paired plugin types. That is easy to wrap and test. The README also says the new version was rewritten from the ground up and no longer resembles its devalue-derived predecessor, while no formal cross-major wire-compatibility promise is published. Pin the major on both producer and consumer and test recorded payload fixtures before upgrading.
Docs3/5The README clearly lists supported values, states the Web ReadableStream contract, gives the basic round trip, and publishes a reproducible benchmark command. The generated site resolves correctly. Important production behavior is left to declarations and source, including error redaction, AbortSignal, highWaterMark, plugin return contracts, byte conversion for HTTP, and what happens when a decoder lacks a matching plugin.
Maintenance4/5Version 3.2.0 was published in February 2026 and the repository was pushed minutes before that release. It is not archived, reports five open issues and pull requests, uses current TypeScript in development, and checks its package types with arethetypeswrong. The project still has a small maintainer footprint and a compact public roadmap, so applications should not treat release recency as a support contract.
Ecosystem4/5The package recorded 3,306,330 downloads in the measured week, publishes both ESM and CommonJS entry points, bundles declarations, and depends only on Web platform stream primitives rather than framework-specific objects. Its ecosystem boundary is still narrow: the format is JavaScript-specific, paired custom-type plugins must be coordinated manually, and there is no documented family of non-JavaScript decoders.

Use it if

  • You need to stream an object graph whose nested promises should settle on the receiving side as data arrives
  • You need Map, Set, Date, BigInt, typed arrays, circular references, or repeated object identity to survive transport
  • Both ends run JavaScript with Web Streams and can share the same turbo-stream major version
  • You want a zero-runtime-dependency codec with ESM, CommonJS, and bundled TypeScript declarations
Skip it if

Setup reality

Install with npm install turbo-stream. Version 3.2.0 has no runtime dependencies, includes declarations, and exposes import and require conditions, so package installation is easy. Runtime plumbing is where the work lives. encode() returns ReadableStream<string>, while HTTP bodies and files normally carry bytes; pipe through TextEncoderStream before sending or writing, then TextDecoderStream before decode(). Node's classic Readable and Writable streams need Readable.toWeb() or Writable.toWeb() adapters. The package declares no Node engine, so verify that every deployed runtime provides the Web Streams and Web platform classes used by your payload, especially File, Blob, FormData, atob, and btoa. Errors are redacted by default to avoid leaking messages and stacks; disabling that is a security decision, not formatting. The encoder calls an object's toJSON() method, which can execute application code and replace the original type. Non-global symbols and functions decode as undefined. Custom types require matching encode and decode plugins deployed together; an unknown plugin value currently becomes undefined rather than throwing. Embedded promises, async iterables, and readable streams remain live values after the outer object decodes, so consumers must await or iterate them and handle late failures. Use AbortSignal to stop async production when a client disconnects. There are no credentials or config files, but you need an explicit content type, byte encoding, version coordination, cancellation policy, and payload validation around the decoded result.

Patterns

Encode and decode a valueround-trip-value

import { decode, encode } from 'turbo-stream';

const stream = encode({ answer: 42, createdAt: new Date() });
const value = await decode(stream);
console.log(value.answer, value.createdAt instanceof Date);

decode() expects a Web ReadableStream of strings, exactly what encode() returns before any network or file byte conversion.

Preserve maps, sets, URLs, and special numberspreserve-rich-types

const input = {
  ids: new Set([1, 2]),
  labels: new Map([[1, 'one']]),
  endpoint: new URL('https://example.com/items'),
  limit: Infinity,
  missing: undefined,
  counter: 42n,
};

const output = await decode(encode(input));

These values retain their runtime types; JSON would drop undefined, reject BigInt, and flatten the other objects.

Round-trip circular and repeated referencespreserve-object-identity

const shared = { name: 'shared' };
const input = { first: shared, second: shared };
input.self = input;

const output = await decode(encode(input));
console.log(output.first === output.second); // true
console.log(output.self === output); // true

Reference identity is part of the format, which is useful for graphs but makes turbo-stream different from a plain tree serializer.

Send a promise inside the initial objectstream-nested-promise

const payload = {
  user: { id: 7 },
  recommendations: fetchRecommendations(7),
};

const decoded = await decode(encode(payload));
renderUser(decoded.user);
renderRecommendations(await decoded.recommendations);

The outer object can decode before the nested promise settles; the later rejection must be handled where that promise is awaited.

Transport an async iterablestream-async-iterable

async function* events() {
  yield { type: 'ready' };
  yield { type: 'progress', value: 50 };
}

const decoded = await decode(encode({ events: events() }));
for await (const event of decoded.events) {
  console.log(event);
}

An error raised after iteration begins arrives as an iteration failure, not as a rejection from the initial decode() call.

Transport a nested Web ReadableStreamstream-readable-stream

const messages = new ReadableStream({
  start(controller) {
    controller.enqueue('first');
    controller.enqueue('second');
    controller.close();
  },
});

const decoded = await decode(encode({ messages }));
for await (const message of decoded.messages) console.log(message);

This is a Web ReadableStream, not node:stream.Readable; adapt classic Node streams before placing them in the payload.

Convert string chunks to bytes for HTTPsend-http-response

const body = encode({
  profile: getProfile(),
  activity: getActivity(),
}).pipeThrough(new TextEncoderStream());

return new Response(body, {
  headers: { 'content-type': 'text/x-turbo-stream; charset=utf-8' },
});

encode() emits strings, while HTTP response bodies use byte chunks; TextEncoderStream supplies that boundary explicitly.

Decode a byte response from fetchdecode-http-response

const response = await fetch('/api/dashboard');
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);

const data = await decode(
  response.body.pipeThrough(new TextDecoderStream())
);

decode() does not validate status codes or content types; check the response before passing its string stream to the parser.

Abort async encoding on disconnectcancel-encoding

const controller = new AbortController();
request.signal.addEventListener('abort', () => controller.abort());

const stream = encode(loadDashboard(), {
  signal: controller.signal,
});

The signal stops turbo-stream from waiting for pending promises and iterables, but your underlying work also needs its own cancellation wiring.

Tune the encoder flush thresholdcontrol-chunk-size

const stream = encode(largePayload, {
  highWaterMark: 64 * 1024,
});

The default is 16 KB measured as accumulated string length; increasing it reduces chunk frequency but delays each flush and retains more text.

Choose how serialized errors are exposedconfigure-error-redaction

const publicStream = encode(result); // message becomes <redacted>
const customStream = encode(result, { redactErrors: 'Request failed' });
const internalStream = encode(result, { redactErrors: false });

false includes the original name, message, and stack; keep the default or a custom public message across trust boundaries.

Round-trip a class with paired pluginsserialize-custom-class

class Money {
  constructor(public cents, public currency) {}
}

const encodeMoney = (value) =>
  value instanceof Money ? ['Money', value.cents, value.currency] : false;
const decodeMoney = (type, cents, currency) =>
  type === 'Money' ? { value: new Money(cents, currency) } : false;

const stream = encode(new Money(1250, 'USD'), { plugins: [encodeMoney] });
const money = await decode(stream, { plugins: [decodeMoney] });

Producer and consumer must deploy matching tags and data order; without a matching decode plugin, the custom value becomes undefined.

Alternatives

PackageRegistryPick it when
devaluenpmYou need compact rich-value serialization without progressive Web Stream delivery
superjsonnpmYou want JSON-compatible data plus metadata for common rich JavaScript types
serovalnpmYou need broader JavaScript value serialization with plugin support and several output modes