How to Upload Files in Node.js Using Native Streams and the Fetch API
The most efficient methods to upload files in Node.js applications are native HTTP streaming using fs.createReadStream() piped to http.request(), and the built-in fetch API with FormData for multipart uploads, both of which avoid buffering entire files in memory.
Uploading files efficiently in Node.js requires streaming data directly from disk to the network to prevent memory exhaustion. The nodejs/node repository provides several built-in mechanisms for high-performance file uploads, ranging from low-level HTTP streams to the modern fetch API powered by Undici. This guide examines the most common and efficient methods to upload files in Node.js applications using only core modules and the built-in fetch implementation.
Native HTTP Streaming with http and https Modules
Architecture and Core Implementation
The native http and https modules provide the foundation for zero-copy file uploads in Node.js. The implementation resides in lib/http.js and lib/https.js, where the request() method returns a ClientRequest object that inherits from Writable stream (specifically OutgoingMessage).
This architecture allows you to pipe a file stream directly into the HTTP request without intermediate buffering. The kernel handles the data transfer from the file descriptor to the network socket, making this approach optimal for large files.
Streaming Upload Implementation
To implement a streaming upload, create a readable file stream using fs.createReadStream() and pipe it to the request object:
// upload-http.js
import { createReadStream } from 'fs';
import { request } from 'https'; // or 'http' for plain HTTP
const filePath = '/path/to/large-file.zip';
const options = {
hostname: 'example.com',
port: 443,
path: '/api/upload',
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
// Content-Length can be omitted; Node uses chunked transfer encoding
},
};
const req = request(options, (res) => {
console.log(`Server responded with ${res.statusCode}`);
res.on('data', (chunk) => process.stdout.write(chunk));
});
req.on('error', (err) => console.error('Upload error:', err));
// Stream the file directly into the request
createReadStream(filePath).pipe(req);
For production reliability, use stream.pipeline from lib/stream/promises.js to handle errors and ensure proper stream cleanup:
import { pipeline } from 'stream';
import { createReadStream } from 'fs';
import { request } from 'https';
const req = request({ /* options */ });
pipeline(
createReadStream(filePath),
req,
(err) => {
if (err) console.error('Pipeline failed:', err);
else console.log('Upload completed successfully');
}
);
Memory Efficiency and Back-Pressure
Native HTTP streaming provides zero-copy efficiency where data flows from the file system to the network without being buffered in the JavaScript heap. The pipe mechanism respects back-pressure through the highWaterMark property, automatically pausing the file read when the network buffer fills, preventing memory spikes during large uploads.
Multipart Uploads Using the Built-in fetch API and Undici
FormData and Automatic Boundary Handling
Since Node.js 18, the global fetch implementation is powered by Undici, located in deps/undici/undici.js. For multipart uploads, Undici provides a native FormData implementation that automatically handles boundary generation and MIME type negotiation.
The multipart parsing logic resides in deps/undici/src/lib/web/fetch/formdata-parser.js, which serializes the form data into a streaming body suitable for HTTP transmission. This eliminates the need to manually construct --boundary strings or calculate content lengths.
Streaming Multipart Implementation
To upload files using the modern fetch API, create a FormData instance and append file streams along with metadata:
// upload-fetch.js
import { createReadStream } from 'fs';
import { FormData, fileFromPath } from 'node:fetch'; // native fetch globals
const form = new FormData();
form.append('metadata', JSON.stringify({ userId: 123 }));
form.append('file', createReadStream('/path/to/photo.jpg'), {
filename: 'photo.jpg',
contentType: 'image/jpeg',
});
const response = await fetch('https://example.com/api/upload', {
method: 'POST',
body: form,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
const result = await response.json();
console.log('Server response:', result);
This approach maintains streaming efficiency because Undici’s FormData implementation accepts Readable streams and emits them as part of the multipart body without buffering the entire payload in memory.
Advantages Over Manual Multipart Construction
Using the built-in fetch API with FormData provides three critical advantages for uploading files in Node.js applications:
- Automatic boundary generation – The
Content-Type: multipart/form-data; boundary=…header is generated correctly without manual string concatenation. - Native streaming support – File streams are piped directly into the HTTP body, maintaining back-pressure throughout the upload.
- Async/await compatibility – The Promise-based API integrates cleanly with modern JavaScript patterns and error handling.
Third-Party Libraries for Advanced Scenarios
While native methods provide the highest performance for uploading files in Node.js, third-party libraries offer convenience features for specific requirements:
| Library | Best For | Underlying Mechanism |
|---|---|---|
form-data |
Explicit boundary control and legacy compatibility | Builds a CombinedStream that pipes to http.request |
axios |
Request interceptors, automatic JSON parsing, and progress events | Wraps native http/https with Promise-based ergonomics |
got |
Retry logic, timeout handling, and advanced stream composition | Uses native requests with pipeline-style streaming |
multer / busboy |
Server-side multipart parsing and file storage | Implements streaming parsers similar to Undici's formdata-parser |
Choose these libraries when you require upload progress bars, automatic retries on network failure, or server-side file handling beyond simple HTTP client functionality.
Summary
- Native HTTP streaming via
fs.createReadStream()piped tohttp.request()provides zero-copy efficiency and minimal memory footprint for large files. - Built-in
fetchAPI withFormData(Undici) automatically handles multipart boundaries while maintaining streaming performance for HTML-form-style uploads. stream.pipelinefromlib/stream/promises.jsensures proper error handling and resource cleanup when piping file streams to HTTP requests.- Third-party libraries like
axiosorgotadd convenience features but use the same core streaming mechanisms under the hood.
Frequently Asked Questions
What is the most memory-efficient way to upload files in Node.js?
The most memory-efficient approach uses fs.createReadStream() piped directly to http.request() or https.request(). This method, implemented in lib/http.js and lib/https.js, leverages zero-copy streaming where data flows from the file descriptor to the network socket without buffering in the JavaScript heap. For multipart uploads, the built-in fetch API with FormData (powered by Undici in deps/undici/undici.js) also streams data efficiently without loading entire files into memory.
Can I use the native fetch API to upload large files without buffering?
Yes, the native fetch API available since Node.js 18 supports streaming uploads through FormData and readable streams. When you append a fs.createReadStream() to a FormData instance, Undici's implementation (located in deps/undici/src/lib/web/fetch/formdata-parser.js) streams the file content directly into the HTTP body using chunked transfer encoding. This maintains back-pressure and prevents memory exhaustion even with multi-gigabyte files.
When should I use third-party libraries like axios or multer instead of native methods?
Use third-party libraries when you need features beyond basic streaming. Choose axios or got when you require upload progress events, automatic retries, request interceptors, or simplified error handling. Use multer or busboy specifically for server-side applications where you need to parse incoming multipart/form-data requests and save uploaded files to disk or memory. These libraries ultimately rely on the same native http and stream modules but add ergonomic abstractions for complex workflows.
How do I handle upload progress in native Node.js HTTP requests?
To track upload progress with native http.request(), attach a listener to the request's socket event and monitor the socket.bytesWritten property, or pipe the file stream through a custom transform stream that counts bytes. Alternatively, use the data event on the source read stream to calculate progress before it enters the request. For precise progress tracking with less boilerplate, third-party libraries like axios provide onUploadProgress callbacks that abstract these native socket events.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →