Migrating from Bun

Bun's Web-standard surface carries over unchanged. What moves is the Bun.* namespace: each ambient global becomes an explicit runtime: import.

1. File handling (Bun.file, Bun.write)

JavaScript
// ❌ Bun
const text = await Bun.file("./data.txt").text();
const json = await Bun.file("./data.json").json();
await Bun.write("./out.txt", "data");

// ✅ esrun
import { file, write } from "runtime:fs";

const text = await file("./data.txt").text();
const json = await file("./data.json").json();
await write("./out.txt", "data");

2. Subprocesses (Bun.spawn, Bun.$)

JavaScript
// ❌ Bun
const proc = Bun.spawn(["git", "rev-parse", "HEAD"]);
const text = await new Response(proc.stdout).text();

// ✅ esrun
import { Command } from "runtime:system";

const { stdout } = await new Command("git", {
  args: ["rev-parse", "HEAD"],
}).output();

const text = new TextDecoder().decode(stdout).trim();

3. Hashing and passwords (Bun.hash, Bun.CryptoHasher, Bun.password)

JavaScript
// ❌ Bun
const key = Bun.hash(data);
const digest = new Bun.CryptoHasher("sha256").update("data").digest("hex");
const stored = await Bun.password.hash(input);

// ✅ esrun
import { hash, Hasher, password } from "runtime:hashing";

const key = hash("xxhash64", data);
const digest = new Hasher("sha256").update("data").digest("hex");
const stored = await password.hash(input);

The same classes, imported from runtime:http. request.cookies works the same way in serve(): changes are sent with the response.

JavaScript
// ❌ Bun
Bun.serve({ fetch(req) { req.cookies.set("theme", "dark"); return new Response("ok"); } });
const cookie = new Bun.Cookie("sid", id, { httpOnly: true });

// ✅ esrun
import { serve, Cookie } from "runtime:http";

serve((req) => { req.cookies.set("theme", "dark"); return new Response("ok"); });
const cookie = new Cookie("sid", id, { httpOnly: true });

Output matches Bun's, with one intentional difference: a cookie a browser would drop throws instead of being sent. That covers:

  • SameSite=None or Partitioned without secure;

  • a __Host-/__Secure- cookie that breaks its prefix rules;

  • a fractional maxAge.

When you delete a __Host- or __Secure- cookie, pass { secure: true }. Otherwise the deletion is refused, where in Bun the browser would have ignored it. Cookie.parse also decodes the value, as CookieMap does in both runtimes.

5. Async context (node:async_hooks)

Bun's AsyncLocalStorage becomes runtime:context — a rename, with no async_hooks shim.

JavaScript
// ❌ Bun
import { AsyncLocalStorage } from "node:async_hooks";
const store = new AsyncLocalStorage();
store.run({ tenant }, () => handle(req));
store.getStore().tenant;

// ✅ esrun
import { createContext } from "runtime:context";
const store = createContext({ name: "request" });
store.run({ tenant }, () => handle(req));
store.get().tenant;
Bunesrun
new AsyncLocalStorage()createContext({ name })
.getStore().get()
.run(store, fn).run(value, fn)
.enterWith()Removed — snapshot()
AsyncResourceRemoved

6. Observability and process metrics

JavaScript
// ❌ Bun
process.memoryUsage().heapUsed;
process.cpuUsage().user;      // microseconds
import { heapStats } from "bun:jsc";

// ✅ esrun
import { memoryUsage, cpuTime, uptime } from "runtime:process";
import { metrics, subscribe } from "runtime:diagnostics";

memoryUsage().heapUsed;
cpuTime();                    // milliseconds, this agent's thread
metrics().gc.pauseMs.p99;     // needs --allow-diagnostics
BunesrunNeeds
process.memoryUsage().heapUsedmemoryUsage().heapUsed—
process.memoryUsage().rssmetrics().process.rssdiagnostics
process.cpuUsage() { user, system }cpuTime() — one total—
bun:jsc heapStats()metrics().gcdiagnostics
Bun.gc(true)Removed — no forced collection—
--console-depth, ad-hoc tracingsubscribe(filter, onBatch)diagnostics
OpenTelemetry SDKesrun --otel=<url>—

Units and scope both change: milliseconds rather than microseconds, and the per-agent figures describe the calling agent rather than the process — which is what makes them answerable inside a worker. The process-wide ones are in metrics(), behind --allow-diagnostics.

See the Request context and Observing the runtime guides.

7. Images (Bun.Image)

runtime:images has Bun.Image's chain: the same transform names, the same format methods and the same terminals, so most pipelines move with an import and a rename.

JavaScript
// ❌ Bun
const thumb = await Bun.file("photo.jpg").image()
  .resize(400, 400, { fit: "inside" })
  .webp({ quality: 80 })
  .bytes();
await new Bun.Image(upload).resize(1600).write("public/large.avif");

// ✅ esrun
import { Image } from "runtime:images";
import { file } from "runtime:fs";

const thumb = await new Image(file("photo.jpg"))
  .resize(400, 400, { fit: "inside" })
  .webp({ quality: 80 })
  .bytes();
await new Image(upload).resize(1600).avif().write(file("public/large.avif"));

What carries over unchanged

resize(width, height?, { fit, filter, withoutEnlargement }) with "fill" and "inside"; rotate, flip, flop; modulate({ brightness, saturation }); jpeg, png, webp and avif with quality, and webp({ lossless }); bytes() and blob(); the maxPixels and autoOrient options with the same defaults; and the error codes ERR_IMAGE_DECODE_FAILED, ERR_IMAGE_FORMAT_UNSUPPORTED and ERR_IMAGE_TOO_MANY_PIXELS.

What changes

BunesrunWhy
new Bun.Image("photo.jpg")new Image(file("photo.jpg"))A path string is a TypeError. A file() is read through runtime:fs, so --allow-read and the project jail apply. Bun's docs warn that a path argument is an arbitrary-file-read primitive.
Bun.file(p).image()new Image(file(p))
img.write("out.webp")img.webp().write(file("out.webp"))The format is the one chained, or else the source's. The extension is never read, so write(file("out.webp")) with no format method writes the source's format. Writing needs --allow-write.
img.buffer()img.bytes()There is no Buffer.
img.toBase64()(await img.bytes()).toBase64()
img.dataurl()`data:image/webp;base64,${(await img.webp().bytes()).toBase64()}`
new Response(img)new Response(await img.bytes(), { headers: { "content-type": "image/webp" } })An Image is not a body yet.
img.width, img.height after a terminal(await img.metadata()).widthmetadata() works at any point in a chain, before anything is encoded.
metadata() → { width, height, format }{ width, height, format, pixelFormat, hasAlpha, animation }A superset.

Not available

BunInstead
heic(), HEIC inputNo HEIC codec. Convert to AVIF or JPEG before upload.
placeholder()No ThumbHash yet. A tiny resize(16).webp() inlined as a data URL is the nearest.
jpeg({ progressive })Baseline JPEG only. The option is a TypeError.
png({ compressionLevel, palette, colors, dither })A TypeError. gif() writes a palette image.
mks2013, mks2021 filters"lanczos3" (the default) or "mitchell".
Bun.Image.fromClipboard() and the clipboard helpersNone. A server has no clipboard.
Bun.Image.backendNone. There is one backend.

Added

  • The "outside", "cover" and "contain" fits, and background for "contain".

  • crop, blur, sharpen, flatten, grayscale, extractChannel and modulate({ hue }).

  • gif() and tiff() output.

  • AVIF and TIFF on Linux, where Bun has neither.

  • The toSrgb and animated options.

Last updated on
Edit this page