Downloading a file in Node.js looks simple until a connection stalls, a server returns an error, or a process stops halfway through writing. A robust downloader needs to stream data, enforce limits, handle failures, and remove incomplete files. If the URL comes from a user, it also needs protections against server-side request forgery (SSRF).
This tutorial builds a downloader for a trusted, fixed HTTPS source using Node.js built-in APIs. It covers HTTP errors, timeouts, file-size limits, redirects, partial-file cleanup, and what changes when URLs are user supplied.
Key takeaways
- Stream the response to disk instead of buffering the entire file in memory.
- Set a timeout and a maximum byte count; do not rely only on
Content-Length. - Write to a temporary file and rename it only after the download completes.
- For a fixed source, disable redirects unless you explicitly need and validate them.
- A user-supplied URL requires layered SSRF defenses, not just URL parsing or a hostname check.
Use a fixed, trusted source first
When the source is controlled by your application, keep its URL in server-side configuration or code rather than accepting it from a request. The example below uses a reserved example domain; replace it with the exact HTTPS endpoint you trust.
The implementation requires Node.js 20 or later and uses the built-in fetch, Readable.fromWeb, pipeline, and filesystem APIs. It caps the download at 50 MiB, applies a 30-second overall timeout, rejects redirects, and writes to a unique temporary file in the destination directory. Keeping the temporary file on the same filesystem as the destination allows the final rename to act as a completion step.
import { randomUUID } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
const SOURCE_URL = 'https://downloads.example.com/releases/archive.zip';
const MAX_BYTES = 50 * 1024 * 1024;
const TIMEOUT_MS = 30_000;
export async function downloadTrustedFile(destinationPath) {
// destinationPath should be chosen by the application, not taken directly
// from an untrusted request parameter.
const temporaryPath = join(
dirname(destinationPath),
`.download-${randomUUID()}.part`,
);
try {
const response = await fetch(SOURCE_URL, {
redirect: 'error',
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Download failed with HTTP status ${response.status}`);
}
if (!response.body) {
throw new Error('The response did not contain a file body');
}
const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
if (!/^\d+$/.test(contentLength)) {
await response.body.cancel();
throw new Error('The server returned an invalid Content-Length');
}
if (Number(contentLength) > MAX_BYTES) {
await response.body.cancel();
throw new Error('The file exceeds the configured size limit');
}
}
let bytesWritten = 0;
const sizeLimiter = new Transform({
transform(chunk, encoding, callback) {
bytesWritten += chunk.length;
if (bytesWritten > MAX_BYTES) {
callback(new Error('The file exceeds the configured size limit'));
return;
}
callback(null, chunk);
},
});
await pipeline(
Readable.fromWeb(response.body),
sizeLimiter,
createWriteStream(temporaryPath, { flags: 'wx' }),
);
await rename(temporaryPath, destinationPath);
return { destinationPath, bytesWritten };
} finally {
// This is harmless after a successful rename and removes incomplete files
// after HTTP, stream, timeout, or filesystem errors.
await rm(temporaryPath, { force: true });
}
}
The configured timeout is an overall deadline, not a guarantee that a connection will succeed within a particular time. Adjust it for the expected file size and network conditions. If your application needs separate connection, idle, and total-transfer deadlines, implement and test those policies explicitly.
The example uses flags: 'wx' so the randomly named temporary file is created exclusively rather than accidentally opened if a path already exists. The final rename may replace an existing destination on some platforms. If overwriting is not acceptable, define an explicit no-overwrite policy and implement it with filesystem operations appropriate to your platform and concurrency requirements.
How the download flow works
Check the HTTP response before writing
fetch resolves to a response even for HTTP statuses such as 404 or 500. Checking response.ok prevents an error page from being saved as if it were the requested file. The redirect policy is set to error so a redirect cannot silently change the destination.
Stream data and enforce a byte limit
Readable.fromWeb(response.body) adapts the Fetch API's web stream to a Node.js readable stream. pipeline connects it to the size-checking transform and file writer, propagating errors and managing stream cleanup.
Content-Length is useful as an early rejection check, but it may be absent or unreliable. The transform counts the bytes actually received and rejects the stream if it exceeds the cap. Pick a limit that fits your use case, and account for disk space and concurrent downloads as well as memory.
Publish the file only after success
The downloader writes to a .part file first. If the transfer fails, the finally block removes that partial file. Only a completed stream is renamed to the requested destination, so downstream code is less likely to mistake an incomplete file for a finished one.
If the URL comes from a user, defend against SSRF
A user-controlled URL changes the problem: your server may be induced to make requests to internal services or other destinations it should not access. Parsing a URL, checking that it starts with https://, or comparing a hostname string is not a complete SSRF defense.
Prefer an allowlist of exact hosts and, where possible, fixed paths. If arbitrary external URLs are a genuine product requirement, use multiple controls:
- Restrict URL structure. Parse with Node.js's
URLAPI. Allow only required schemes, usually HTTPS; reject credentials in the URL, unexpected ports, and malformed or unsupported forms. Do not treat parsing as authorization. - Validate the destination. Compare the normalized hostname against an explicit allowlist where possible. If arbitrary hosts are allowed, resolve and reject loopback, private, link-local, multicast, and other prohibited address ranges for both IPv4 and IPv6. Consider IPv4-mapped IPv6 addresses and DNS rebinding.
- Control the actual connection. A separate DNS check followed by a normal fetch can be vulnerable to a changed DNS answer between validation and connection. Use a client or network architecture that ensures the connected address is among the validated addresses, or enforce outbound restrictions at the network layer. Do not rely on a hostname check alone.
- Restrict redirects. The example rejects them. If redirects are required, validate every redirect target using the same rules and limit the number of hops. A permitted initial host can redirect to a prohibited destination.
- Keep resource limits. Set an overall timeout, a byte limit enforced while streaming, and appropriate concurrency limits. Do not trust a remote
Content-Lengthheader as the sole limit. - Keep file paths under application control. Do not use a remote URL's path or filename directly as a filesystem destination. Generate a safe local name and store files in a directory with appropriate permissions.
These measures are layered. URL validation does not replace network egress controls, and network controls do not replace application-level limits and validation.
Common mistakes and troubleshooting
- Using
response.arrayBuffer()for large downloads: this buffers the entire body in memory. Stream the response instead. - Treating every resolved
fetchas success: HTTP error statuses do not necessarily reject the fetch promise. Checkresponse.ok. - Trusting the declared size: headers can be missing or incorrect. Count bytes as they arrive.
- Following redirects automatically: a redirect may lead somewhere the application did not intend. Reject redirects or validate each hop.
- Writing directly to the final path: a failed transfer can leave a partial file that looks complete. Use a temporary file and cleanup.
- Accepting arbitrary destination paths: this can create path traversal or overwrite risks. Choose destinations on the server and apply a clear overwrite policy.
- Treating a timeout as a size limit: a fast, oversized response can still exceed disk limits. Use both time and byte limits.
If downloads fail unexpectedly, check whether the source requires authentication, whether the configured deadline is realistic, whether the destination directory exists and is writable, and whether the server sends a valid response body. Log the failure category and status without logging sensitive URL credentials or other secrets.
Conclusion
A safe Node.js downloader streams data, handles unsuccessful HTTP responses, applies time and size limits, rejects unvalidated redirects, and removes partial files. Keeping the source fixed and trusted makes the design simpler. If users can choose URLs, treat the feature as a network security boundary and add destination validation, redirect controls, and outbound network restrictions.
