Bun Was Faster. I Still Kept the AWS SDK.

I ran a benchmark that should have ended the argument.

One of my sites runs on Bun 1.4 and stores files in Cloudflare R2, which exposes an S3-compatible API. The application still used the AWS SDK for object reads, writes, metadata checks, deletes, and presigned upload URLs.

Bun has a native S3Client. Replacing the AWS packages looked like exactly the kind of cleanup I wanted: fewer dependencies, less memory, and less code between the application and the runtime.

Then I measured it.

For 10,000 presigned URLs, Bun’s native client finished in about 46 milliseconds. The AWS SDK took about 1,464 milliseconds.

After loading the client, the Bun process used roughly 12.3 MB of RSS compared with 40.9 MB for the AWS path. After the full run, it was about 19.5 MB versus 97 MB.

Removing the AWS client and presigner would also remove 25 AWS and Smithy packages from the lockfile.

Faster. Smaller. Fewer dependencies. Easy decision, right?

I kept the AWS SDK.

The Benchmark Was Real, but Incomplete

These numbers came from a single local run on macOS using Bun 1.4. They aren’t a statistically rigorous performance study, and presigning is offline CPU work. No object storage request happens while the URL is generated.

Still, the difference was large enough to matter. Bun’s native implementation was doing far less work.

The problem was not performance. The problem was behavioral parity.

An S3 client in this application does more than produce a valid signature. It has to preserve the exact metadata and security rules that the rest of the upload pipeline expects.

Two details stopped the migration.

Cache-Control Disappeared on Writes

The application writes two kinds of public objects:

  • Generated image variants with a one-year cache lifetime
  • Versioned static assets with a one-year, immutable cache policy

Those are stored as object metadata through the Cache-Control header. Cloudflare R2 supports that metadata through its S3-compatible API.

Bun 1.4’s S3 client exposes a fixed set of metadata options: content type, content encoding, and content disposition. It doesn’t take arbitrary request headers, and it doesn’t take Cache-Control.

I wanted to verify the behavior rather than infer it from the type definitions, so I pointed the client at a local HTTP capture server and inspected the outgoing PUT request.

This correctly emitted Content-Type:

await client.write("image.png", bytes, {
  type: "image/png",
});

Adding a cacheControl property did nothing. Passing a Response with a Cache-Control header did nothing. In both cases, the outgoing request omitted the header.

The upload would work, but the stored object wouldn’t have the cache policy the application depends on. That’s not a drop-in replacement.

The Presigned URL Signed the Wrong Thing

The second blocker was more subtle.

The browser uploads files directly to object storage using a short-lived presigned PUT URL. If the server approves an image/png upload, the signature should require the browser to send Content-Type: image/png.

Bun’s presigner accepts a type option:

client.presign("uploads/image.png", {
  method: "PUT",
  expiresIn: 300,
  type: "image/png",
});

That looks right until you inspect the URL.

The generated query contained response-content-type=image/png, while X-Amz-SignedHeaders contained only host. The type affected response metadata. It didn’t require the upload request to contain a matching Content-Type header.

Bun 1.4 doesn’t expose an equivalent to the AWS presigner’s custom signableHeaders option. So I could generate a working PUT URL, but I couldn’t express the security contract I needed.

Again, it worked. It just didn’t do the same job.

The Evaluation Found a Bug in the Existing Code

This part was worth the whole exercise.

The existing AWS code created a PUT command with ContentType: "image/png", and the surrounding comments said the header was signed. The tests checked that ContentType was present on the command.

They never inspected the final URL.

The AWS JavaScript presigner does not sign Content-Type by default. Its own documentation says to opt in with signableHeaders:

const uploadUrl = await getSignedUrl(client, command, {
  expiresIn: 300,
  signableHeaders: new Set(["content-type"]),
});

Once I generated a real URL with dummy credentials, the gap was obvious. Before the fix, X-Amz-SignedHeaders contained only host. After the fix, it contained both host and content-type.

I replaced the mocked-only assertion with an offline contract test that generates the actual URL and checks the signed headers and five-minute expiration.

So the failed migration still improved the application. It corrected a security assumption that the old test suite had been politely agreeing with.

What Would Make Me Reconsider?

This is a no for Bun 1.4, not a no forever.

I will revisit the native client when all of these are true:

  1. S3 writes can send arbitrary object metadata, including Cache-Control.
  2. Presigned PUT URLs can require request headers such as Content-Type.
  3. I can run the full flow against an isolated R2 test prefix: PUT, HEAD, read, list, delete, CORS, content type, and cache metadata.

The performance upside is sitting there waiting. A 32x difference in this micro-benchmark and dozens fewer dependency packages are both compelling.

But performance comes after correctness. A faster client that silently changes cache behavior or weakens an upload constraint isn’t an optimization. It’s a regression with good benchmark numbers.

I wanted the native implementation to win. This time, the boring dependency stayed.

Sources

I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

Bun javascript Performance cloudflare Tooling Aws