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.
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.
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
- You need a language-neutral wire format: turbo-stream is a JavaScript-specific format and other ecosystems do not have standard decoders for it
- Plain JSON covers your payload: the README's own benchmark shows full turbo encode-and-decode is slower than JSON, so rich values and progressive delivery must earn the added protocol
- You need a long-lived archival or public API format with a published compatibility specification: the public documentation describes behavior but does not promise cross-major wire compatibility
- Your runtime lacks Web ReadableStream, TransformStream, TextEncoderStream, or TextDecoderStream support; Node streams are not accepted directly by encode() or decode()
- You expect arbitrary class instances or functions to round-trip automatically: classes need paired plugins, functions become undefined, and only global symbols created with Symbol.for() are preserved
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); // trueReference 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
| Package | Registry | Pick it when |
|---|---|---|
| devalue | npm | You need compact rich-value serialization without progressive Web Stream delivery |
| superjson | npm | You want JSON-compatible data plus metadata for common rich JavaScript types |
| seroval | npm | You need broader JavaScript value serialization with plugin support and several output modes |