Photo by Compare Fibre on Unsplash
To upload a stream to S3 with Node.js SDK v3, pass the readable stream as Body to new Upload({ client, params }) from @aws-sdk/lib-storage and await upload.done(). It splits the stream into parts of partSize bytes, sends queueSize parts at once, and works without knowing the total length. Add a lifecycle rule so abandoned multipart uploads get cleaned up.
This example is for Node.js services that move data that doesn’t fit comfortably in memory: database dumps, log archives, video, or data piped from another process or an HTTP download. If you only need to put a known, small file into a bucket, our example that shows how to upload a file to S3 with S3Client in TypeScript covers PutObjectCommand and when to switch. Data already in S3 doesn’t need to pass through your process: the guide to copy and move S3 objects server-side with AWS SDK v3 handles large objects with multipart copy.
Here you’ll get one script that can upload a stream to S3 with Node.js SDK v3 from a file, stdin or a URL, with optional on-the-fly gzip, progress, and a clean abort on Ctrl+C. You also get a second script that finds leftover multipart uploads, the lifecycle rule that prevents them, and the part size math that decides how big an object you can upload. It’s part of our set of AWS SDK v3 practical examples for TypeScript. If you’re replacing v2’s s3.upload() across a whole codebase, start with the plan to migrate a Node.js app from AWS SDK v2 to v3.
What the scripts do
upload-stream.tsreads from a file path,-(stdin) or anhttps://URL, optionally gzips the data, and streams it to S3 withUpload. Memory stays nearpartSize × queueSizeno matter how large the source is.list-incomplete-uploads.tslists multipart uploads that were started but never completed or aborted, and can abort the ones older than a number of days.
How lib-storage Upload splits a stream
These options decide memory, speed and the maximum object size. Defaults come from the lib-storage package in the AWS SDK for JavaScript v3 repository:
| Option | Default | What it controls |
|---|---|---|
partSize |
5 MiB (the S3 minimum) | Bytes per part. With a known length and no partSize, the SDK raises it so the upload fits in 10,000 parts. |
queueSize |
4 | Parts uploaded concurrently. The SDK buffers at most queueSize × partSize bytes. |
leavePartsOnError |
false |
When false, a failed upload calls AbortMultipartUpload so parts don’t linger. |
abortController |
internal | Pass your own to cancel from outside, for example on Ctrl+C or a timeout. |
tags |
none | Applied with a separate PutObjectTagging call after the upload completes. |
If the whole body fits in the first part, Upload sends a single PutObject instead of a multipart upload, so small streams don’t pay for three requests.
Part size math: the biggest object you can upload
S3 allows at most 10,000 parts, each between 5 MiB and 5 GiB (the last part can be smaller), and a maximum object size of 48.8 TiB, according to the S3 multipart upload limits. So the largest object is partSize × 10,000:
- 5 MiB parts: 50,000 MiB, about 48.8 GiB. This is the ceiling for a stream of unknown length if you keep the default.
- 16 MiB parts (the script’s default): 160,000 MiB, about 156 GiB.
- 64 MiB parts: 640,000 MiB, exactly 625 GiB.
Memory is the other side: 64 MiB parts with queueSize 4 buffer up to 256 MiB. Beyond 10,000 parts the SDK throws Exceeded 10000 parts in multipart upload, so size partSize for the largest input you expect.
Prerequisites
- Node.js 20 or later (current SDK v3 releases require it), npm and
tsx. @aws-sdk/client-s3and@aws-sdk/lib-storage.- An existing bucket and an AWS profile allowed to write to it.
IAM permissions (JSON policy)
s3:PutObject authorizes creating the multipart upload, uploading parts and completing it. s3:AbortMultipartUpload lets a failed or cancelled upload clean up. Listing incomplete uploads needs s3:ListBucketMultipartUploads on the bucket itself, and the lifecycle step needs the two lifecycle actions. Replace my-bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "UploadAndAbort",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:AbortMultipartUpload"
],
"Resource": "arn:aws:s3:::my-bucket/*"
},
{
"Sid": "FindIncompleteUploadsAndSetLifecycle",
"Effect": "Allow",
"Action": [
"s3:ListBucketMultipartUploads",
"s3:GetLifecycleConfiguration",
"s3:PutLifecycleConfiguration"
],
"Resource": "arn:aws:s3:::my-bucket"
}
]
}
Using tags also requires s3:PutObjectTagging, and a bucket encrypted with a customer managed KMS key needs kms:GenerateDataKey and kms:Decrypt on that key. To derive a policy from your own variant, paste it into the free IAM policy generator for TypeScript code, or find the IAM actions your SDK for JavaScript code needs by hand.
The full script: upload a stream to S3 with Node.js SDK v3
// upload-stream.ts
// Streams a file, stdin or an HTTP(S) download to S3 as a multipart upload,
// optionally gzip-compressing on the fly. Memory use stays near partSize x queueSize.
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from "node:fs";
import { PassThrough, Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import { createGzip } from "node:zlib";
const MiB = 1024 * 1024;
const MAX_PARTS = 10_000;
interface Options {
source: string;
bucket: string;
key: string;
gzip: boolean;
partSizeMiB: number;
queueSize: number;
}
function parseArgs(argv: string[]): Options {
const flags = new Map<string, string>();
const positional: string[] = [];
for (const arg of argv) {
if (arg.startsWith("--")) {
const [name, value = "true"] = arg.slice(2).split("=");
flags.set(name, value);
} else {
positional.push(arg);
}
}
const [source, bucket, key] = positional;
if (!source || !bucket || !key) {
console.error(
"Usage: npx tsx upload-stream.ts <file | - | https://url> <bucket> <key> [--gzip] [--part-size=MiB] [--queue-size=N]",
);
process.exit(1);
}
return {
source,
bucket,
key,
gzip: flags.get("gzip") === "true",
partSizeMiB: Number(flags.get("part-size") ?? "16"),
queueSize: Number(flags.get("queue-size") ?? "4"),
};
}
async function openSource(source: string): Promise<Readable> {
if (source === "-") {
return process.stdin;
}
if (source.startsWith("https://") || source.startsWith("http://")) {
const res = await fetch(source);
if (!res.ok || !res.body) {
throw new Error(`GET ${source} failed: ${res.status} ${res.statusText}`);
}
return Readable.fromWeb(res.body as WebReadableStream<Uint8Array>);
}
return createReadStream(source);
}
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
const partSize = opts.partSizeMiB * MiB;
const s3 = new S3Client({});
const source = await openSource(opts.source);
let body: Readable = source;
let compressing: Promise<void> | undefined;
if (opts.gzip) {
const compressed = new PassThrough();
compressing = pipeline(source, createGzip(), compressed);
body = compressed;
}
const controller = new AbortController();
const upload = new Upload({
client: s3,
params: {
Bucket: opts.bucket,
Key: opts.key,
Body: body,
ContentType: opts.gzip ? "application/gzip" : "application/octet-stream",
},
partSize,
queueSize: opts.queueSize,
leavePartsOnError: false,
abortController: controller,
});
process.once("SIGINT", () => {
process.stderr.write("\nCtrl+C received, aborting the multipart upload...\n");
controller.abort();
source.destroy();
});
const started = Date.now();
upload.on("httpUploadProgress", ({ loaded = 0, part }) => {
const seconds = Math.max((Date.now() - started) / 1000, 0.001);
process.stderr.write(
`\r part ${part ?? "-"} ${(loaded / MiB).toFixed(1)} MiB sent ${(loaded / MiB / seconds).toFixed(1)} MiB/s `,
);
});
console.log(
`Streaming ${opts.source} to s3://${opts.bucket}/${opts.key} ` +
`(part size ${opts.partSizeMiB} MiB, ${opts.queueSize} parts in flight, ` +
`max object ${((partSize * MAX_PARTS) / 1024 ** 3).toFixed(0)} GiB, ` +
`buffer up to ${opts.partSizeMiB * opts.queueSize} MiB)`,
);
try {
const [result] = await Promise.all([upload.done(), compressing]);
process.stderr.write("\n");
console.log(`Done in ${((Date.now() - started) / 1000).toFixed(1)} s. ETag: ${result.ETag}`);
if (upload.uploadId) {
console.log(`Multipart upload ID: ${upload.uploadId}`);
}
} catch (err) {
process.stderr.write("\n");
if (err instanceof Error && err.name === "AbortError") {
console.error("Upload aborted. In-flight parts finish, then the SDK calls AbortMultipartUpload.");
process.exitCode = 130;
return;
}
throw err;
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Why the gzip path uses pipeline and a PassThrough
source.pipe(createGzip()) would work until something fails, because .pipe() doesn’t forward errors. pipeline() from node:stream/promises destroys every stream in the chain when one fails, so a read error ends the S3 upload too, and the SDK aborts the multipart upload. Awaiting upload.done() and the pipeline together with Promise.all surfaces whichever error comes first. The Node.js stream.pipeline documentation describes the cleanup rules.
Known length versus unknown length
For a file read stream, the SDK reads the file size, so progress events include a total and the SDK checks the part count at the end. Stdin, gzip output and most HTTP bodies have no known length: total stays undefined and the upload finishes when the stream ends. That’s the case where partSize caps the object size, as in the math above.
Aborting cleanly
controller.abort() makes upload.done() reject with an AbortError right away. Parts already in flight finish, and then the SDK sends AbortMultipartUpload, provided leavePartsOnError is false. That’s why the handler sets process.exitCode instead of calling process.exit(), which would kill the cleanup request.
Find and prevent incomplete multipart uploads
A crash, a killed container or leavePartsOnError: true can leave parts behind. They don’t show up as objects in the console listing, but S3 stores and bills them until the upload is aborted. This script lists them and optionally aborts old ones:
// list-incomplete-uploads.ts
// Lists multipart uploads that were started but never completed or aborted,
// and optionally aborts the ones older than N days.
import {
AbortMultipartUploadCommand,
ListMultipartUploadsCommand,
S3Client,
type MultipartUpload,
} from "@aws-sdk/client-s3";
const s3 = new S3Client({});
async function listIncomplete(bucket: string): Promise<MultipartUpload[]> {
const uploads: MultipartUpload[] = [];
let keyMarker: string | undefined;
let uploadIdMarker: string | undefined;
do {
const page = await s3.send(
new ListMultipartUploadsCommand({ Bucket: bucket, KeyMarker: keyMarker, UploadIdMarker: uploadIdMarker }),
);
uploads.push(...(page.Uploads ?? []));
keyMarker = page.IsTruncated ? page.NextKeyMarker : undefined;
uploadIdMarker = page.IsTruncated ? page.NextUploadIdMarker : undefined;
} while (keyMarker);
return uploads;
}
async function main(): Promise<void> {
const [bucket, olderThanDays] = process.argv.slice(2);
if (!bucket) {
console.error("Usage: npx tsx list-incomplete-uploads.ts <bucket> [abort-if-older-than-days]");
process.exit(1);
}
const cutoff = olderThanDays ? Date.now() - Number(olderThanDays) * 86_400_000 : undefined;
const uploads = await listIncomplete(bucket);
console.log(`${uploads.length} incomplete multipart upload(s) in ${bucket}`);
for (const u of uploads) {
const started = u.Initiated ?? new Date(0);
console.log(` ${started.toISOString()} ${u.Key} ${u.UploadId}`);
if (cutoff !== undefined && started.getTime() < cutoff) {
await s3.send(new AbortMultipartUploadCommand({ Bucket: bucket, Key: u.Key, UploadId: u.UploadId }));
console.log(" aborted");
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
The permanent fix is a lifecycle rule that aborts incomplete uploads automatically. put-bucket-lifecycle-configuration replaces the bucket’s whole lifecycle configuration, so read the existing one first and merge this rule into it (the script to find S3 buckets without lifecycle rules does that merge for every bucket that lacks the rule):
{
"Rules": [
{
"ID": "abort-incomplete-multipart-uploads",
"Filter": { "Prefix": "" },
"Status": "Enabled",
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}
aws s3api get-bucket-lifecycle-configuration --bucket my-bucket
aws s3api put-bucket-lifecycle-configuration --bucket my-bucket --lifecycle-configuration file://lifecycle.json
How do you run it?
npm install @aws-sdk/client-s3 @aws-sdk/lib-storage
npm install --save-dev tsx typescript @types/node
# A large local file with 64 MiB parts
AWS_PROFILE=deploy AWS_REGION=us-east-1 npx tsx upload-stream.ts ./exports/events.parquet my-bucket exports/events.parquet --part-size=64
# A database dump piped through stdin and gzipped on the way
pg_dump orders | AWS_PROFILE=deploy npx tsx upload-stream.ts - my-bucket backups/orders.sql.gz --gzip
# Leftover uploads older than 7 days: list, then abort
npx tsx list-incomplete-uploads.ts my-bucket
npx tsx list-incomplete-uploads.ts my-bucket 7
Sample output
Streaming - to s3://my-bucket/backups/orders.sql.gz (part size 16 MiB, 4 parts in flight, max object 156 GiB, buffer up to 64 MiB)
part 38 602.4 MiB sent 41.7 MiB/s
Done in 14.6 s. ETag: "5c1e0f2a9d7b4e38a6f0b1c2d3e4f5a6-38"
Multipart upload ID: 2~kXv3dQ9sTzP0aYbR8mNc1LhGwUe7FjSo
$ npx tsx list-incomplete-uploads.ts my-bucket 7
2 incomplete multipart upload(s) in my-bucket
2026-09-02T08:14:55.000Z backups/orders.sql.gz 2~Qm4Tx8yVbN1cRz6WpLk0HsJdFa9GeUo3
aborted
2026-09-26T21:03:10.000Z exports/events.parquet 2~Zr7Bn2kLmP5qXc8VtHy4JwEsDa1GfUi6
The -38 suffix on the ETag is the part count. Multipart ETags aren’t an MD5 of the object, so don’t compare them with a local checksum.
Troubleshooting
EntityTooSmallwhen you setpartSize. The value is below 5 MiB. The SDK rejects it before sending anything.Exceeded 10000 parts. The stream is bigger thanpartSize × 10,000. Raise--part-size.Expected N part(s) but uploaded M part(s). The SDK computed a length from the body that didn’t match what the stream delivered. Pass the correctContentLength, or none for streams.- The process runs out of memory.
partSize × queueSizeis too large for the machine. Lower--queue-sizefirst; it costs less throughput than smaller parts. AccessDeniedonly on large uploads. Small bodies go throughPutObject; large ones also callAbortMultipartUploadon failure, and KMS-encrypted buckets needkms:Decryptfor multipart. Check the policy above, or follow our steps to troubleshoot AWS IAM access denied errors.- Bucket storage is higher than the objects add up to. Incomplete uploads. Run the second script and add the lifecycle rule.
Every part is a PUT request, so a 600 MiB upload in 16 MiB parts sends 38 UploadPart calls plus create and complete. The guide to calculate S3 GET and PUT request costs shows why that’s negligible for large parts, and the free AWS S3 pricing calculator estimates the storage side. If the uploads are backups, compare the S3 storage class cost for backups before they pile up in S3 Standard. For clients that should upload directly without your server in the path, use a presigned S3 upload URL created with SDK v3 instead. Coming from v2’s s3.upload()? The AWS SDK JS v2 to v3 converter rewrites it to Upload.
Ask ChatWithCloud instead
You don’t need a script to check the bucket side. Ask ChatWithCloud “Which buckets have incomplete multipart uploads older than a week?” or “Does my-bucket have a lifecycle rule that aborts incomplete uploads?” and it writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile, and explains the result. The guide to ask AI which S3 buckets are largest shows more storage questions. Changes run without a confirmation step, so connect ChatWithCloud with a read-only AWS profile for exploring.
Frequently asked questions
Can I upload a stream of unknown length to S3 with SDK v3?
Yes, with Upload from @aws-sdk/lib-storage. It buffers one part at a time, so no ContentLength is needed. PutObjectCommand isn’t a good fit for streams of unknown size.
What part size should I use for S3 multipart uploads?
Pick the smallest size where partSize × 10,000 exceeds your largest object, then tune queueSize for throughput. 16 to 64 MiB suits most server uploads.
How do I show upload progress in Node.js?
Listen for httpUploadProgress on the Upload instance. Each event has loaded, the part number, and total when the length is known.
Are incomplete multipart uploads deleted automatically?
No. They stay, and are billed, until you abort them or a lifecycle rule with AbortIncompleteMultipartUpload does it for you.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud