In the last post What’s the Request
object in the browser, Cloudflare, and Node?, I detailed the how the Request class is the same regardless the implementation. However, when handling streams, there are some practical differences, even though both runtimes expose the same Fetch API.
What differs with streams
-
Stream types available
-
Cloudflare Workers: only Web Streams (
ReadableStream
,WritableStream
,TransformStream
). -
Node 18+: has both classic Node streams and Web Streams.
fetch
uses Web Streams, but Node lets you pass a Node Readable as a request body.
-
Cloudflare Workers: only Web Streams (
-
Streaming request bodies (uploads)
-
Node: if the request body is a stream, you must set
duplex: 'half'
in the init/options. If you forget, you’ll get: “duplex option is required when sending a body that is a ReadableStream”. -
Workers: no
duplex
flag; just pass a WebReadableStream
.
-
Node: if the request body is a stream, you must set
-
Bridging between stream kinds (Node only)
- Convert Node → Web:
Readable.toWeb(nodeWritable)
/Readable.from(nodeReadable)
(orReadable.fromWeb
). - You’ll use this when adapting
http.IncomingMessage
↔︎ Fetch.
- Convert Node → Web:
-
Response bodies (downloads)
- Both return a Web
ReadableStream
atresponse.body
. - In Node, to send it to
http.ServerResponse
, convert the Node writable to a Web Writable andpipeTo
.
- Both return a Web
Tiny patterns
Node: adapt IncomingMessage
→ Request
(streaming upload)
import { Readable } from 'node:stream'
const request = new Request(url, {
method,
headers,
body: hasBody ? (req as unknown as Readable) : undefined, // Node stream ok
// Node/undici requirement for streamed bodies:
// @ts-expect-error undici extension
duplex: hasBody ? 'half' : undefined,
})
Node: write a streamed Response
back to ServerResponse
import { Readable } from 'node:stream'
const response = await fetchHandler(request)
res.statusCode = response.status
response.headers.forEach((v, k) => res.setHeader(k, v))
if (response.body) {
// pipe Web ReadableStream → Node ServerResponse
// @ts-ignore types can lag behind
await response.body.pipeTo(Readable.toWeb(res))
} else {
res.end()
}
Workers: streaming is Web-Streams-native
export default {
async fetch(req: Request) {
// req.body is a Web ReadableStream (or null)
return new Response(req.body) // streams through unchanged
}
}
Rules of thumb
- Use Web Streams as your common denominator.
- In Node, add
duplex: 'half'
only when the request body is a stream. - Convert between Node and Web streams only at the edges (the Node HTTP adapter). Your itty router can stay pure-Fetch/Web-Streams and run the same on Workers and Node.
Top comments (0)