Migrate a Node.js App From AWS SDK v2 to v3, Step by Step

Lines of JavaScript code on a dark monitor in a dimly lit room

Photo by Juanjo Jaramillo on Unsplash

A Node.js AWS SDK v2 to v3 migration works best as a planned sequence: inventory every aws-sdk call, install one @aws-sdk/client-* package per service, run the official codemod, move each service to a shared client module, then fix what the codemod can’t: pagination, waiters, error checks, S3 Body streams and test mocks. Remove aws-sdk last, when tests pass.

This guide is for teams with a real application to move, not a single snippet: an API, a set of workers or a Lambda codebase that still imports aws-sdk. It covers the nodejs AWS SDK v2 to v3 migration as a whole project, in the order that keeps the app deployable after every step. Per-file translation is the easy part, and our free AWS SDK v2 to v3 converter handles that. The hard part is the behaviour that changes between versions.

By the end you’ll have v3 clients in shared modules, tests that mock v3 commands, and no aws-sdk left in package.json.

Why the migration can’t wait any longer

AWS SDK for JavaScript v2 entered maintenance mode on 8 September 2024 and reached end-of-support on 8 September 2025. It gets no new releases, which means no support for services or API features launched since then, and no security fixes. Previously published versions stay on npm, so nothing breaks on day one; the risk grows quietly. New features belong in v3 from the start, as in the guide to call Amazon Bedrock models with AWS SDK v3 and the Converse API.

Lambda removes the choice for serverless code. Every supported Node.js runtime (nodejs22.x, nodejs24.x and nodejs26.x at the time of writing) includes a version of SDK v3, not v2. A v2 function has to bundle aws-sdk itself, which adds weight to every deployment package. To see which functions are still on older runtimes, find Lambda functions on deprecated runtimes first.

What changes between v2 and v3

The GitHub UPGRADING.md notes for AWS SDK for JavaScript v3 list every difference. These are the ones that touch almost every codebase:

Area v2 v3
Package One aws-sdk package One package per service, such as @aws-sdk/client-s3
Calls s3.getObject(params).promise() client.send(new GetObjectCommand(params)); every call already returns a Promise
Pagination Manual token loops or eachPage paginate* async iterators
Waiters s3.waitFor("objectExists", …) waitUntilObjectExists({ client, maxWaitTime }, input)
Errors err.code, err.statusCode err.name, exception classes, err.$metadata.httpStatusCode
S3 GetObject body A Buffer A stream with transformToString() and transformToByteArray()
DynamoDB document client AWS.DynamoDB.DocumentClient DynamoDBDocumentClient.from(client) in @aws-sdk/lib-dynamodb
Retries maxRetries maxAttempts (v2 value plus 1)
Region from ~/.aws/config Only when AWS_SDK_LOAD_CONFIG is set Read by default

The last row catches people out. A service that silently ran in the wrong Region under v2 (because the config file was ignored) can start calling a different Region under v3. Pin the Region explicitly in each client or through AWS_REGION.

Prerequisites before you touch the code

  • A supported Node.js version. The v3 README states that v3.968.0 and higher require Node.js 20 or later. Upgrade Node first, as its own change.
  • Tests that exercise AWS calls, even if they currently mock v2. You’ll rewrite the mocks, but the assertions tell you whether behaviour survived.
  • A clean branch per service. Migrating S3 and DynamoDB in separate pull requests keeps reviews small and rollbacks cheap. v2 and v3 can run side by side in one app while you work.
  • A list of the IAM permissions the app has today. v3 calls the same API operations, so permissions shouldn’t change, but paginators and waiters make extra calls you should know about.

The nodejs AWS SDK v2 to v3 migration, step by step

  1. Inventory every v2 usageFind which services, operations and helpers (DocumentClient, S3.ManagedUpload, getSignedUrl, waitFor) the app uses.
  2. Install the v3 packagesOne client package per service, plus the helper libraries that replace v2 conveniences.
  3. Run the codemod on one directoryLet the official transform do the mechanical rewrite, then read the diff.
  4. Move each service to one shared client moduleCreate the client once per Region and import it everywhere.
  5. Fix what the codemod leavesPagination, waiters, error handling and S3 body streams need hand edits.
  6. Rewrite the test mocksReplace v2 mocks with aws-sdk-client-mock and assert on commands.
  7. Remove aws-sdkUninstall it once nothing imports it, and confirm with npm ls aws-sdk.

Step 1: inventory the v2 calls

Terminal

# Which service clients are created, and how often
grep -rhoE "new AWS\.[A-Za-z0-9.]+" src | sort | uniq -c | sort -rn

# Helpers that need a different v3 package
grep -rnE "DocumentClient|ManagedUpload|getSignedUrl|waitFor\(|eachPage" src

# Anything else that pulls in aws-sdk transitively
npm ls aws-sdk

