Skip to main content

@aws-sdk v3 vs v2 Migration Guide 2026

·PkgPulse Team
0

@aws-sdk v3 Modular vs v2 Migration Guide 2026

TL;DR

AWS SDK for JavaScript v2 (aws-sdk) is in maintenance mode — it still works but receives only critical security fixes, not new features. @aws-sdk v3 (launched 2020, reached feature parity in 2022) is the current version and the only choice for new projects in 2026. The key differences are modular package architecture (import only what you use), a middleware-based plugin system, improved TypeScript types, and dramatically smaller bundle sizes for Lambda and edge deployments. Migration from v2 is mostly mechanical but requires updating import paths, instantiation patterns, and pagination utilities.

Key Takeaways

  • @aws-sdk/client-s3: 1.2M weekly downloads (v3), vs aws-sdk v2 at 6M (still dominant in legacy codebases)
  • Bundle size: importing only S3 from v3 — ~95KB gzip vs ~900KB for the entire v2 SDK
  • Tree-shaking: v3 packages are ES modules; Lambda cold starts drop 40-70% for services that previously imported the full v2 SDK
  • Middleware stack: v3 uses a composable middleware chain (like Express) — add retry logic, signing, logging without forking the SDK
  • TypeScript: v3 generates types from service API definitions — exact input/output types per operation, no more any
  • Pagination: v3 has built-in paginator helpers (paginateListObjects) that eliminate manual NextToken/Marker loops

Why v2 Still Has More Downloads

aws-sdk v2 has 6M+ weekly downloads in 2026, despite v3 being the official recommendation. The reasons are instructive:

  1. Lambda execution environments — AWS Lambda included aws-sdk v2 in the Node.js runtime until Node.js 18. Any Lambda not bundling its own SDK uses v2 automatically.
  2. Legacy codebases — Enterprise Node.js apps started before 2020 often have thousands of require('aws-sdk') calls.
  3. CDK and tooling — Some infrastructure-as-code tools internally depended on v2.
  4. Gradual migration — Many teams are mid-migration, still using v2 in parts of their codebase.

AWS deprecated v2 in September 2024, with end of support (no security fixes) planned for September 2025. If you're on v2 in 2026, you're on unsupported software.

Architecture Differences

v2: Single Monolithic Package

// v2: one import, everything available
import AWS from 'aws-sdk'

// Instantiate any service
const s3 = new AWS.S3({ region: 'us-east-1' })
const dynamodb = new AWS.DynamoDB({ region: 'us-east-1' })
const lambda = new AWS.Lambda({ region: 'us-east-1' })

Installing aws-sdk pulls in ~100MB of code covering every AWS service. For a Lambda using only S3, you import the full SDK and pay the cold start penalty for every unused service.

v3: Modular Service Packages

// v3: import only what you need
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'

const s3 = new S3Client({ region: 'us-east-1' })
const ddb = new DynamoDBClient({ region: 'us-east-1' })

Each AWS service is its own npm package. Adding S3 doesn't add Lambda, DynamoDB, or any other service. A Lambda function that only uses S3 installs ~3MB instead of ~100MB.

Migration: Common Patterns

S3

// ===== v2 =====
import AWS from 'aws-sdk'
const s3 = new AWS.S3()

// Get object
const result = await s3.getObject({
  Bucket: 'my-bucket',
  Key: 'my-key',
}).promise()
const body = result.Body?.toString()

// Put object
await s3.putObject({
  Bucket: 'my-bucket',
  Key: 'my-key',
  Body: 'hello world',
  ContentType: 'text/plain',
}).promise()

// ===== v3 =====
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

const s3 = new S3Client({ region: 'us-east-1' })

// Get object
const result = await s3.send(new GetObjectCommand({
  Bucket: 'my-bucket',
  Key: 'my-key',
}))
// Body is now a ReadableStream — convert appropriately:
const body = await result.Body?.transformToString()

// Put object
await s3.send(new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: 'my-key',
  Body: 'hello world',
  ContentType: 'text/plain',
}))

// Presigned URL (separate package in v3)
const url = await getSignedUrl(s3, new GetObjectCommand({
  Bucket: 'my-bucket',
  Key: 'my-key',
}), { expiresIn: 3600 })

The key v3 changes for S3:

  • .promise() is gone — all operations return Promises natively
  • Body is a ReadableStream | Blob | string, not a Buffer — use .transformToString(), .transformToByteArray(), or pipe it
  • Presigned URLs require @aws-sdk/s3-request-presigner as a separate install

DynamoDB

// ===== v2 =====
const docClient = new AWS.DynamoDB.DocumentClient()
const result = await docClient.get({
  TableName: 'Users',
  Key: { userId: '123' },
}).promise()
const user = result.Item

// ===== v3: DynamoDB DocumentClient =====
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand } from '@aws-sdk/lib-dynamodb'

const client = new DynamoDBClient({ region: 'us-east-1' })
const docClient = DynamoDBDocumentClient.from(client, {
  marshallOptions: { removeUndefinedValues: true },
})

// Get
const result = await docClient.send(new GetCommand({
  TableName: 'Users',
  Key: { userId: '123' },
}))
const user = result.Item  // typed, no manual unmarshalling

// Put
await docClient.send(new PutCommand({
  TableName: 'Users',
  Item: { userId: '123', name: 'Alice', createdAt: new Date().toISOString() },
}))

// Query with expression
const queryResult = await docClient.send(new QueryCommand({
  TableName: 'Users',
  IndexName: 'email-index',
  KeyConditionExpression: 'email = :email',
  ExpressionAttributeValues: { ':email': 'alice@example.com' },
}))
const users = queryResult.Items ?? []

The @aws-sdk/lib-dynamodb package provides the DocumentClient equivalent — it handles marshalling/unmarshalling JavaScript values to DynamoDB's typed format. Install it alongside @aws-sdk/client-dynamodb.

SES (Email)

// v2
await ses.sendEmail({
  Source: 'noreply@example.com',
  Destination: { ToAddresses: ['user@example.com'] },
  Message: {
    Subject: { Data: 'Hello' },
    Body: { Text: { Data: 'World' } },
  },
}).promise()

// v3 — use SESv2 for new features (templates, contacts)
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'

const ses = new SESv2Client({ region: 'us-east-1' })

await ses.send(new SendEmailCommand({
  FromEmailAddress: 'noreply@example.com',
  Destination: { ToAddresses: ['user@example.com'] },
  Content: {
    Simple: {
      Subject: { Data: 'Hello' },
      Body: { Text: { Data: 'World' } },
    },
  },
}))

Pagination: The Biggest API Improvement

v2 pagination required manual NextToken loops. v3 provides paginator functions:

// ===== v2: manual pagination =====
let continuationToken: string | undefined
const allObjects: AWS.S3.Object[] = []

do {
  const result = await s3.listObjectsV2({
    Bucket: 'my-bucket',
    ContinuationToken: continuationToken,
  }).promise()

  allObjects.push(...(result.Contents ?? []))
  continuationToken = result.NextContinuationToken
} while (continuationToken)

// ===== v3: built-in paginator =====
import { S3Client, paginateListObjectsV2 } from '@aws-sdk/client-s3'

const s3 = new S3Client({ region: 'us-east-1' })
const allObjects = []

for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: 'my-bucket' })) {
  allObjects.push(...(page.Contents ?? []))
}
// Automatic pagination — no token management needed

Every AWS service with paginated responses has a corresponding paginate* function in v3.

Middleware Stack

v3's middleware system is the most powerful architectural improvement:

import { S3Client } from '@aws-sdk/client-s3'

const s3 = new S3Client({ region: 'us-east-1' })

// Add logging middleware
s3.middlewareStack.add(
  (next, context) => async (args) => {
    console.log(`[AWS] ${context.commandName} started`)
    const start = Date.now()
    const result = await next(args)
    console.log(`[AWS] ${context.commandName} completed in ${Date.now() - start}ms`)
    return result
  },
  {
    step: 'initialize',
    name: 'loggingMiddleware',
  }
)

