When you're fetching large data—whether it's a multi-megabyte file, an AI model response, or real-time logs—waiting for the entire response before showing anything to your users feels prehistoric. That's where streaming comes in. Instead of holding everything in memory until it arrives, you can stream large responses by reading chunks as they come through, decoding them on the fly, and displaying progress to your users immediately. Today we're going deep into how to handle response bodies as streams, read them chunk by chunk, and decode text efficiently so your applications feel snappy even when dealing with gigabytes of data.
response.body is a ReadableStream
Most developers reach for .json() or .text() when they fetch data, and for small payloads that's fine. But the Fetch API gives you something more powerful: response.body is a ReadableStream that lets you consume data as it arrives rather than waiting for the entire response buffer.
A ReadableStream is a fundamental Web API primitive that represents a source of data that flows over time. When you make a fetch request and the server starts sending bytes, those bytes flow into the stream. If you don't explicitly read from the stream, the browser buffers everything into memory—which becomes a problem at scale. By reading the stream yourself, you regain control and can process chunks incrementally.
Here's what the raw response body looks like:
const response = await fetch('/api/large-file');
const stream = response.body; // ReadableStream<Uint8Array>
if (!stream) {
throw new Error('Response body is empty');
}
console.log(stream); // ReadableStream { locked: false }
Notice the locked property. A stream can only have one active reader at a time, which prevents concurrent consumption. Once you call .getReader(), the stream becomes locked until that reader is released or closed.
I've used this pattern in production when building a file upload progress tracker for an e-commerce platform. Instead of showing a generic loader, we streamed the response chunk by chunk and calculated real-time progress—much better UX than the old "please wait" spinner.
Read chunks with a reader
To actually consume the stream, you need to get a reader and then call .read() repeatedly. Each call to .read() returns a promise that resolves to an object with two properties: done (boolean indicating end of stream) and value (a Uint8Array chunk).
Here's the core pattern:
async function readStream(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader();
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log(`Stream complete. Total bytes: ${totalBytes}`);
break;
}
totalBytes += value.length;
console.log(`Received chunk: ${value.length} bytes`);
// Process the chunk here
}
} finally {
reader.releaseLock();
}
}
const response = await fetch('/api/large-file');
await readStream(response.body!);
The finally block is critical—you must call releaseLock() to free the stream so it can be garbage collected or read by another consumer. Forgetting this leads to memory leaks and mysterious hangs in production.
In a real scenario like streaming a CRM export, you might accumulate chunks into a buffer until you have a complete line, then process that line:
async function streamLargeCSVExport(
response: Response,
onRow: (row: string) => void
) {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Process all complete lines
for (let i = 0; i < lines.length - 1; i++) {
onRow(lines[i]);
}
// Keep the incomplete line in the buffer
buffer = lines[lines.length - 1];
}
// Flush any remaining data
if (buffer) {
onRow(buffer);
}
} finally {
reader.releaseLock();
}
}
This approach works beautifully when you're streaming JSON Lines format (one JSON object per line) from a trading platform API—you get updates in near real-time and can render them to your dashboard immediately.
Decode text with TextDecoderStream
Those chunks coming off the wire are raw bytes (Uint8Array). To convert them into human-readable text, you need a decoder. The Fetch API gives you TextDecoderStream, which is specifically designed for this use case and handles UTF-8 multibyte sequences gracefully.
Why not just use new TextDecoder().decode() manually? Because when a multibyte UTF-8 character spans two chunks, the naive approach breaks. The stream: true option tells TextDecoder to buffer incomplete sequences, but TextDecoderStream handles this automatically and elegantly.
Here's how to wire it up using the Streams API pipeTo (modern, readable) or manually with a transform stream:
// Option 1: Using pipeThrough with TextDecoderStream
async function streamTextResponse(url: string, onChunk: (text: string) => void) {
const response = await fetch(url);
const textStream = response.body!
.pipeThrough(new TextDecoderStream());
const reader = textStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
onChunk(value); // value is a string now
}
} finally {
reader.releaseLock();
}
}
// Option 2: Manual chunking (for when you need more control)
async function streamWithLineBuffering(
url: string,
onLine: (line: string) => void
) {
const response = await fetch(url);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (buffer) onLine(buffer);
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
onLine(lines[i]);
}
buffer = lines[lines.length - 1];
}
} finally {
reader.releaseLock();
}
}
I use option 2 frequently on the ride-hailing platform where we stream GPS coordinates and need to parse JSON objects that might span chunks. The buffer accumulation pattern is essential when dealing with structured data.
Great for streaming AI responses
This is where streaming really shines in modern applications. When you call an AI API like OpenAI's GPT or Anthropic's Claude with streaming enabled, you get tokens (small pieces of text) arriving continuously. Without streaming, you'd wait 5-10 seconds for the entire response before showing anything to the user. With streaming, you show the first token in under a second.
Here's a practical example for a SaaS chatbot feature:
async function streamAIResponse(
userMessage: string,
onToken: (token: string) => void,
onComplete: () => void
) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: userMessage }],
stream: true
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
onComplete();
return;
}
try {
const json = JSON.parse(data);
const token = json.choices[0].delta.content;
if (token) {
onToken(token);
}
} catch (e) {
// Skip malformed lines
}
}
}
buffer = lines[lines.length - 1];
}
} finally {
reader.releaseLock();
}
}
// Usage in React
function ChatBubble() {
const [response, setResponse] = useState('');
const handleSend = async (message: string) => {
setResponse('');
await streamAIResponse(
message,
(token) => setResponse(prev => prev + token),
() => console.log('AI response complete')
);
};
return (
<>
<button onClick={() => handleSend('Tell me a joke')}>Send</button>
<div>{response}</div>
</>
);
}
The user sees text appearing word by word, which feels natural and responsive. This is standard practice now in every modern AI application—it's a user experience expectation, not a nice-to-have.
Common mistakes
I've seen these patterns cause real problems in production:
- Forgetting to release the reader lock: This causes the stream to stay locked indefinitely. If you make multiple requests using the same response object, you'll get "TypeError: Cannot lock a stream that is already locked." Always use try/finally or await/catch to guarantee releaseLock() runs.
- Not handling the stream: true option in TextDecoder: When you manually call decoder.decode() on each chunk, you must pass
{ stream: true }except on the final chunk. Without it, UTF-8 sequences that span boundaries will produce replacement characters. - Assuming response.body is always present: On 204 No Content responses or certain redirects, body might be null. Always check or use optional chaining.
- Blocking the main thread with large synchronous processing: If your onChunk callback does heavy computation, the stream consumption pauses. Use Web Workers or break work into microtasks with setImmediate for CPU-bound operations.
- Not handling network errors mid-stream: If the connection drops after you've already started reading, you might miss detecting the failure. Always wrap the read loop in proper error handling and set reasonable timeouts.
Key takeaways
- response.body is a ReadableStream that lets you consume data incrementally instead of buffering everything in memory first.
- Call getReader() to get exclusive access to the stream, then loop calling read() until done is true—always releaseLock() in a finally block.
- Use TextDecoder with { stream: true } or TextDecoderStream to convert byte chunks into strings while properly handling multibyte UTF-8 sequences.
- Streaming is essential for large files, real-time data, and AI responses—it dramatically improves perceived performance and user experience.
- Handle null response bodies, release locks, and add proper error handling in production code to avoid subtle bugs that only appear under load or poor network conditions.






Comments
Loading comments…