AWS
S3
JavaScript
Node.js
SDK

AWS S3 Sync with JS/Node SDK

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

The AWS CLI gives you aws s3 sync, but the Node.js SDK does not provide one built-in command that mirrors a directory automatically. If you want sync behavior in application code, you need to implement it yourself by listing local files, listing S3 objects, comparing them, and then uploading or deleting as needed.

Define What "Sync" Means

Before writing any code, decide on the policy. A sync tool usually answers four questions:

  • Should missing local files be uploaded?
  • Should changed local files overwrite remote objects?
  • Should remote objects that no longer exist locally be deleted?
  • How are local paths converted into S3 object keys?

Without those rules, "sync" is just a vague label. The SDK gives you S3 operations, not the policy layer.

Uploading a Single File

The modern Node.js choice is AWS SDK v3. A single-file upload is straightforward:

javascript
1import { readFile } from "node:fs/promises";
2import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
3
4const s3 = new S3Client({ region: "us-east-1" });
5
6async function uploadFile(bucket, key, filePath) {
7  const body = await readFile(filePath);
8
9  await s3.send(
10    new PutObjectCommand({
11      Bucket: bucket,
12      Key: key,
13      Body: body,
14    })
15  );
16}
17
18await uploadFile("my-bucket", "docs/readme.txt", "./docs/readme.txt");

That is only one building block. A sync feature needs discovery and comparison logic around it.

Listing Remote Objects

To compare local state with S3, first list the remote objects for the prefix you care about.

javascript
1import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
2
3const s3 = new S3Client({ region: "us-east-1" });
4
5async function listRemoteObjects(bucket, prefix = "") {
6  const objects = [];
7  let continuationToken;
8
9  do {
10    const response = await s3.send(
11      new ListObjectsV2Command({
12        Bucket: bucket,
13        Prefix: prefix,
14        ContinuationToken: continuationToken,
15      })
16    );
17
18    for (const item of response.Contents ?? []) {
19      objects.push({
20        key: item.Key,
21        size: item.Size,
22        modified: item.LastModified,
23      });
24    }
25
26    continuationToken = response.NextContinuationToken;
27  } while (continuationToken);
28
29  return objects;
30}

Pagination matters. A script that works for twenty files but ignores continuation tokens will silently fail on larger buckets.

Walking the Local Directory

You also need a local inventory. Node's filesystem APIs can walk the directory tree and generate relative paths that become S3 keys.

javascript
1import { readdir, stat } from "node:fs/promises";
2import path from "node:path";
3
4async function walk(dir, baseDir = dir) {
5  const entries = await readdir(dir, { withFileTypes: true });
6  const files = [];
7
8  for (const entry of entries) {
9    const fullPath = path.join(dir, entry.name);
10
11    if (entry.isDirectory()) {
12      files.push(...await walk(fullPath, baseDir));
13    } else {
14      const fileStat = await stat(fullPath);
15      files.push({
16        path: fullPath,
17        key: path.relative(baseDir, fullPath).replaceAll(path.sep, "/"),
18        size: fileStat.size,
19      });
20    }
21  }
22
23  return files;
24}

This preserves the directory structure by turning relative file paths into S3 keys such as images/logo.png.

Comparing Local and Remote State

A basic one-way sync can start with a size comparison. If the key does not exist remotely, or the size differs, upload the local file.

javascript
1async function syncLocalToS3(bucket, localDir) {
2  const localFiles = await walk(localDir);
3  const remoteObjects = await listRemoteObjects(bucket);
4  const remoteMap = new Map(remoteObjects.map((item) => [item.key, item]));
5
6  for (const file of localFiles) {
7    const remote = remoteMap.get(file.key);
8    const needsUpload = !remote || remote.size !== file.size;
9
10    if (needsUpload) {
11      await uploadFile(bucket, file.key, file.path);
12      console.log(`Uploaded ${file.key}`);
13    }
14  }
15}

Size comparison is simple, not perfect. A serious sync tool may also compare checksums, timestamps, or even metadata. Still, size-only comparison is a good starting point for a custom application workflow.

Optional Delete Logic

If you want mirror semantics, delete remote keys that no longer exist locally.

javascript
1import { DeleteObjectCommand } from "@aws-sdk/client-s3";
2
3async function deleteRemoteFile(bucket, key) {
4  await s3.send(
5    new DeleteObjectCommand({
6      Bucket: bucket,
7      Key: key,
8    })
9  );
10}

Deletion is the risky part. Many teams deliberately ship upload-only sync first, then add delete mode only after the comparison logic is well tested.

Common Pitfalls

  • Assuming the JavaScript SDK has a direct equivalent to aws s3 sync. It does not.
  • Ignoring pagination when listing S3 objects.
  • Comparing only filenames instead of full relative paths that map to keys.
  • Writing delete logic too early and removing objects unintentionally.
  • Hardcoding credentials instead of using the AWS credential chain, environment variables, or IAM roles.

Summary

  • S3 sync in Node.js is something you build from list, compare, upload, and optional delete operations.
  • AWS SDK v3 gives you the primitives, not a one-call sync feature.
  • Preserve relative paths carefully when converting files into S3 object keys.
  • Handle pagination and use a clear comparison strategy for changed files.
  • Start with upload-only behavior unless you are confident your delete rules are correct.

Course illustration
Course illustration

All Rights Reserved.