// Add custom retry logic middleware
s3.middlewareStack.add(
  (next) => async (args) => {
    let attempt = 0
    while (true) {
      try {
        return await next(args)
      } catch (err) {
        if (attempt++ >= 3) throw err
        await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 100))
      }
    }
  },
  { step: 'finalizeRequest', name: 'retryMiddleware' }
)

The middleware stack replaces v2's event hooks with a composable, typed system. Libraries can export middleware plugins that users opt into.

TypeScript Improvements

v3 generates TypeScript types directly from AWS service API definitions:

import { PutItemCommandInput } from '@aws-sdk/client-dynamodb'

// ✅ Fully typed — TypeScript knows all valid fields and their types
const input: PutItemCommandInput = {
  TableName: 'Users',
  Item: {
    userId: { S: '123' },          // 'S' for string
    count: { N: '42' },            // 'N' for number (as string)
    active: { BOOL: true },        // 'BOOL' for boolean
  },
  ConditionExpression: 'attribute_not_exists(userId)',
}

In v2, most inputs and outputs were typed as any or with loose types. v3 gives precise types per operation, including response shapes — enabling autocompletion and catching type errors at compile time.

Bundle Size Impact

For a Lambda function using only S3 and DynamoDB:

ScenarioInstalled SizeGzip
v2 full SDK (aws-sdk)~100MB~9MB
v3 S3 + DynamoDB only~8MB~850KB
v3 S3 only (bundled, tree-shaken)~3MB~300KB
v2 on Lambda (built-in, not bundled)0 (built-in)0

The "0" row for Lambda built-in is why many teams haven't migrated — Lambda included aws-sdk v2 in the execution environment. Since Node 18 (now the minimum recommended runtime), AWS no longer includes v2 by default. Bundling v3 is now both required and recommended.

When using esbuild or tsup with proper tree-shaking, a Lambda using only @aws-sdk/client-s3 bundles to ~300KB gzip — a 30x reduction from the full v2 SDK.

Error Handling

v3 improves error handling with typed error classes per service:

import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'
import { NoSuchKey, S3ServiceException } from '@aws-sdk/client-s3'

const s3 = new S3Client({ region: 'us-east-1' })

try {
  const result = await s3.send(new GetObjectCommand({
    Bucket: 'my-bucket',
    Key: 'missing-file.txt',
  }))
} catch (err) {
  if (err instanceof NoSuchKey) {
    // Typed: this is specifically a 404 NoSuchKey error
    console.log('File does not exist')
  } else if (err instanceof S3ServiceException) {
    // Any other S3 service error
    console.log(`S3 error: ${err.name}${err.message}`)
    console.log(`HTTP status: ${err.$response?.statusCode}`)
  } else {
    // Network error, timeout, etc.
    throw err
  }
}

In v2, error handling required checking err.code string values (err.code === 'NoSuchKey'). v3 gives you instanceof checks with TypeScript-aware typed error classes, one per AWS error code.

Credentials Configuration

// v3 credential providers
import { S3Client } from '@aws-sdk/client-s3'
import { fromEnv } from '@aws-sdk/credential-providers'
import { fromIni } from '@aws-sdk/credential-providers'
import { fromTemporaryCredentials } from '@aws-sdk/credential-providers'

// Explicit env vars
const s3 = new S3Client({
  region: 'us-east-1',
  credentials: fromEnv(),  // AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
})

// Assume a role (common in CI and cross-account access)
const crossAccountS3 = new S3Client({
  region: 'us-east-1',
  credentials: fromTemporaryCredentials({
    params: {
      RoleArn: 'arn:aws:iam::123456789:role/MyRole',
      RoleSessionName: 'my-session',
    },
  }),
})

// Named profile from ~/.aws/credentials
const profileS3 = new S3Client({
  region: 'us-east-1',
  credentials: fromIni({ profile: 'staging' }),
})