Map each helper to its replacement before you start: DocumentClient goes to @aws-sdk/lib-dynamodb, multipart upload() to @aws-sdk/lib-storage (see how to upload large files and streams to S3 with SDK v3), and getSignedUrl to @aws-sdk/s3-request-presigner. Our examples show the v3 versions of the last two: upload a file to S3 with S3Client in TypeScript and create a presigned S3 download URL with SDK v3. For DynamoDB writes, the guide to update DynamoDB items with AWS SDK v3 shows UpdateCommand from @aws-sdk/lib-dynamodb with conditions and counters.

Step 2: install the packages

Terminal

npm install @aws-sdk/client-s3 @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb \
  @aws-sdk/lib-storage @aws-sdk/s3-request-presigner @aws-sdk/credential-providers
npm install --save-dev aws-sdk-client-mock aws-sdk-client-mock-jest @smithy/util-stream

Step 3: run the codemod

Terminal

# Preview the rewrite without changing files
npx aws-sdk-js-codemod@latest --dry --print -t v2-to-v3 src/reports

# Apply it
npx aws-sdk-js-codemod@latest -t v2-to-v3 src/reports

The transform swaps imports and drops .promise(), but it keeps the v2 call style: it produces the aggregated S3 or DynamoDB class with methods like client.listTables(). That works, and it’s a fine first commit. Moving to the bare client plus commands (S3Client and GetObjectCommand) is a second pass that pays off in bundle size, which matters for Lambda. For individual files the codemod mangles, paste them into the AWS SDK JavaScript v2 to v3 code converter and compare.

Steps 4 and 5: a shared client and the hand-fixed patterns

Here is a typical v2 module with all four patterns the codemod can’t finish for you: a manual pagination loop, a Buffer body, an err.code check and a waiter.

src/reports.js (v2, before)

const AWS = require("aws-sdk");

const s3 = new AWS.S3({ region: "us-east-1", maxRetries: 5 });

async function listReportKeys(bucket, prefix) {
  const keys = [];
  let token;
  do {
    const page = await s3
      .listObjectsV2({ Bucket: bucket, Prefix: prefix, ContinuationToken: token })
      .promise();
    for (const obj of page.Contents || []) keys.push(obj.Key);
    token = page.NextContinuationToken;
  } while (token);
  return keys;
}

async function readReport(bucket, key) {
  try {
    const res = await s3.getObject({ Bucket: bucket, Key: key }).promise();
    return res.Body.toString("utf-8");
  } catch (err) {
    if (err.code === "NoSuchKey") return null;
    throw err;
  }
}

async function waitForReport(bucket, key) {
  await s3.waitFor("objectExists", { Bucket: bucket, Key: key }).promise();
}

module.exports = { listReportKeys, readReport, waitForReport };

The v3 version below creates the client once at module level, so every function reuses its connection pool. Note maxAttempts: 6, which matches v2’s maxRetries: 5 because v3 counts the first attempt. The guide to configure retry and timeout settings in AWS SDK for JavaScript v3 covers retry modes and the HTTP timeouts, which v3 leaves unset by default.

src/reports.ts (v3, after)

import {
  S3Client,
  GetObjectCommand,
  NoSuchKey,
  paginateListObjectsV2,
  waitUntilObjectExists,
} from "@aws-sdk/client-s3";

export const s3 = new S3Client({ region: "us-east-1", maxAttempts: 6 });

export async function listReportKeys(bucket: string, prefix: string): Promise<string[]> {
  const keys: string[] = [];
  const pages = paginateListObjectsV2({ client: s3 }, { Bucket: bucket, Prefix: prefix });
  for await (const page of pages) {
    for (const obj of page.Contents ?? []) {
      if (obj.Key) keys.push(obj.Key);
    }
  }
  return keys;
}

export async function readReport(bucket: string, key: string): Promise<string | null> {
  try {
    const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
    return (await res.Body?.transformToString("utf-8")) ?? null;
  } catch (err) {
    if (err instanceof NoSuchKey) return null;
    throw err;
  }
}

export async function waitForReport(bucket: string, key: string): Promise<void> {
  await waitUntilObjectExists({ client: s3, maxWaitTime: 120 }, { Bucket: bucket, Key: key });
}

Three details to carry into every file:

  • Read the body once. A v3 Body is a stream. Call transformToString() or transformToByteArray() a single time, and always consume or destroy it, or the socket stays open.
  • HeadObject errors differ from GetObject. A HEAD response has no body, so a missing key surfaces as a NotFound error name, not NoSuchKey. Without s3:ListBucket you get a 403 instead of a 404. Our example to check if an S3 object exists in TypeScript handles both cases.
  • Byte payloads are Uint8Array. Lambda’s InvokeCommand returns Payload as bytes; decode it with transformToString(), as in invoking a Lambda function with AWS SDK v3 in TypeScript.

DynamoDB DocumentClient

The v3 document client wraps a normal DynamoDBClient. v2 omitted undefined values when it marshalled items; in v3 you opt into that behaviour with removeUndefinedValues:

src/db.ts

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

