Somewhere in the middle of building KilnForge, we wrote a fully working feature, tested it, confirmed it was correct — and then didn't ship it. On purpose. That's not a typo. It's the most honest answer we have to what this post is actually about.
Key Takeaways
- We hand-rolled a byte-level EXIF orientation parser, then discovered
Bun.Imagealready does that internally — shipping ours would have double-rotated every tagged photo- Three other "obvious"
sharpfeatures don't exist natively inBun.Image: a BMP/GIF encoder, afit: coverresize mode, and arbitrary-angle rotation — each needed a real, hand-rolled fallback- We also found a bug in our own reproducible-build proof: 3 compiles produced 3 different hashes, traced to 2 differing bytes out of ~89MB — our own filename, not the build
- Measured, not estimated: KilnForge runs 1.81x–10.22x faster than
sharpon resize/rotate/convert, and 41.73x faster thantarat unpacking archives — but 1.22x slower at packing them, published honestly either way- 263 tests, 6,463 assertions, all passing — including differential tests run against real
sharpand realtaroutput, not synthetic stand-ins
How We Verified All of This
Every claim in this post comes from one of three concrete checks, not from reading a changelog:
- Direct API probing — calling the actual method on the actual installed Bun binary (1.4.0) and recording what happens, success or exception, before writing a single line of pipeline code around it.
-
Pixel-level differential comparison — comparing our output against
Bun.Image's own native output, byte for byte, on identical source images (used to catch the rotation off-by-half-pixel bug and to confirm the EXIF auto-rotation behavior). -
Real reference-implementation testing — running our output through the same test suite against real
sharpand realtaroutput on identical inputs, not synthetic stand-ins, via golden-corpus tests that ship in the repo.
All of it is reproducible: bun test runs all 263 tests, including the golden-corpus suite, from a clean checkout.
The Package We'd Normally Install
If you're processing images on a server, you reach for sharp. It's fast, it's mature, and underneath it's a native addon wrapping libvips — which means a real C build toolchain has to exist on whatever machine installs it. If you're bundling files, you reach for tar. Both are exactly the kind of dependency a zero-dependency hackathon exists to prove you don't strictly need.
So we set out to replace both, using Bun's own native Bun.Image and Bun.Archive APIs — no sharp, no tar, no native build step. dependencies: {} in package.json, checked mechanically on every build via a script that scans every import in src/** and refuses to let either package sneak in. This is our entry for a zero-dependency hackathon that scores exactly that kind of mechanical proof over a README claim.
That part sounds simple in a pitch. It wasn't simple in practice, because Bun.Image and Bun.Archive are both very new APIs — days old at the point we started. The write-ups we found while planning disagreed with each other on basic questions, color-profile survival being the clearest example: some sources claimed ICC profiles survive a transcode, others claimed they get stripped. We didn't pick a side. We built a minimal PNG carrying a real color-profile chunk, ran it through Bun.Image's own encoder, and checked the output bytes directly. The chunk was gone. One test resolved a contradiction that no amount of re-reading either source would have.
We decided early that we wouldn't build anything on top of an assumption we hadn't personally verified against the real, installed Bun binary. That decision is the reason this post has a story to tell instead of just a features list.
What We Built By Hand
Before writing any pipeline code, we wrote a Foundation Verification Harness — a script that runs real probes against the actual Bun runtime and records what it finds, separate from whether the probe itself crashed:
async function probe(
results: ProbeResult[],
name: string,
fn: () => Promise<{ finding: boolean; detail: string }>,
): Promise<boolean> {
try {
const { finding, detail } = await fn();
results.push({ name, ok: true, finding, detail });
return finding;
} catch (err) {
results.push({
name, ok: false, finding: false,
detail: `threw unexpectedly: ${err}`,
});
return false;
}
}
ok answers "did this probe run without crashing." finding answers "what did it actually discover." Those are different questions — a probe can run perfectly cleanly and still report a negative finding, and that's a successful, informative run, not a failure. Every architectural decision downstream got pinned to what this harness actually measured, not to what we assumed going in.
It's a good thing we did, because several of those assumptions were wrong.
Bun.Image has no BMP or GIF encoder
Our original plan was to choose between a PNG channel and a BMP channel for reading and writing raw pixels. Real inspection of Bun.Image.prototype showed no .bmp() and no .gif() method at all — both formats are decode-only. PNG wasn't the preferred channel. It was the only one. That meant hand-rolling a real PNG encoder/decoder from scratch: chunk parsing, node:zlib for the inflate/deflate, scanline filter/unfilter, our own CRC32 — just to get a working raw-pixel round trip.
There's no native fit: cover resize mode
Bun.Image.resize() only exposes fill (stretch to exact dimensions) and inside (aspect-preserving, fits within the box). There's no crop-to-fill. We built it ourselves — compute the overscale factor that would cover the target box while preserving aspect ratio, resize with the native fill mode, then center-crop the raw pixel buffer down to size:
async function resizeCover(
image: Bun.Image,
targetWidth: number,
targetHeight: number,
): Promise<Bun.Image> {
const meta = await image.metadata();
const scale = Math.max(
targetWidth / meta.width,
targetHeight / meta.height,
);
const overW = Math.round(meta.width * scale);
const overH = Math.round(meta.height * scale);
const resized = image.resize(overW, overH, { fit: "fill" });
const rgba = await toRGBA(resized);
const cropped = cropCenter(rgba, targetWidth, targetHeight);
return loadImage(fromRGBA(cropped));
}
Arbitrary-angle rotation isn't native either
Bun.Image.rotate(45) genuinely throws — "only multiples of 90 are supported" — confirmed directly, not assumed from a changelog. We wrote our own rotation via an inverse coordinate transform, and it had a bug worth mentioning: the first version was wrong by exactly half a pixel, because it treated a pixel's raw integer index as its coordinate instead of the center of the cell that pixel occupies:
// Pixel index i occupies the continuous interval [i, i+1) with its
// CENTER at i+0.5 — sampling must rotate from that center, not from
// the raw integer index, or the whole mapping is off by half a pixel
// (verified by hand against Bun.Image's native rotate(90), which has
// an exact, unambiguous discrete answer to check against).
const relY = dy + 0.5 - destCenterY;
const relX = dx + 0.5 - destCenterX;
const srcX = relX * cos + relY * sin + srcCenterX;
const srcY = -relX * sin + relY * cos + srcCenterY;
That one-line fix (+ 0.5) took a 58% pixel mismatch against Bun.Image's own native rotate(90) — used as a cross-check oracle for the 90° case, which is native — down to zero mismatches across every test we threw at it.
The Feature We Never Shipped
Here's the part we're actually proudest of, and it's not a feature that shipped — it's one that didn't.
We built a complete, tested, byte-level EXIF orientation parser by hand: reads the JPEG APP1 marker, walks the TIFF structure, handles both byte orders (Intel and Motorola), covers all eight orientation values, and converts the result into the correct rotate-and-flip transform. It worked. It passed its own test suite standalone. Every image-processing library we've ever used needs this, so naturally we assumed Bun.Image would need it wired in too.
Before wiring it into the live request pipeline, we ran one more check — because by this point "test it before you trust it" was the whole method, not just something we did once at the start. We fed a real EXIF-tagged photo straight into Bun.Image with no correction of our own, and checked what came out.
Bun.Image had already rotated it. Correctly. On its own, during decode.
We confirmed it two ways: metadata() reported swapped width and height immediately after decode, and a direct pixel comparison against a manual native .rotate(90) on the same source came back with zero mismatches. Bun.Image auto-applies EXIF orientation internally — nobody advertised that clearly anywhere we'd read, but the runtime doesn't lie the way documentation sometimes does.
If we'd shipped the original plan — wiring our own parser into the request pipeline on top of that — every single EXIF-tagged photo uploaded to the service would have been rotated twice. Once by Bun.Image, invisibly, during decode. Once by us, right after. The bug wouldn't have been subtle. It would have been every photo, every time. And it would have passed casual testing anyway — anyone checking with an un-rotated test image would have seen nothing wrong.
The parser still exists in the codebase today, fully tested, completely real — just deliberately disconnected from the request pipeline, with a comment explaining exactly why:
// NOTE: this module deliberately does NOT auto-apply EXIF orientation
// before handing bytes to Bun.Image. Empirical testing (see
// src/image/exif.ts's module comment) found Bun.Image already applies
// EXIF orientation correction internally during decode — confirmed via
// metadata() reporting swapped dimensions immediately and 0 pixel
// mismatches against a manual native .rotate(90) on the same source.
// Calling our own applyOrientation() on top of that would double-rotate
// every EXIF-tagged upload.
That's the whole point of this post, if it has one: the win here wasn't building the parser. It was catching, before a single real user ever touched it, that building it into the pipeline would have been the bug.
The Bug in Our Own Proof
We found one more bug the same way, and it wasn't in the product — it was in our own evidence.
One of the bonuses this hackathon offers is a reproducible build: compile the same source three separate times, hash each binary, prove they're identical. We ran it. We got three different SHA-256 hashes. By the letter of the check, that's a fail.
Instead of writing that down as an honest limitation and moving on, we diffed the three binaries byte for byte. Out of roughly 89 megabytes, exactly two bytes were different. One of them was plain, readable text — our own --outfile filename, which Bun's compiler embeds into the binary as an internal module path. We'd given each of the three test builds a different output filename, so the test introduced the only variance it then measured.
Fixed it by using an identical filename across three separate temp directories instead, and reran. Byte-for-byte identical. Verified with cmp, zero differences. The build had been reproducible the entire time — our test of it hadn't been.
The Real Numbers
Bun's own release coverage cites roughly 1.2x–1.4x faster resize/convert performance for Bun.Image versus sharp. Our numbers below are our own independent run, on our own fixtures — not a substitute for that public figure, cited alongside it rather than instead of it.
| Operation | KilnForge (mean) | sharp (mean) | Result |
|---|---|---|---|
| resize 64×48 → 20×20 (fill) | 0.23ms | 2.35ms | 10.22x faster |
| rotate 90° (20×40) | 0.11ms | 0.99ms | 9.00x faster |
| convert JPEG → WebP (q85) | 0.30ms | 1.15ms | 3.83x faster |
| resize 256×256 → 30×30 (cover) | 1.87ms | 3.39ms | 1.81x faster |
| Operation (archive) | KilnForge (mean) | tar (mean) | Result |
|---|---|---|---|
| unpack | 0.22ms | 9.18ms | 41.73x faster |
| pack (5 files × 5KB) | 0.11ms | 0.09ms | 1.22x slower |
We didn't publish only the numbers that flatter us. On the archive side, unpacking a tarball through Bun.Archive runs 41.73x faster than tar — mostly because tar's own API extracts to real disk I/O per file while ours stays in memory, a genuine architectural difference. But packing a tarball is 1.22x slower with our implementation than with tar. That's a small, real margin on a handful of small files, and we're stating it instead of quietly leaving it out of the chart.
None of these numbers are estimates. They come from real differential tests — our output compared directly against real sharp and real tar output on the same inputs, both kept as devDependencies used only by the benchmark and test scripts, never imported by anything that ships. A script that scans every import in the actual service code confirms that split mechanically, not by us promising it. All 263 tests pass, 6,463 assertions total, across 34 files.
None of this is worth much without seeing it actually run. Here's the real thing — server up from a clean checkout, resize/watermark/convert//batch all live, the Foundation Verification Harness's own output on camera:
What Going Zero-Dependency Actually Costs
The honest answer to "what did it take to replace sharp and tar" isn't "we're faster" — though on most operations, measured fairly, we are. The honest answer is that a mature library like sharp has quietly handled a decade of edge cases you never think about until you're the one who has to handle them: EXIF orientation, crop-to-fill resizing, arbitrary rotation angles, alpha-safe format conversion, decompression-bomb protection. None of that goes away when you drop the dependency. It just moves onto your desk.
The only way we found to know we'd actually covered those edge cases — instead of just assuming our replacement code was equivalent — was to keep testing against the real runtime and the real reference implementation, the whole way through. That's what caught the EXIF bug before it shipped. That's what caught the reproducibility bug in our own proof. Neither of those would show up in a features list. Both of them are the actual work.
We're The Vighnahartas — a team entry for Track F of the Zero Dependency Hackathon (Aug 28–31, 2026). KilnForge is the project this post is about. Everything referenced here — the code, the tests, the benchmark scripts — is in the public repo, built on Bun 1.4.0.

Top comments (0)