The @aws-sdk/credential-providers package consolidates all credential sources — environment variables, IAM roles, SSO, web identity tokens, and more. The default credential chain (no credentials option) checks environment → ECS task role → EC2 instance metadata, which works for most production deployments.

Ecosystem & Community

The @aws-sdk v3 package family is one of the most actively maintained open-source projects in the JavaScript ecosystem. The AWS SDK JavaScript team at Amazon releases updates weekly, with service-specific packages tracking the AWS API changelog. The monorepo at github.com/aws/aws-sdk-js-v3 has 3,000+ contributors and is the source of truth for v3 development.

The community around AWS SDK v3 includes several high-quality third-party utilities. aws-lite provides a lightweight alternative client for teams that find v3 too heavy for specific use cases. Serverless Framework and AWS CDK both use v3 internally. The SST (Serverless Stack) framework, popular for full-stack serverless applications, builds its resource bindings on top of v3 clients. The @smithy/* packages that underpin v3's architecture are themselves useful for teams building non-AWS AWS-compatible APIs (like MinIO or Localstack wrappers).

For local development, LocalStack provides a Docker-based mock of AWS services that works seamlessly with v3 clients. The @aws-sdk/credential-providers's fromEnv() provider is commonly used in conjunction with LocalStack for local development without real AWS credentials.

Real-World Adoption

The v3 migration is now effectively complete for greenfield projects — any Node.js project started in 2024 or later uses v3. The remaining v2 footprint is in legacy enterprise systems, infrastructure tooling written before v3's feature parity in 2022, and AWS Lambda functions that relied on the built-in v2 SDK before Node.js 18 removed it.

Large engineering organizations that migrated to v3 report consistent results: Lambda cold start times drop 40-70% when moving from the full v2 SDK to modular v3 clients. For a Lambda that previously imported the full v2 SDK (100MB installed) but only used S3 and DynamoDB, the v3 migration brings the installed bundle to under 10MB — a 10x reduction in Lambda package size.

The middleware system has enabled a new category of AWS SDK extensions. Datadog's AWS Lambda integration, OpenTelemetry tracing for AWS, and custom retry/circuit-breaker implementations all use v3's middleware API. These would have required SDK forks or monkey-patching in v2.

Teams report that the v3 TypeScript improvements alone often justify the migration. Catching incorrect DynamoDB expression attribute value formats at compile time — rather than getting a runtime error from AWS — saves debugging time in production environments where AWS API errors can be slow to reproduce.

Developer Experience Deep Dive

The Command pattern in v3 (client.send(new GetObjectCommand(...))) is more verbose than v2's method-on-client approach (s3.getObject(...).promise()). This verbosity is intentional — it makes the operation explicit and enables TypeScript to provide precise types for each command's input and output. Once you're used to it, autocomplete on Command classes is significantly better than v2's loose typing.

The separate package architecture does add cognitive overhead during setup. For a Lambda that uses S3, SES, and DynamoDB, you install three separate packages (@aws-sdk/client-s3, @aws-sdk/client-sesv2, @aws-sdk/client-dynamodb) plus @aws-sdk/lib-dynamodb for the DocumentClient equivalent. The npm install command is longer, but the resulting bundle is dramatically smaller.

Documentation quality for v3 is excellent. The AWS Developer Guide provides service-specific examples for v3, and the TypeScript types themselves are auto-generated documentation — hovering over a command in VS Code shows the full API reference inline. The migration documentation at docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide is comprehensive.

The local development story with LocalStack and v3 is smooth. You configure a custom endpoint URL and the credential provider uses test credentials, making it possible to develop AWS-integrated code without incurring AWS costs or requiring network access.

Performance & Lambda Optimization

The performance story for v3 is primarily about cold starts. AWS Lambda cold starts are proportional to the size of the deployment package — a 300KB bundle cold-starts in under 100ms, while a 10MB bundle might take 500ms-1s. For user-facing Lambda functions (API Gateway integrations, image processing, edge functions), this difference is significant.

Beyond bundle size, v3's connection reuse is configurable at the client level. By default, v3 clients reuse HTTP connections within a Lambda execution environment. For high-throughput functions making many API calls, configuring the max connections via requestHandler configuration prevents connection exhaustion.

Keeping AWS clients as module-level singletons (not re-instantiating them per Lambda invocation) is a critical optimization that applies to both v2 and v3. Module-level clients are re-used across warm Lambda invocations, avoiding the overhead of re-establishing connections and re-discovering credentials.

// Correct: module-level singleton
const s3 = new S3Client({ region: process.env.AWS_REGION })

export async function handler(event: APIGatewayEvent) {
  // Uses the same s3 client on warm invocations
  const result = await s3.send(new GetObjectCommand({ ... }))
}

For Bun and Deno runtimes (increasingly common for serverless workloads), v3's native ES module support is essential — both runtimes have limited or no support for CommonJS, and v3's ESM-first design ensures compatibility.

Migration Checklist

When migrating a project from v2 to v3:

[ ] Replace aws-sdk with individual @aws-sdk/client-* packages
[ ] Update all instantiation: new AWS.S3() → new S3Client()
[ ] Remove .promise() calls — v3 returns Promises natively
[ ] Update S3 Body handling: Buffer → transformToString() / transformToByteArray()
[ ] Install @aws-sdk/lib-dynamodb for DocumentClient equivalent
[ ] Replace manual pagination loops with paginate* helpers
[ ] Update presigned URL code to @aws-sdk/s3-request-presigner
[ ] Update credential configuration (CredentialProvider changed)
[ ] Test in Lambda with Node 18+ (no longer has built-in v2)
[ ] Enable bundling (esbuild/tsup) for Lambda to get tree-shaking benefits

Final Verdict 2026

If you're starting a new project that uses AWS services in Node.js, there is no decision to make: use @aws-sdk v3. The modular architecture, TypeScript types, middleware system, and built-in paginators are unambiguously better than v2, and v2 is now unsupported security software.

If you have an existing v2 codebase, the migration priority depends on your Lambda cold start sensitivity and security requirements. AWS's official end-of-support date means v2 will not receive security patches in 2026 — any organization with security compliance requirements should treat this as a priority migration.

The migration is largely mechanical: each service follows the same pattern, and once you've migrated S3 and DynamoDB (the two most common services), the rest follow the same pattern. The TypeScript improvements and bundle size reduction make the investment worthwhile in every case where it's been done.

Methodology

  • npm download data from npmjs.com registry API, March 2026
  • @aws-sdk v3 docs: docs.aws.amazon.com/AWSJavaScriptSDK/v3
  • aws-sdk v2 end of support announcement: aws.amazon.com/blogs/developer/announcing-end-of-support-for-aws-sdk-for-javascript-v2
  • Bundle sizes measured via bundlephobia.com and local Lambda build analysis

Compare AWS SDK with other cloud SDKs on PkgPulse.

See also: AWS courses and tutorials on CourseFacts — learn AWS SDK, serverless, and cloud infrastructure from top-rated courses.

Testing Strategies for AWS SDK v3 Code

Testing code that calls AWS services presents consistent challenges: real AWS calls cost money, require network access, and make tests slow and non-deterministic. AWS SDK v3's architecture makes several testing strategies practical.

The most common approach is mocking with @aws-sdk/client-s3's or any service client's mockClient utility from the aws-sdk-client-mock package. This package intercepts client.send() calls at the middleware level and returns configured mock responses:

The middleware stack is the key enabler here. By intercepting at the send() level, you mock the entire AWS HTTP request/response cycle without any network access. Your test asserts that PutObjectCommand was called with specific parameters, and the mock returns the exact response shape your code expects.

For local development and integration tests, LocalStack provides a Docker container that implements most AWS service APIs locally. v3 clients work seamlessly with LocalStack by setting a custom endpoint in the client configuration pointing to http://localhost:4566. This approach is more realistic than unit mocks for testing DynamoDB queries and S3 bucket policies.

Serverless and Lambda Best Practices

AWS SDK v3 was designed with serverless workloads in mind, and several patterns significantly affect Lambda performance. The most important: instantiate AWS clients outside your handler function, at the module level. Lambda execution environments are reused across warm invocations, meaning module-level code runs once while the handler runs on every request. A DynamoDB client created inside the handler re-establishes its configuration and credentials on every invocation — expensive and unnecessary.

Connection pooling in v3 is handled by the NodeHttpHandler from @aws-sdk/node-http-handler. For Lambda functions making many concurrent AWS API calls, configuring maxSockets prevents connection pool exhaustion under load. The default is 50 concurrent connections per client, which is appropriate for most functions but may need tuning for high-concurrency workloads.

For Lambda functions bundled with esbuild or tsup, externalize @aws-sdk packages carefully. AWS Lambda runtimes for Node.js 18+ no longer include any AWS SDK by default. You must bundle the packages you use. A common mistake is marking @aws-sdk/* as external in the bundler config — this works in development where the SDK is in node_modules but produces a broken Lambda bundle. Bundle the SDK packages you use and tree-shake aggressively.

Working with S3 Presigned URLs

Presigned URLs are one of the most common S3 patterns — generate a URL that grants temporary access to a private object, or allows a client to upload directly to S3 without exposing credentials. v3 handles this via the @aws-sdk/s3-request-presigner package (separate from @aws-sdk/client-s3).

Presigned POST URLs (for browser uploads) use a different package: @aws-sdk/s3-presigned-post. The distinction matters because presigned PUT vs presigned POST have different security and size constraint capabilities. Presigned PUT is simpler and works with fetch. Presigned POST supports server-side size limits and content-type restrictions enforced by S3, making it the right choice for user-generated content uploads where you want to prevent oversized file uploads.

Both approaches eliminate the need to route file data through your server — the client uploads directly to S3, which scales independently of your API server.

v3 in the Broader AWS Ecosystem

The move to v3 aligns with the broader direction of AWS infrastructure tooling for JavaScript. AWS CDK v2, the infrastructure-as-code framework, uses v3 internally. SST v3 (Serverless Stack), one of the most popular full-stack serverless frameworks, builds on v3 clients for its resource bindings. Architect (arc.codes) has v3 support. The tooling ecosystem has essentially completed the v3 transition — new libraries and frameworks in the AWS/serverless JavaScript space don't support v2.

For teams building event-driven architectures alongside their REST APIs, understanding EventBridge, SQS, and SNS with v3 follows the same modular pattern: @aws-sdk/client-eventbridge, @aws-sdk/client-sqs, @aws-sdk/client-sns. Each service has dedicated paginators, typed error classes, and middleware support. The migration checklist from the main section of this guide applies to all services.

For database alternatives that work alongside AWS architectures — particularly for applications that want more SQL expressiveness than DynamoDB — see the comparison of Neon vs Supabase vs Tembo serverless Postgres for serverless-native Postgres options that integrate cleanly with Lambda functions.

Vendor Lock-in Considerations

A reasonable concern when adopting v3 deeply is vendor lock-in. AWS SDK v3 is AWS-specific — it doesn't abstract over cloud providers. If there's a chance your application needs to run on GCP or Azure in the future, AWS SDK v3 calls are not portable.

Practically, most teams don't switch cloud providers after choosing AWS, and abstracting over cloud providers adds significant complexity for uncertain benefit. The pragmatic approach is to isolate AWS SDK calls behind repository or service interfaces in your application code. This provides the benefit of testability (mock the interface, not the SDK) and makes the surface area of AWS-specific code visible. If you ever need to swap S3 for GCP Cloud Storage, the change is contained to the repository implementation.

For infrastructure decisions like CI/CD, secrets management, and database selection that complement your AWS usage, see the 20 fastest growing npm packages 2026 for emerging tooling that pairs well with modern AWS deployments.

Related: Best Email Libraries Node.js 2026 for SES alternatives, Best WebSocket Libraries Node.js 2026 for real-time features alongside your AWS backend, and Turso vs PlanetScale vs Neon serverless database 2026 for database alternatives to DynamoDB.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.