Bun
-
I Removed Three Dependencies. One of Them Actually Left.
In forty-six minutes one morning I opened three pull requests, and every one of them deleted a dependency.
The commits are timestamped, so I can be precise about it:
- 07:33 replace Sharp transforms with
Bun.Image - 07:55 enforce signed upload content type (the AWS SDK removal that didn’t happen)
- 08:19 adopt
Bun.YAMLfor sync
Two merged with the dependency gone from
package.json. One I abandoned on purpose. That’s a decent morning, and it’s how I described it to myself at the time.Then I went back and looked at the lockfile.
Only one of those packages actually left.
Sharp Is Still Sitting in node_modules Right Now
The image one looked like the clean win. My media pipeline generates thumbnail, medium, and large variants, plus WebP versions, and it used Sharp to do it. Bun 1.4 ships a native
Bun.Image, so I rewrotesrc/lib/media.tsonto it and pulled"sharp": "^0.35.3"out of my dependencies.The benchmark was encouraging. Same workload, 2400×1600 inputs, separate processes,
/usr/bin/time -lon macOS:Input Sharp peak RSS Bun.Image peak RSS Elapsed JPEG 218 MB 75 MB 224ms → 207ms PNG 185 MB 80 MB 389ms → 549ms WebP 177 MB 77 MB 238ms → 180ms Memory is not close. Roughly a third of the footprint across all three formats. Speed is a wash, and PNG got noticeably worse, about 40% slower. The encoded output isn’t byte-identical either: JPEG came out 9% larger, PNG 2% smaller, WebP 3% larger. Fine for my purposes, but it’s a re-encode, not a port.
One caveat I want to be honest about, because it undercuts the comparison: Sharp wouldn’t load under Bun 1.4.0 on this machine at all. It failed on a missing
libvips-cpp.8.18.3.dylib. Undernode -e 'import("sharp")'it imported fine. So that table is Sharp-under-Node against Bun.Image-under-Bun, which means it’s measuring two runtimes, not two image libraries.Anyway. I removed Sharp, the tests passed, I merged it.
Sharp is still installed. Here it is:
$ grep -c '"@img/sharp-\|"sharp"' bun.lock 27 $ ls -d node_modules/sharp node_modules/sharpAstro 7 declares Sharp as an optional dependency for its own image integration. My lockfile says so in as many words:
"[email protected]" ... "optionalDependencies": { "sharp": "0.35.2" }So when I deleted my direct dependency, the resolver didn’t drop Sharp. It just stopped hearing my opinion about which version to use, and fell back to Astro’s. Sharp went from 0.35.3 to 0.35.2. My dependency removal was, in lockfile terms, a downgrade.
The One That Actually Left
The YAML change is the boring one and it’s the only real removal.
My blog sync script parsed frontmatter with the
yamlpackage. Bun 1.4 hasBun.YAMLwithparseandstringify, which is the entire surface I was using:const data = Bun.YAML.parse(frontmatter);That’s it.
"yaml": "^2.9.0"came out ofpackage.json, one entry came out ofbun.lock, and nothing else in the tree wanted it. Gone.No benchmark for this one. I didn’t run one, because a dependency that does one thing I can do with a builtin doesn’t need a performance argument to justify deleting it.
The Third One I Talked Myself Out Of
The AWS SDK is the one I wrote about separately. Short version: Bun’s native
S3Clientpresigns URLs about 32x faster and would take 25 packages out of the lockfile, and I kept the AWS SDK anyway, because Bun 1.4 can’t sendCache-Controlon writes and can’t forceContent-Typeinto a presigned URL’s signed headers. Faster, smaller, and not behaviorally equivalent.That one at least failed honestly. I evaluated it, wrote down why not, and the packages are still there because I decided they should be.
Three Words That Mean Three Different Things
Here’s what I actually learned, and it’s a vocabulary problem more than a technical one. “I removed a dependency” turns out to describe at least three unrelated states:
- It’s out of my
package.json. Sharp, yaml. This is the one people mean when they say it, and it’s the weakest of the three. - It’s out of the lockfile and off the disk. Only yaml. Sharp is still there, 27 entries, physically present in
node_modules. - It’s out of
trustedDependencies. This is the one I undersold at the time.esbuildis now the only package in my project allowed to run install lifecycle scripts. Sharp used to be in that list and isn’t anymore.
Number three is the one that actually changed my exposure. Sharp is still on disk, but nothing in my code calls it, and it no longer gets to execute arbitrary code at install time. Number two never happened. Number one is mostly bookkeeping.
I would have told you I removed two dependencies that morning. The lockfile says I removed one, downgraded another, and revoked install-script trust from a package that’s still sitting right there. All three of those are fine outcomes. They’re just not the same outcome, and I had been filing them under one word.
Check your lockfile after you delete something. Transitive optional dependencies do not care what your
package.jsonsays.Sources
- Bun.Image runtime documentation — the image API that replaced Sharp
- Bun.YAML runtime documentation —
parseandstringify, nothing else - Bun 1.4 release announcement — released 2026-08-20
- Astro images guide — Astro’s built-in image service, which is why Sharp stays
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].
- 07:33 replace Sharp transforms with
-
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-Controlheader. 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
cacheControlproperty did nothing. Passing aResponsewith aCache-Controlheader 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/pngupload, the signature should require the browser to sendContent-Type: image/png.Bun’s presigner accepts a
typeoption: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, whileX-Amz-SignedHeaderscontained onlyhost. The type affected response metadata. It didn’t require the upload request to contain a matchingContent-Typeheader.Bun 1.4 doesn’t expose an equivalent to the AWS presigner’s custom
signableHeadersoption. 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 thatContentTypewas present on the command.They never inspected the final URL.
The AWS JavaScript presigner does not sign
Content-Typeby default. Its own documentation says to opt in withsignableHeaders: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-SignedHeaderscontained onlyhost. After the fix, it contained bothhostandcontent-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:
- S3 writes can send arbitrary object metadata, including
Cache-Control. - Presigned PUT URLs can require request headers such as
Content-Type. - 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
- Bun S3 runtime documentation
- AWS SDK for JavaScript v3 S3 presigner documentation
- Cloudflare R2 presigned URL documentation
- Cloudflare R2 S3 API compatibility
- Cloudflare R2 metadata header mapping
- Bun issue #16048: allow for custom S3 headers/query params — open since December 2024
- Bun issue #18016: missing ResponseCacheControl options in s3.presign() — open since March 2025
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].
-
I Benchmarked JSON Parsing in Bun, Node, Rust, and Go
I’m just going to start posting about JSON everyday. Well ok, maybe not every day, but for the next few days at least. Later this week I’ve committed to writing a guide on getting started with CLIs for non-programmers, so stay tuned for that.
This morning I benchmarked JSON parsing across four runtimes: Bun, Node, Rust, and Go.
The Results
- Bun is the overall winner on large files — 307-354 MB/s, beating even Rust’s serde_json for untyped parsing
- Rust wins on small/nested data (225 MB/s small, 327 MB/s nested) due to low overhead
- Node is close behind Bun — V8’s JSON.parse is very optimized
- Go is ~3x slower than the JS runtimes on large payloads (encoding/json is notoriously slow)
- Memory: Bun reports 0 delta (likely GC reclaims before measurement), Rust’s tracking allocator shows the true heap cost (73-96MB), Go uses 52-65MB
Rust’s numbers were the most honest here since the tracking allocator catches everything. We should take Bun result with grain of salt because benchmarking memory in GC’d languages is tricky.
The json parser in v8 in node is the exact same as what is in Chrome…
Here’s the full test results if you want to dig into the numbers yourself.
More JSON content coming soon. You’ve been warned.
-
It’s always some weird networking thing. Switched an internal Astro site from PNPM to Bun last night, didn’t test it before bed. Woke up to port 4321 not binding; dev server wouldn’t start. Turns out it randomly decided to only bind on IPv6 overnight. Had to explicitly tell it to bind on IPv4. Why is it always networking?