@aws-sdk/client-s3
@aws-sdk/client-s3 is the official AWS SDK for JavaScript v3 client for S3: upload, download, list, copy, and delete objects plus bucket-level operations, with one Command class per API call. v3 split the old monolithic aws-sdk into per-service packages so you ship only the client you use, added first-class TypeScript types, and routes every call through a middleware stack. It also talks to S3-compatible stores (Cloudflare R2, MinIO, Backblaze B2) via a custom endpoint, with caveats.
If you use S3 from Node, use this client; it is well built once the Command pattern clicks. Budget time for the generated docs, pin versions, and watch release notes, because AWS ships daily and has changed defaults inside the major before.
Use it if
- You talk to S3 or an S3-compatible store from Node 20+ and want the client AWS itself builds, types, and supports
- You need the full surface: multipart uploads, presigned URLs, versioning, checksums, bucket policies
- You run on Lambda, where the v3 SDK ships inside the Node.js runtimes so the dependency is effectively free
- You bundle for size and want per-service packages plus modular commands that tree shaking can trim
- You only need to sign a request in a browser or edge runtime: aws4fetch is tiny, while this client drags a wide @smithy dependency tree with it
- You maintain v2 aws-sdk code: v3 is a full rewrite (Command objects, streaming bodies, different error shapes), so migration is real work, not find-and-replace
- You target S3-compatible services and cannot babysit AWS changes: the v3.729.0 default checksum change broke uploads to R2, MinIO, and others until configs and services caught up
- You expect a guided API: the reference docs are generated from service models, so figuring out input shapes often means reading TypeScript definitions
Setup reality
npm install @aws-sdk/client-s3 pulls a wide tree of @aws-sdk/* and @smithy/* internals and requires Node 20+. Presigned URLs and managed multipart uploads live in separate packages (@aws-sdk/s3-request-presigner, @aws-sdk/lib-storage) that you discover only when something is missing. Versions move almost daily (3.1103.0 at review time), so pin and upgrade deliberately. Credentials flow through the default provider chain, which is painless on AWS infrastructure and confusing everywhere else; local dev usually means env vars or a shared credentials file, and S3-compatible endpoints usually need forcePathStyle plus checksum overrides.
Patterns
Create a clientclient-setup
import { S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({ region: 'us-east-1' });Credentials come from the default provider chain (env vars, shared config, IAM role); reuse one client instead of creating one per request.
Upload an objectput-object
import { PutObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'notes/hello.txt',
Body: 'hello world',
ContentType: 'text/plain'
}));Body accepts strings, Buffers, and streams, but a stream of unknown length fails; use Upload from @aws-sdk/lib-storage for those.
Read an object into memoryget-object
import { GetObjectCommand } from '@aws-sdk/client-s3';
const res = await s3.send(new GetObjectCommand({
Bucket: 'my-bucket',
Key: 'notes/hello.txt'
}));
const text = await res.Body.transformToString();Unlike v2, Body is a stream, not a Buffer; transformToString and transformToByteArray are the easy way out for small objects.
Stream a large object to diskdownload-to-file
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { GetObjectCommand } from '@aws-sdk/client-s3';
const res = await s3.send(new GetObjectCommand({ Bucket: 'my-bucket', Key: 'big.bin' }));
await pipeline(res.Body, createWriteStream('/tmp/big.bin'));Pipe instead of buffering; transformToByteArray on a multi-GB object will exhaust the heap.
List all keys under a prefixlist-objects
import { paginateListObjectsV2 } from '@aws-sdk/client-s3';
for await (const page of paginateListObjectsV2(
{ client: s3 },
{ Bucket: 'my-bucket', Prefix: 'logs/' }
)) {
for (const obj of page.Contents ?? []) console.log(obj.Key, obj.Size);
}ListObjectsV2 returns at most 1000 keys per call; the paginator handles ContinuationToken, and Contents is undefined on empty pages.
Generate a presigned URLpresigned-url
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'my-bucket', Key: 'file.pdf' }),
{ expiresIn: 3600 }
);The presigner is a separate package. Expiry caps at 7 days, and URLs signed with temporary credentials die when those credentials expire.
Upload big files with progressmultipart-upload
import { Upload } from '@aws-sdk/lib-storage';
const upload = new Upload({
client: s3,
params: { Bucket: 'my-bucket', Key: 'video.mp4', Body: fileStream }
});
upload.on('httpUploadProgress', (p) => console.log(p.loaded, '/', p.total));
await upload.done();PutObject tops out at 5 GB and cannot report progress; Upload does multipart with parallel parts and handles unknown-length streams.
Check whether a key existscheck-exists
import { HeadObjectCommand } from '@aws-sdk/client-s3';
try {
await s3.send(new HeadObjectCommand({ Bucket: 'my-bucket', Key: 'maybe.txt' }));
// exists
} catch (err) {
if (err.name === 'NotFound') {
// missing
} else throw err;
}HeadObject throws NotFound (not NoSuchKey), and you get 403 instead of 404 when the caller lacks s3:ListBucket on the bucket.
Delete multiple objects in one calldelete-many
import { DeleteObjectsCommand } from '@aws-sdk/client-s3';
const res = await s3.send(new DeleteObjectsCommand({
Bucket: 'my-bucket',
Delete: { Objects: [{ Key: 'a.txt' }, { Key: 'b.txt' }] }
}));
if (res.Errors?.length) console.error(res.Errors);Limit is 1000 keys per call, and per-key failures arrive inside a 200 response; always check the Errors array.
Point the client at R2 or MinIOs3-compatible-endpoint
import { S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'auto',
endpoint: 'https://ACCOUNT_ID.r2.cloudflarestorage.com',
forcePathStyle: true,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED'
});Since v3.729.0 the SDK sends CRC32 checksums by default, which broke several S3-compatible services; the WHEN_REQUIRED settings restore the old behavior.
Copy an object server-sidecopy-object
import { CopyObjectCommand } from '@aws-sdk/client-s3';
await s3.send(new CopyObjectCommand({
Bucket: 'dest-bucket',
Key: 'copy.txt',
CopySource: '/src-bucket/original.txt'
}));CopySource needs URL encoding when the key has special characters, and server-side copy caps at 5 GB before you need multipart copy.
Tune retries and timeoutsretry-timeout
import { S3Client } from '@aws-sdk/client-s3';
import { NodeHttpHandler } from '@smithy/node-http-handler';
const s3 = new S3Client({
maxAttempts: 5,
retryMode: 'adaptive',
requestHandler: new NodeHttpHandler({
connectionTimeout: 3000,
requestTimeout: 30000
})
});Defaults are 3 attempts in standard retry mode and no overall request timeout; a hung socket waits forever unless you set one.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| minio | npm | A smaller focused client for MinIO and other S3-compatible stores without the AWS dependency tree |
| aws4fetch | npm | You just need SigV4-signed S3 requests from browsers, Cloudflare Workers, or other edge runtimes |
| aws-sdk | npm | A legacy v2 codebase not worth migrating yet; note v2 reached end-of-support and gets no fixes |