π― The Problem to Solve
App needs to read a large text file from the file system and send it to the client. Explore 3 solutions, from worst to best.
β Prerequsite
π₯ Grab the starter files for this section and open the project folder.
Download the Repo & Open starter Folder : 2-how-node-works
https://github.com/codevedadev/complete-node-bootcamp
If you already have it open, skip this step.
π Create a File streams.js
const fs = require("fs");
const server = require("http").createServer();
server.on("request", (req, res) => {
// Solution 1
fs.readFile("test-file.txt", (err, data) => {
if (err) console.log(err);
res.end(data);
});
});
server.listen(8000, "127.0.0.1", () => {
console.log("Listening...");
});
Start the server:
node streams.jsGo to
127.0.0.1:8000orlocalhost:8000and check the response

βοΈ
http.createServer(callback)β passing the request-handling callback directly, instead of using.on('request', ...)afterward. Same result, less code.
β Solution 1: Read the Whole File Into a Variable First
fs.readFile('test-file.txt', (err, data) => {
if (err) console.log(err);
res.end(data);
});π Loads the entire file into memory (
data), then sends it all at once.β Problem: nothing is sent until the whole file has been read.
π Fine for small files/local dev β π₯ crashes in production with big files or many concurrent requests, since memory fills up fast.
β‘ Solution 2: Use a Readable Stream + Manual Events
Instead of loading the whole file into a variable, read it piece by piece and immediately forward each piece to the response β which is itself a writable stream.
πΉ Step 1: Create a readable stream from the file
const readable = fs.createReadStream('test-file.txt');
π Creates a stream that lets you consume the file's data piece by piece.
πΉ Step 2: Listen for the data event
readable.on('data', (chunk) => {
res.write(chunk);
});
π¦ Every time a new chunk of data is available,
datafires.βοΈ
res.write(chunk)immediately writes that chunk to the response (writable stream) β sending it to the client right away instead of waiting for the whole file.
πΉ Step 3: Listen for the end event
readable.on('end', () => {
res.end();
});
β Result
endfires once the readable stream has finished reading the entire file.res.end()(called with no data, since all data was already sent viares.write()) signals that no more data will be written to the response β this is what actually finalizes/sends the response to the client.β οΈ Both
dataandendlisteners are required together β withoutres.end(), the response is never considered complete and will never actually be sent.
πΉ Step 4: Handle errors
readable.on('error', (err) => {
console.log(err);
res.statusCode = 500;
res.end('File not found');
});
β
errorevent fires if something goes wrong (e.g. file doesn't exist).π¨
res.statusCode = 500sets an error status (default is 200 = OK). Note: plain Node usesres.statusCode(notres.status(), which is an Express-specific method).π Sends a fallback message and logs the error.

β Result: This works and avoids loading the whole file into memory at once β data streams to the client piece by piece.
β οΈ Remaining problem β Backpressure
β‘ The readable stream (reading from disk) is much faster than the writable stream (sending data over the network to the client).
π§ This speed mismatch overwhelms the response stream, which can't keep up β this issue is called backpressure.
π‘ Needs an even better solution.
π Solution 3 (Best): Use .pipe()
const readable = fs.createReadStream('test-file.txt');
readable.pipe(res);
β Result
Hope this makes the speed-adjustment (backpressure) difference clear.
π
.pipe()is available on every readable stream.π General pattern:
readableSource.pipe(writableDestination).π Here: source = the file stream, destination =
res(the response).β‘
.pipe()automatically balances the speed difference between the incoming (readable) and outgoing (writable) sides β solving backpressure for you.π€ Under the hood it's doing roughly what Approach 2 did manually (listening for
data/end, writing/ending accordingly) β just handled internally, with far less code and none of the backpressure risk.
π‘ The destination doesn't have to be purely writable β a duplex or transform stream works too, as long as it can be written to.
π§ Learn More on this
It means .pipe()'s target argument just needs to be writable β it doesn't have to be a "pure" writable stream like res. A duplex or transform stream is also writable (that's part of what makes it duplex/transform), so it qualifies as a valid destination too.
Why this matters in practice
Duplex and transform streams are writable and readable at the same time. So when you pipe into one, you can then pipe out of it into something else β chaining multiple streams together.
Example: compressing a file before sending it to the client
const fs = require('fs');
const zlib = require('zlib');
http.createServer((req, res) => {
const readable = fs.createReadStream('test-file.txt');
const gzip = zlib.createGzip(); // π transform stream β writable AND readable
readable
.pipe(gzip) // π pipe into a transform stream (not res!)
.pipe(res); // π pipe gzip's output into res
res.setHeader('Content-Encoding', 'gzip');
});Walking through it:
readable.pipe(gzip)βgzipis the destination here, and it's not purely writable likeresis. It's a transform stream: it accepts written data (readable file chunks) and compresses it and makes the compressed result available to read..pipe(res)β becausegzipis also readable, you can immediately chain another.pipe()off it, sending the compressed output intores.
So the note is really saying: .pipe() isn't limited to "readable β writable" as a one-shot pairing. Because transform/duplex streams are writable on one side, you can pipe into them just like you'd pipe into res β and because they're also readable on the other side, you can keep chaining .pipe() calls to build a multi-stage pipeline (read β transform β transform β write), instead of being stuck with a single direct hop from source to final destination.
β Key Takeaways
β Solution 1 (
fs.readFileβres.end(data)): simplest, but loads entire file into memory β not production-safe for large files or high traffic.β‘ Solution 2 (manual
data/end/errorevents +res.write/res.end): streams data chunk by chunk, avoiding memory overload, but introduces backpressure risk since read speed β write speed.π Solution 3 (
readable.pipe(res)): the best, most elegant solution β pipes data from readable to writable stream while automatically handling backpressure. Preferred approach unless a custom/manual solution is specifically needed.
const fs = require("fs");
const server = require("http").createServer();
server.on("request", (req, res) => {
// Solution 1
// fs.readFile("test-file.txt", (err, data) => {
// if (err) console.log(err);
// res.end(data);
// });
// Solution 2: Streams
// const readable = fs.createReadStream("test-file.txt");
// readable.on("data", (chunk) => {
// res.write(chunk);
// });
// readable.on("end", () => {
// res.end();
// });
// readable.on("error", (err) => {
// console.log(err);
// res.statusCode = 500;
// res.end("File not found!");
// });
// Solution 3
const readable = fs.createReadStream("test-file.txt");
readable.pipe(res);
// readableSource.pipe(writeableDestination);
});
server.listen(8000, "127.0.0.1", () => {
console.log("Listening...");
});