SDK v3's Command Pattern
The single most surprising thing about the AWS SDK for JavaScript v3 is that you never call a method named after the operation.
Search across all documentation pages
The single most surprising thing about the AWS SDK for JavaScript v3 is that you never call a method named after the operation.
Instead of s3.getObject(...), you write s3.send(new GetObjectCommand(...)). Every service, every operation, the same shape.
This page explains why v3 is built that way, what a command actually is, and why the pattern pays off even though it looks like extra typing.
new XCommand(input) and pass it to client.send(), which returns a promise of the response.send, tree-shaking, middleware stack, modular packages.A v3 call has two independent pieces.
The client is a long-lived object bound to a service and a region. It holds configuration, a connection pool, the credential provider, and the middleware stack. You build it once and reuse it.
The command is a small, throwaway object that names one operation and carries its input. new GetObjectCommand({ Bucket, Key }) does not do anything on its own; it is a description of intent.
You bring the two together with send. The client takes the command, runs it through its pipeline, and returns the parsed response.
Here is the same DynamoDB GetItem operation in boto3 and in SDK v3, so the difference is concrete.
# --- Python (boto3) ---
import boto3
ddb = boto3.client("dynamodb", region_name="us-east-1")
item = ddb.get_item( # operation is a method on the client
TableName="Orders",
Key={"id": {"S": "order-42"}},
)
print(item.get("Item"))// --- TypeScript (AWS SDK v3) ---
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient({ region: "us-east-1" });
const item = await ddb.send(new GetItemCommand({ // operation is a Command
TableName: "Orders",
Key: { id: { S: "order-42" } },
}));
console.log(item.Item);The input object is identical, because both call the same DynamoDB API. Only the calling convention differs: boto3 hangs get_item on the client, v3 makes you construct a GetItemCommand and send it.
Why would a library choose the more verbose form on purpose? The answer is tree-shaking.
boto3 attaches every one of a service's operations as a method on the client object. That is convenient, but it means the whole service surface is reachable at runtime, so nothing can be removed. In Python, running from a full environment, that is fine.
JavaScript is different. Code often ships to a browser or a cold-starting Lambda, where every kilobyte costs load time. If DynamoDB's client carried all of its operations as methods, importing the client would drag all of them into your bundle even if you only ever call GetItem.
The command pattern breaks that link. Each operation lives in its own exported class. Import GetItemCommand and only that command's serialization and validation code is reachable; a bundler like esbuild, Rollup, or webpack statically drops the rest.
send is the second half. It is a generic method typed so that the command's input type and output type flow through automatically. When you write client.send(new GetItemCommand(input)), TypeScript infers that the result is a GetItemCommandOutput. You get full typing without the client needing a named method per operation.
Underneath, send pushes the command through the client's Smithy middleware stack: serialize the input, build the HTTP request, sign it, send it, deserialize the response. Because every command travels the same pipeline, v3 can insert signing, retries, and logging in one place rather than per operation.
The client and command being separate objects has practical consequences beyond bundle size.
A command is reusable and inspectable. You can build one, log it, pass it to a helper, or hand it to getSignedUrl from @aws-sdk/s3-request-presigner to produce a presigned URL - all without a client sending it. The command is data; sending is a separate act.
One client can send many different commands. An S3Client sends GetObjectCommand, PutObjectCommand, ListObjectsV2Command, and any other S3 command. You do not need a client per operation, only per service.
The pattern also composes with the paginator and waiter helpers. Functions like paginateListObjectsV2 take { client } and drive the underlying command for you, so you rarely construct list commands in a manual loop.
| Aspect | boto3 (method model) | SDK v3 (command pattern) |
|---|---|---|
| Calling an operation | client.get_item(**input) | client.send(new GetItemCommand(input)) |
| Tree-shaking unused operations | Not possible | Yes, unused commands are dropped |
| Imports per call | Client only | Client plus command |
| Typing of result | Dynamic (dict) | Static (GetItemCommandOutput) |
| Where signing/retries live | botocore internals | Shared middleware stack |
The trade-off is honest: v3 asks for a second import and a new XCommand(...) wrapper on every call. In return you ship less code, get precise types, and gain a uniform place to hook middleware. For server code where bundle size is irrelevant, the ceremony can feel gratuitous - but the same convention runs unchanged in the browser, which is the point.
send is slower because it constructs an object." - No, allocating a small command object is negligible; the cost of a call is the network round trip, not the new.new XCommand(input) only describes the operation; nothing happens until you await client.send(...).So bundlers can tree-shake. Each operation is a separately imported command class, letting your build drop every operation you never use - critical for browser and Lambda bundle size.
The client is a reusable transport bound to a service and region, holding config and the middleware stack. The command is a throwaway object naming one operation and its input. send joins them.
No. new XCommand(input) only builds a description of the operation. The network call happens when you await client.send(command).
Yes. An S3Client sends any S3 command - GetObjectCommand, PutObjectCommand, and so on. You need one client per service, not per operation.
send is generic. It infers the output type from the command you pass, so send(new GetItemCommand(...)) resolves to GetItemCommandOutput with no manual annotation.
They reinforce each other. Because every command flows through the same send, v3 can run all commands through one Smithy middleware stack for signing, retries, and logging.
No. boto3 attaches each operation as a method on the client. It cannot tree-shake, but it also never needs the construct-and-send step.
The client provides transport and config; the command provides the operation and input. Keeping them separate is exactly what enables unused commands to be dropped from your bundle.
You can pass it around and inspect it, and helpers like getSignedUrl accept one. In practice you construct a fresh command per call since it is cheap and carries per-call input.
On the server, bundle size matters less, so the pattern can feel verbose. The payoff is one convention that also runs in the browser and a shared middleware pipeline everywhere.
Stack versions: This page was written for the AWS SDK for JavaScript v3 on Node.js 18+ (and boto3 1.43.x / Python 3.10+ where contrasted).
Reviewed by Chris St. John·Last updated Jul 23, 2026