turbo-stream review
turbo-stream 3.2.1 is a JavaScript-to-JavaScript codec carried by Web ReadableStream. It preserves values that JSON cannot represent directly, including BigInt, Date, Map, Set, special numbers, typed arrays, circular identity, promises, async iterables, files, blobs, form data, and nested streams. A receiver can get the outer object while embedded async values are still arriving. The 3.2.1 release fixes `__proto__` handling and exponential-notation numbers. This is a transport format, not HTTP, validation, or durable storage.
Our turbo-stream 3.2.1 browser import measured 14.2 KB minified and 4.8 KB gzipped with 0 runtime dependencies. Choose it for JavaScript peers that need progressive nested values or object identity; prefer JSON or a cross-language codec for ordinary payloads.
We installed it
| Install | ✓ · 0.7s | 1 package on disk · 1 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package with exports map |
| Browser | 4.8 KB | gzipped (14.2 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does turbo-stream install cleanly?
Yes. In a fresh container with an empty cache, npm install turbo-stream finished in 0.7s, leaving 1 package and 1 MB on disk. npm audit reported no known vulnerabilities.
How much does turbo-stream add to a browser bundle?
4.8 KB gzipped (14.2 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does turbo-stream work with both ESM and CommonJS?
Yes. Both import 'turbo-stream' and require('turbo-stream') worked in Node 22 in our run. The package is published as CommonJS with an exports map.
Does turbo-stream include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
turbo-stream or devalue: which should you use?
devalue: Use it for rich JavaScript value serialization without progressive Web Stream delivery. Our turbo-stream 3.2.1 browser import measured 14.2 KB minified and 4.8 KB gzipped with 0 runtime dependencies.
When should you not use turbo-stream?
Other languages need to consume the payload: turbo-stream has no standard cross-language wire format
Use it if
- A server should send an outer object immediately while nested promises or async iterables continue producing values
- Maps, Sets, Dates, BigInts, typed arrays, circular links, or repeated object identity must survive transport
- Producer and consumer both run JavaScript with Web Streams and can pin the same turbo-stream major
- You need a dependency-free codec with CommonJS, ESM, an exports map, and bundled declarations
- Other languages need to consume the payload: turbo-stream has no standard cross-language wire format
- Plain JSON already expresses the data and progressive delivery adds no user-visible benefit
- You need a public archival format with a published cross-major compatibility promise: version 3 introduced a new encoding
- Your runtime supplies only classic Node streams: encode and decode operate on Web ReadableStream and need adapters or text transforms
- Unknown classes, local Symbols, or functions must round-trip automatically: custom classes need matching plugins and functions are unsupported
Setup reality
We installed turbo-stream 3.2.1 in our sandbox in 0.7 seconds. It left 1 package and 1 MB on disk, and npm audit found 0 known vulnerabilities. The package is 184 KB unpacked, with 0 direct dependencies, 0 peer dependencies, and an MIT license. No credentials, native build, service, or config file is required.
The package is CommonJS with an exports map that also provides an ESM import target. require() and ESM import both worked under Node 22, and TypeScript declarations are bundled. Our full browser import measured 14.2 KB minified and 4.8 KB gzipped. The registry declares no Node engine floor, so deployments must separately confirm Web Streams and any payload classes such as File, Blob, or FormData.
encode() produces a Web ReadableStream of strings. HTTP responses and files usually need bytes, so pipe through TextEncoderStream; feed response bytes through TextDecoderStream before decode(). Classic node:stream objects need Web Stream adapters. Embedded promises and iterables stay live after the outer decode resolves, which means late errors must be handled at each await or iteration point. Wire an AbortSignal through to stop production after disconnects.
Errors are redacted by default, and disabling redaction can expose names, messages, and stacks. Objects with toJSON() execute that method during encoding. Custom types require paired plugins deployed with the same tag and field order on both ends. Validate the decoded result at the application boundary. Version 3 changed the encoding format, while 3.2.1 specifically repairs __proto__ and exponential-number cases, so keep producer and consumer majors coordinated and retain fixture tests across upgrades.
Patterns
Encode and decode one rich value round-trip
import { encode, decode } from 'turbo-stream';
const stream = encode({ answer: 42, createdAt: new Date() });
const value = await decode(stream);`encode()` returns the exact string Web Stream shape accepted by `decode()` before any network byte conversion.
Keep collections and special numbers preserve-types
const input = {
ids: new Set([1, 2]),
labels: new Map([[1, 'one']]),
limit: Infinity,
counter: 42n
};
const output = await decode(encode(input));These runtime values survive the round trip; JSON would flatten, reject, or lose several of them.
Retain circular and repeated references preserve-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, output.self === output);Reference identity is encoded, so the format represents graphs rather than only JSON-style trees.
Deliver a nested Promise later stream-promise
const decoded = await decode(encode({
user: { id: 7 },
recommendations: fetchRecommendations(7)
}));
renderUser(decoded.user);
renderRecommendations(await decoded.recommendations);The outer object can arrive first. Handle rejection where the nested Promise is awaited.
Transport an async iterable stream-iterable
const decoded = await decode(encode({ events: eventSource() }));
for await (const event of decoded.events) {
console.log(event);
}A producer failure after iteration starts appears during the loop, not in the initial `decode()` result.
Encode strings into an HTTP body send-http
const body = encode(loadDashboard())
.pipeThrough(new TextEncoderStream());
return new Response(body, {
headers: { 'content-type': 'text/x-turbo-stream; charset=utf-8' }
});HTTP bodies carry byte chunks. `TextEncoderStream` converts turbo-stream's string chunks at that boundary.
Decode a fetch response decode-http
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()));Check status and content type yourself; `decode()` does not validate the HTTP response.
Stop pending values after disconnect abort-encoding
const controller = new AbortController();
request.signal.addEventListener('abort', () => controller.abort());
const stream = encode(loadDashboard(), { signal: controller.signal });The signal stops codec work waiting on async values. The underlying database or network calls need their own cancellation wiring.
Choose the serialized error exposure redact-errors
const safe = encode(result);
const publicMessage = encode(result, { redactErrors: 'Request failed' });
const internal = encode(result, { redactErrors: false });`false` may send an original stack and message. Keep redaction across trust boundaries.
Pair plugins for a custom class custom-type
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 wire = encode(money, { plugins: [encodeMoney] });
const copy = await decode(wire, { plugins: [decodeMoney] });Both peers must agree on tag and field order. Deploy plugin changes with the same protocol-version discipline as the core codec.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| devalue | npm | Use it for rich JavaScript value serialization without progressive Web Stream delivery. |
| superjson | npm | Use it when JSON plus metadata for common rich values is easier to inspect and transport. |
| @msgpack/msgpack | npm | Use it for a compact binary format with implementations outside JavaScript. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

