Building a Production File Download API in Node.js: Streaming, Range Requests, and Bandwidth Control
Your API reads an entire file into memory on every download request, crashes the server when users download multiple large files simultaneously, and cannot resume interrupted transfers. Here is how to build a Node.js download endpoint that streams, supports Range headers for pause/resume, and throttles bandwidth per connection.
Your first file download endpoint probably looks like this:
import { readFile } from 'fs/promises';
app.get('/download/:id', async (req, res) => {
const file = await readFile(`/data/${req.params.id}`);
res.send(file);
});
This works fine for a 4 KB invoice PDF under zero load. It becomes a production incident at 200 concurrent requests for 50 MB CSV exports. The server’s memory graph forms a hockey stick, the event loop starves, and users staring at a spinning “Download” indicator bounce before the file ever reaches them.
The fix is not “add more RAM.” The fix is three interconnected patterns that every download endpoint needs: streaming with backpressure, HTTP Range request support, and per-connection bandwidth control.
This post builds all three into a single, production-ready Express route handler, explains why each piece matters, and includes a test script that proves the Range support actually resumes where it left off.
The three problems with the naive approach
The readFile version above has three distinct problems, and each one gets worse under load.
Memory. Every concurrent download holds the full file in V8 heap. A 500 MB file times three concurrent downloads is 1.5 GB of application memory. The OS page cache already has the bytes; you are duplicating them in user space for no reason.
Latency. The handler waits for the entire disk read to complete before writing a single byte to the socket. The user stares at a blank browser tab for 3 seconds while the file loads into RAM, then gets the entire payload at once.
No resume. If the network drops at 60%, the user starts over from byte zero. For large files this is not an inconvenience; it is a failed product experience that drives users to competitors.
Each of these maps to a specific fix.
Fix 1: Stream from disk to socket
Node.js fs.createReadStream creates a Readable stream that emits data chunks as the kernel reads them from disk, without loading the whole file into user-space memory. Pipe that directly into the HTTP response.
import { createReadStream, statSync } from 'fs';
import { join } from 'path';
import { pipeline } from 'stream/promises';
const DATA_DIR = '/data/exports';
app.get('/download/:id', async (req, res) => {
const filePath = join(DATA_DIR, req.params.id);
try {
const stat = statSync(filePath);
} catch {
res.status(404).json({ error: 'File not found' });
return;
}
const contentType = getContentType(filePath); // map extension to MIME type
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.params.id}"`);
await pipeline(createReadStream(filePath), res);
});
pipeline from stream/promises does two things that a bare .pipe() does not: it handles backpressure correctly (pausing the source when the consumer is slow), and it cleans up errors on either end. Without pipeline, a client disconnect leaves a dangling read stream that keeps pulling data from disk.
But this version still lacks Range support. If the client disconnects halfway through, the next request starts from byte 0.
Fix 2: HTTP Range requests for pause and resume
HTTP has a built-in mechanism for partial content: the Range request header and the 206 Partial Content response. The client sends:
GET /download/report.csv
Range: bytes=500000-
And the server responds with only the bytes starting at offset 500,000, plus a Content-Range header that tells the client where this chunk fits in the full file.
Browsers use this for video seeking. Download managers use it for parallel chunking. Your API should use it so interrupted downloads resume without wasting bandwidth.
Here is the pattern:
app.get('/download/:id', async (req, res) => {
const filePath = join(DATA_DIR, req.params.id);
let stat;
try {
stat = statSync(filePath);
} catch {
res.status(404).json({ error: 'File not found' });
return;
}
const fileSize = stat.size;
const rangeHeader = req.headers.range;
if (rangeHeader) {
const { start, end } = parseRange(rangeHeader, fileSize);
if (start === null) {
res.status(416).setHeader('Content-Range', `bytes */${fileSize}`).end();
return;
}
const chunkEnd = end ?? fileSize - 1;
const chunkLength = chunkEnd - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${chunkEnd}/${fileSize}`);
res.setHeader('Content-Length', chunkLength);
res.setHeader('Content-Type', getContentType(filePath));
res.setHeader('Content-Disposition', `attachment; filename="${req.params.id}"`);
await pipeline(createReadStream(filePath, { start, end: chunkEnd }), res);
} else {
// Full file, single request
res.setHeader('Content-Type', getContentType(filePath));
res.setHeader('Content-Length', fileSize);
res.setHeader('Content-Disposition', `attachment; filename="${req.params.id}"`);
res.setHeader('Accept-Ranges', 'bytes');
await pipeline(createReadStream(filePath), res);
}
});
The parseRange helper parses the bytes=start-end format and validates it against the actual file size:
function parseRange(
header: string,
fileSize: number
): { start: number | null; end: number | null } {
const match = header.match(/^bytes=(\d+)-(\d*)$/);
if (!match) return { start: null, end: null };
const start = parseInt(match[1], 10);
const end = match[2] !== '' ? parseInt(match[2], 10) : fileSize - 1;
if (start >= fileSize || start > end) return { start: null, end: null };
return { start, end };
}
The critical detail here is createReadStream(filePath, { start, end: chunkEnd }). Node’s fs.createReadStream accepts a start and end option that reads only the specified byte range from disk. This is a kernel-level seek, not a user-space skip. There is zero overhead for reading the bytes the client already has.
The Accept-Ranges signal
Notice the Accept-Ranges: bytes header on the full-file response. This tells the client that the server supports partial requests. Without it, most download clients and browsers will never send a Range header, because they assume the server does not understand it.
Fix 3: Bandwidth throttling per connection
Streaming large files introduces a new problem: a single fast download can saturate your server’s uplink. If you serve a 2 GB file to one user at line rate, every other user on the same server experiences packet loss, retransmission, and timeouts for their (much smaller) requests.
The fix is a throttled stream that paces bytes per connection. This is not about being mean to your users. It is about fairness: every connection gets a slice of the available bandwidth instead of a race where the first requester wins.
Node.js makes this straightforward with a custom Transform stream that delays chunks when they exceed a per-second budget:
import { Transform } from 'stream';
interface ThrottleOptions {
bytesPerSecond: number;
}
class Throttle extends Transform {
private bytesPerSecond: number;
private lastTime: number;
private bytesThisWindow: number;
private windowMs: number;
constructor(opts: ThrottleOptions) {
super();
this.bytesPerSecond = opts.bytesPerSecond;
this.windowMs = 200; // 5 windows per second for smoother pacing
this.bytesThisWindow = 0;
this.lastTime = Date.now();
}
_transform(
chunk: Buffer,
_encoding: string,
callback: (err?: Error | null, data?: Buffer) => void
): void {
const now = Date.now();
const elapsed = now - this.lastTime;
if (elapsed >= this.windowMs) {
// Reset the window
this.bytesThisWindow = 0;
this.lastTime = now;
}
const maxBytesPerWindow = (this.bytesPerSecond * this.windowMs) / 1000;
this.bytesThisWindow += chunk.length;
if (this.bytesThisWindow <= maxBytesPerWindow) {
// Within budget, pass through immediately
callback(null, chunk);
} else {
// Exceeded budget, delay by remaining time in the window
const delay = this.windowMs - elapsed;
setTimeout(() => callback(null, chunk), Math.max(0, delay));
}
}
}
The throttle operates on 200 ms windows instead of 1-second windows. This prevents the “burst every second” pattern where a client gets nothing for 900 ms and then receives a 500 KB avalanche. Smaller windows mean smoother delivery at the cost of slightly more timer overhead. 200 ms is the sweet spot on modern hardware.
Using it in the route handler is a simple pipe insertion:
const THROTTLE_BYTES_PER_SEC = 5 * 1024 * 1024; // 5 MB/s per connection
await pipeline(
createReadStream(filePath, { start, end: chunkEnd }),
new Throttle({ bytesPerSecond: THROTTLE_BYTES_PER_SEC }),
res
);
You can make this configurable per route, per user tier, or per file type. The pattern is the same: insert a throttling transform into the pipeline.
Putting it all together
Here is the complete handler with all three fixes:
import { Router } from 'express';
import { createReadStream, statSync } from 'fs';
import { join } from 'path';
import { pipeline } from 'stream/promises';
import { Transform } from 'stream';
const DATA_DIR = '/data/exports';
const DEFAULT_BANDWIDTH = 5 * 1024 * 1024; // 5 MB/s
class Throttle extends Transform {
private bytesPerSecond: number;
private lastTime: number;
private bytesThisWindow: number;
private windowMs: number;
constructor(opts: { bytesPerSecond: number }) {
super();
this.bytesPerSecond = opts.bytesPerSecond;
this.windowMs = 200;
this.bytesThisWindow = 0;
this.lastTime = Date.now();
}
_transform(
chunk: Buffer,
_encoding: string,
callback: (err?: Error | null, data?: Buffer) => void
): void {
const now = Date.now();
const elapsed = now - this.lastTime;
if (elapsed >= this.windowMs) {
this.bytesThisWindow = 0;
this.lastTime = now;
}
const maxPerWindow = (this.bytesPerSecond * this.windowMs) / 1000;
this.bytesThisWindow += chunk.length;
if (this.bytesThisWindow <= maxPerWindow) {
callback(null, chunk);
} else {
const delay = this.windowMs - elapsed;
setTimeout(() => callback(null, chunk), Math.max(0, delay));
}
}
}
function parseRange(
header: string,
fileSize: number
): { start: number | null; end: number | null } {
const match = header.match(/^bytes=(\d+)-(\d*)$/);
if (!match) return { start: null, end: null };
const start = parseInt(match[1], 10);
const end = match[2] !== '' ? parseInt(match[2], 10) : fileSize - 1;
if (start >= fileSize || start > end) return { start: null, end: null };
return { start, end };
}
const MIME_TYPES: Record<string, string> = {
'.csv': 'text/csv',
'.pdf': 'application/pdf',
'.json': 'application/json',
'.zip': 'application/zip',
'.gz': 'application/gzip',
};
function getContentType(path: string): string {
const ext = path.slice(path.lastIndexOf('.'));
return MIME_TYPES[ext] ?? 'application/octet-stream';
}
const router = Router();
router.get('/download/:id', async (req, res) => {
// Prevent path traversal
const filename = req.params.id.replace(/\.\.\//g, '').replace(/^\/+/, '');
const filePath = join(DATA_DIR, filename);
let stat;
try {
stat = statSync(filePath);
} catch {
res.status(404).json({ error: 'File not found' });
return;
}
// Reject directory traversal attempts that survived the sanitizer
if (!filePath.startsWith(DATA_DIR)) {
res.status(403).json({ error: 'Access denied' });
return;
}
const fileSize = stat.size;
if (fileSize === 0) {
res.status(204).end();
return;
}
const contentType = getContentType(filePath);
const rangeHeader = req.headers.range;
if (rangeHeader) {
const { start, end } = parseRange(rangeHeader, fileSize);
if (start === null) {
res.status(416)
.setHeader('Content-Range', `bytes */${fileSize}`)
.end();
return;
}
const chunkEnd = end ?? fileSize - 1;
const chunkLength = chunkEnd - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${chunkEnd}/${fileSize}`);
res.setHeader('Content-Length', chunkLength);
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=3600');
await pipeline(
createReadStream(filePath, { start, end: chunkEnd }),
new Throttle({ bytesPerSecond: DEFAULT_BANDWIDTH }),
res
);
} else {
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', fileSize);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Cache-Control', 'public, max-age=3600');
await pipeline(
createReadStream(filePath),
new Throttle({ bytesPerSecond: DEFAULT_BANDWIDTH }),
res
);
}
});
A few details in this version that matter in production but get cut from simplified examples.
Path traversal prevention. The replace calls strip ../ and leading slashes. The startsWith check after path resolution catches any bypass attempts. Without this, ../../etc/passwd becomes a valid download if the file exists.
Zero-byte files. A file with Content-Length: 0 and a stream pipe works technically, but the client will hang waiting for data. Respond with 204 No Content instead.
Content-Disposition with the actual filename. Without this header, the browser saves the file as download or attachment instead of the real filename.
Cache-Control for repeated downloads. If the same user requests the same export twice, the CDN or browser cache serves it without hitting your server. One hour is a reasonable default for generated exports.
Testing the Range support
The easiest way to verify Range request handling is a shell script using curl with a known file:
#!/usr/bin/env bash
# test-range.sh: Verify partial download and resume
# Create a test file of known content
for i in $(seq 0 1023); do
printf "\\x$(printf '%02x' $((i % 256)))" >> /tmp/testfile-pattern.bin
done
# Request bytes 500-999 (500 bytes)
echo "=== Testing Range: bytes=500-999 ==="
curl -s -o /tmp/range-test.bin \
-H "Range: bytes=500-999" \
http://localhost:3000/download/testfile-pattern.bin
# Verify size
SIZE=$(wc -c < /tmp/range-test.bin)
echo "Received $SIZE bytes (expected 500)"
# Verify content offset (byte 500 of original = 500 % 256 = 244 = 0xf4)
FIRST_BYTE=$(xxd -p -l 1 /tmp/range-test.bin)
echo "First byte (hex): $FIRST_BYTE (expected f4)"
# Test 416 on out-of-range
echo "=== Testing out-of-range ==="
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
-H " -H "Range: bytes=99999999-" \
http://localhost:3000/download/testfile-pattern.bin
# Test full file request has Accept-Ranges header
echo "=== Testing Accept-Ranges header ==="
curl -s -o /dev/null -D - \
http://localhost:3000/download/testfile-pattern.bin 2>&1 | grep -i accept-ranges
# Simulate a resume: get bytes 500-999, then get 1000-1023
echo "=== Testing resume (sequential ranges) ==="
curl -s -o /tmp/range-part1.bin \
-H "Range: bytes=500-999" \
http://localhost:3000/download/testfile-pattern.bin
curl -s -o /tmp/range-part2.bin \
-H "Range: bytes=1000-1023" \
http://localhost:3000/download/testfile-pattern.bin
# Reassemble and verify total size
TOTAL=$(( $(wc -c < /tmp/range-part1.bin) + $(wc -c < /tmp/range-part2.bin) ))
echo "Reassembled size: $TOTAL bytes (expected 524)"
echo "=== Done ==="
This script creates a file with a predictable byte pattern (each byte equals its offset modulo 256), requests specific ranges, and verifies both the size and content of the response. If the Range implementation is correct, byte 500 of the original file (which has value 500 % 256 = 244 = 0xf4) appears as the first byte of the range response.
Run it against a running instance of your API:
$ bash test-range.sh
=== Testing Range: bytes=500-999 ===
Received 500 bytes (expected 500)
First byte (hex): f4 (expected f4)
=== Testing out-of-range ===
HTTP 416
=== Testing Accept-Ranges header ===
accept-ranges: bytes
=== Testing resume (sequential ranges) ===
Reassembled size: 524 bytes (expected 524)
=== Done ===
Production considerations
A few things this post deliberately omitted because they depend on your specific deployment.
CDN offload. If your files are served through a CDN (CloudFront, Cloudflare, Fastly), set the CDN as the origin and let it handle Range requests, cache headers, and bandwidth. Your Node.js server only serves a cache miss. The streaming implementation above becomes a fallback for uncached files, which is fine because uncached files are rare in a well-tuned CDN setup.
Authentication and authorization. The example uses a bare /download/:id route. In production, validate that the requesting user has permission to download the file before the first byte leaves the disk. A simple middleware pattern: verify the token, look up the file owner, and reject with 403 if the user does not match.
On-the-fly generation. Some files do not exist on disk until the first download request (generated reports, compiled assets, image transformations). For these, create a PassThrough stream, start the generation process which pipes into it, and pipe the PassThrough to the response. The Range logic gets more complex because you do not know the file size until the generation completes. The pragmatic fix is to disable Range support for generated files and use a smaller bandwidth throttle to keep the response fair.
Monitoring. Add metrics for active download count, bytes served per second, and the distribution of Range vs full-file requests. A sudden shift toward full-file requests might indicate that client download managers have stopped using Range, which means either the Accept-Ranges header is missing or the client library does not support resume.
The takeaway
A file download endpoint is not a readFile call wrapped in an Express route. It is a carefully piped stream with backpressure, partial content support, and per-connection fairness. The three patterns in this post (streaming, Range handling, bandwidth throttling) turn a download API from a memory-bomb under load into a stable, resume-capable endpoint that treats all connections fairly.
The code is not long. The final handler is about 120 lines including the Throttle class and the Range parser. The hard part is knowing that these three patterns need to exist together, because each one solves a problem the other two do not. Streaming without Range means no resume. Range without streaming means loading the full file to skip to an offset. Streaming without throttling means one aggressive client starves the rest.
Ship the complete version. Your server, your users, and your on-call rotation will thank you.
A note from Yojji
The kind of backend engineering this post walks through is exactly the work that separates a demo API from a production service: handling backpressure correctly, supporting HTTP standards like Range requests that users expect but most endpoints ignore, and adding fairness controls that prevent one request from starving others. None of it is glamorous. All of it matters the first time a team ships a real download endpoint into production.
That is the same philosophy Yojji brings to every engagement: build it properly the first time, with the edge cases handled, so production testing does not find them for you. Yojji is an international custom software development company, founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architectures, and they run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.
If your team would rather hire the practice of building well-instrumented, production-grade Node.js services than learn the edge cases during a peak-hour outage, Yojji is worth a conversation.