const base = new DynamoDBClient({ region: "us-east-1" });

export const ddb = DynamoDBDocumentClient.from(base, {
  marshallOptions: { removeUndefinedValues: true },
});

export async function getOrder(orderId: string) {
  const res = await ddb.send(new GetCommand({ TableName: "orders", Key: { pk: orderId, sk: "ORDER" } }));
  return res.Item ?? null;
}

Once GetCommand works, the guide to query DynamoDB with AWS SDK v3 using key conditions, indexes and pagination covers QueryCommand and paginateQuery on the same document client.

Step 6: rewrite the tests with aws-sdk-client-mock

Mocks written for v2 (usually stubbing AWS.S3.prototype or using aws-sdk-mock) don’t intercept v3 clients. The community library aws-sdk-client-mock mocks by command, which suits the v3 design. Wrap mocked S3 bodies with sdkStreamMixin so transformToString() exists in tests:

test/reports.test.ts

import { mockClient } from "aws-sdk-client-mock";
import "aws-sdk-client-mock-jest";
import { S3Client, GetObjectCommand, NoSuchKey } from "@aws-sdk/client-s3";
import { sdkStreamMixin } from "@smithy/util-stream";
import { Readable } from "node:stream";
import { readReport } from "../src/reports";

const s3Mock = mockClient(S3Client);

beforeEach(() => {
  s3Mock.reset();
});

test("returns the report body as a string", async () => {
  const body = sdkStreamMixin(Readable.from(["daily totals"]));
  s3Mock.on(GetObjectCommand).resolves({ Body: body });

  await expect(readReport("reports-bucket", "2026/12/14.csv")).resolves.toBe("daily totals");
  expect(s3Mock).toHaveReceivedCommandWith(GetObjectCommand, {
    Bucket: "reports-bucket",
    Key: "2026/12/14.csv",
  });
});

test("returns null when the key does not exist", async () => {
  s3Mock.on(GetObjectCommand).rejects(new NoSuchKey({ message: "missing", $metadata: {} }));
  await expect(readReport("reports-bucket", "missing.csv")).resolves.toBeNull();
});

Paginators need no special mocking: mock the underlying ListObjectsV2Command and the paginator calls it. The guide to list all objects in an S3 bucket with AWS SDK v3 uses the same paginator with prefixes, delimiters and a resume point.

Permissions: does v3 need different IAM actions?

No. v3 calls the same API operations with the same IAM actions, so an existing role keeps working. What changes is what your code calls once refactored: a waiter polls HeadObject (which needs s3:GetObject), and a paginator repeats its list call until the last page. The method to find the IAM actions your AWS SDK for JavaScript code needs maps each new Command to its action. If the migration is a chance to tighten the role, draft a policy from the new code with the IAM policy generator for TypeScript code, then work through our checklist to review a generated IAM policy for least privilege.

Common mistakes and how to fix them

Symptom Cause and fix
Region is missing No Region in the client, AWS_REGION or the profile. Set it explicitly per client.
Error handling never matches Code still checks err.code. Use err.name or instanceof the exception class.
Body.toString() doesn’t return the file contents The body is a stream now, not a Buffer. Use await Body.transformToString().
Tests hit real AWS v2 mocks don’t intercept v3. Switch to mockClient(S3Client).
Sockets pile up, requests hang Unconsumed GetObject bodies, or a new client per request. Consume every body and share one client.
AccessDenied after refactor A waiter or new helper calls an operation the role never needed. Follow the steps to troubleshoot an AWS IAM access denied error.

Where ChatWithCloud fits, and what it can’t do

The v2 to v3 converter is one of 19 free tools on ChatWithCloud’s AI code converters for AWS. It translates a file at a time and adds a comment wherever there is no direct equivalent; it doesn’t read your repository, run your tests or know your Region settings. Output must be reviewed and tested. The free converter rate limits and size caps allow up to 60,000 characters per conversion, so split very large files. Before pasting company code, read whether it’s safe to paste AWS code into an AI converter.

For working v3 reference code, the AWS SDK v3 practical examples in TypeScript cover common S3, Lambda, EC2 and Cost Explorer tasks. If some of your tooling is still Python, see how to port a Python boto3 script to Node.js with AWS SDK v3.

Frequently asked questions

Can I run AWS SDK v2 and v3 in the same Node.js app?

Yes. They’re separate packages and don’t conflict, which is what makes a service-by-service migration possible. Remove aws-sdk only when nothing imports it.

Does the aws-sdk-js-codemod finish the migration?

No. It rewrites imports, client creation and .promise() calls. Pagination loops, waiters, error checks, stream bodies and tests still need hand edits and review.

Do I need new IAM permissions after moving to SDK v3?

No. The API operations and IAM actions are the same. Only new calls you add, such as waiters, need their own permissions.

How long does a nodejs AWS SDK v2 to v3 migration take?

It depends on how many services and helpers you use, not on file count. An inventory from step 1 gives a realistic estimate: each service is one pull request.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud