"Track 5 introduced fetch. Production work starts where the short examples stop: streaming request bodies, chunk boundaries, Web Stream interop, and knowing when the lower-level undici package is worth adding."
Streaming Uploads
body: JSON.stringify(...) is fine for a small POST. A large video or a continuous log should move through memory in chunks instead of being buffered in full.
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
const src = createReadStream('./huge.bin');
const res = await fetch('https://uploader.example.com/upload', {
method: 'PUT',
body: Readable.toWeb(src),
duplex: 'half',
headers: { 'Content-Type': 'application/octet-stream' },
});
console.log('status:', res.status);
Node requires duplex: 'half' when a fetch request body is a stream. The option acknowledges the streaming request shape; omitting it makes Node throw before sending. It does not mean the whole file is buffered first—the ReadableStream still supplies chunks as the request is written.
SSE Is a Streaming HTTP Response
Server-Sent Events uses an HTTP response with Content-Type: text/event-stream. fetch exposes the response body as a Web Stream, but network chunks do not promise to end on line boundaries. Keep the unfinished tail between reads.
const res = await fetch('https://api.example.com/stream', {
headers: { Accept: 'text/event-stream' },
});
if (!res.ok || !res.body) throw new Error(`SSE failed: ${res.status}`);
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader();
let pending = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
pending += value;
const lines = pending.split('\n');
pending = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
handleEvent(JSON.parse(line.slice(6)));
}
}
}
This minimal pattern is enough for a stream that sends one JSON value per data: line, and it correctly carries a line split across chunks. Full SSE also supports multi-line data, event names, IDs, retries, comments, and CRLF framing; use a complete parser when the server relies on those parts of the protocol.
Use undici Directly When You Need Its Controls
Pool from undici means adding the package as a direct dependency. That trade can be worthwhile when a client needs explicit connection pools, pipelining, timeouts, or dispatcher control.import { Pool } from 'undici';
const pool = new Pool('https://api.example.com', {
connections: 100,
pipelining: 10,
bodyTimeout: 30_000,
});
const { body, statusCode } = await pool.request({
method: 'GET',
path: '/items',
});
for await (const chunk of body) {
// chunk is a Buffer
}Start with built-in fetch. Add undici only after the application needs a control that the fetch surface does not expose, then benchmark the real workload instead of assuming the lower-level API is automatically faster.Response Can Wrap a Stream
Response is not only a value returned by fetch. You can construct one around a transformed stream, which is useful in proxy, cache, and server-handler code.
const upstream = await fetch('https://api.example.com/big.json');
const upper = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk);
},
});
const piped = upstream.body.pipeThrough(upper);
const response = new Response(piped, {
status: upstream.status,
headers: upstream.headers,
});
// `response` can be returned by a compatible server handler,
// or consumed with .text(), .json(), and the other Body methods.