Bun 1.4
Bun is the complete toolkit for building and testing full-stack JavaScript and TypeScript applications. If you're new to Bun, you can learn more from the Bun 1.0 blog post.
curl
curl -fsSL https://bun.sh/install | bashpowershell
powershell -c "irm bun.sh/install.ps1 | iex"npm
npm install -g bunbrew
brew install oven-sh/bun/bundocker
docker pull oven/bunBun 1.4 adds +1,517 tests from the Node.js test suite - our biggest jump in Node.js compatibility since Bun 1.0. Bun v1.4 also fixes over 2,900 issues. It reduces idle CPU usage by 5x, reduces memory usage by up to 35%, and starts 50% faster on Linux. It adds Bun.Image, Bun.WebView, Bun.markdown, Bun.cron(), Bun.Terminal, bun run --parallel, bun test --parallel, bun audit fix, bun dedupe, and bun prune. And it rewrites Bun from Zig to Rust.
This post covers everything we've shipped since Bun 1.3.0 (with new to Bun v1.4 tagged).
To upgrade:
bun upgradeNode.js compatibility#
Bun is designed to be a drop-in replacement for Node.js. We've added +1,517 tests from the Node.js test suite to run on every commit of Bun.
`node:http`, `node:fs`, `node:cluster`, `node:timers`, `node:zlib`, `node:vm`, and `node:stream` pass 97% of Node's own tests; `node:quic` 99%; `node:events`, `node:trace_events`, and `node:sqlite` 100%.
Node.js v26.3.0 test suite · passing on every commit
+1,517newly passing tests
▶ play↻ replay▶ play
quic
235 / 237+235
httprewritten
403 / 415+193
fs
349 / 358+133
tls
191 / 224+106
http2rewritten
261 / 280+102
repl
82 / 109+77
stream
254 / 262+61
worker
116 / 157+57
process
160 / 190+45
net
171 / 191+39
https
61 / 65+39
internal
68 / 108+35
cluster
85 / 88+31
trace\_events
30 / 30+30
webcrypto
39 / 50+28
crypto
123 / 135+27
vm
98 / 101+25
webstreams
28 / 38+23
test\_runner
28 / 81+21
module
68 / 93+19
diagnostics\_channel
36 / 69+19
sqlite
18 / 18+18
child\_process
105 / 116+15
inspector
22 / 110+14
Per-module pass counts from Bun’s vendored copy of Node’s `test/parallel` suite, Bun 1.3 → 1.4. The tick marks the 1.3 baseline.
Bun is not 100% compatible with Node.js yet. In practice, much of the existing JavaScript ecosystem just works. You can more closely track Bun's Node.js test suite progress here.
Playwright v1.4.0#
Playwright now runs on Bun: drive a browser with `connectOverCDP()`, run your suite with `playwright test` and a `playwright.config.ts`, open `--ui`, and launch Chromium on Windows.
Next.js 16 v1.3.2#
`bun --bun next build` works on Next.js 16.3 with Turbopack and the React Compiler.
vitest v1.4.0#
vitest runs under Bun, including `--coverage`, with the threads and forks pools.
OpenTelemetry v1.4.0#
OpenTelemetry's http and fs instrumentation export spans, and `shimmer` and `require-in-the-middle` patch bundled code.
dd-trace v1.4.0#
`dd-trace` traces and `@datadog/pprof` profiles continuously; the V8 C++ APIs they link against are implemented.
Additional Node.js compatibility improvements#
Every day, Bun gets closer to 100% Node.js compatibility. More packages now work in Bun without changes:
- Nuxt: `nuxt dev` connects HMR and the Nuxt DevTools.
- testcontainers and dockerode: `container.exec()` works.
- https-proxy-agent and socks-proxy-agent: `http.request()` tunnels through them.
- crawlee: crawls through `proxy-chain`.
- @grpc/grpc-js and ConnectRPC: servers behind Envoy and clients behind AWS ALB work.
- amqplib: connects to RabbitMQ.
- @aws-sdk/client-s3: streaming uploads work.
- TypeORM: starts with the decorator settings in your `tsconfig.json`.
- nock: intercepts `http` and `https` requests.
- Fastify `inject()` and light-my-request: work.
- happy-dom: no longer breaks `console.log`.
- piscina: runs.
- `worker_threads`: `resourceLimits`, `stdout`, `stderr`, and `eval` options.
- ws: `'upgrade'` and `'unexpected-response'` events.
- `socket.upgradeTLS({ isServer: true })`: server-side STARTTLS.
- `node:cluster`: shares listening sockets between workers.
- `node:repl`, `node:trace_events`, `node:domain`: implemented.
- `bun --cpu-prof` writes a `.cpuprofile`. Open it in Chrome DevTools or VS Code.
- `bun --heap-prof` writes a V8-compatible `.heapsnapshot`. Open it in Chrome DevTools.
- `node:inspector`: a `Session` can start and stop a CPU profile while the app runs, with `Profiler.start` and `Profiler.stop`. #25939
- Datadog: `dd-trace` traces requests and `@datadog/pprof` profiles CPU continuously. #36747
- OpenTelemetry: the `@opentelemetry/instrumentation-http` and `@opentelemetry/instrumentation-fs` packages from npm work with `node:http` and `node:fs` in Bun. The `shimmer` and `require-in-the-middle` packages they depend on can patch bundled code.
- Async stack traces: an error from `fs.promises`, `fetch()`, S3, DNS, or crypto points at the `await` in your code, not at native frames.
New Node.js APIs in Bun:
Production#
Bun v1.4 uses less memory, less CPU, and starts faster.
Until Bun v1.4, Bun used two memory allocators - JavaScriptCore's libpas allocator and mimalloc. JavaScriptCore in Bun now uses mimalloc (improving memory reclamation), and we've extended mimalloc with features like partial page clearing, a scavenger thread that frees memory while JavaScript idles, and improved lazy zeroing.
CPU usage#
For Claude Code, a large long-running application built on Bun, production CPU usage dropped by 2×: p99 from 24% to 10%, p50 from 5.8% to 2.5%.
For a small "hello world" app, idle CPU usage drops by 5x.
We did this by optimizing when garbage collector timers request a GC, switching how JavaScriptCore visits Strong roots from a linked list to a linked list of segmented arrays, and reducing the number of `futex` calls, along with the mimalloc changes mentioned earlier.
Memory usage#
Applications using HTTP servers with Bun should see a 13% - 48% memory usage reduction.
Peak memory under load (1,000,000 requests with 64 connections; 100,000 for Next.js and Vite):
| Server | Bun 1.4 | Bun 1.3 | Node.js 26 | Δ vs Bun 1.3 | | --------------- | ---------- | ------- | ---------- | ------------ | | fastify | 120 MB | 233 MB | 156 MB | −48% | | Express | 92 MB | 169 MB | 145 MB | −46% | | node:http | 81 MB | 135 MB | 107 MB | −40% | | Elysia | 55 MB | 91 MB | n/a | −40% | | Next.js | 285 MB | 397 MB | 342 MB | −28% | | Bun.serve | 36 MB | 45 MB | n/a | −20% | | Vite dev server | 233 MB | 268 MB | 214 MB | −13% |
Server-side rendering with Next.js gets a bigger reduction. On a common App Router pattern that grew without bound in 1.3 (`React.cache` \+ `no-store` fetch in a dynamic route), Bun 1.4 settles at 238 MB over 4,000 pages, under Node's 410 MB.
Next.js App Router SSR, 4,000 pages: Bun 1.4 settles at 238 MB, under Node's 410 MB.
Startup#
On Windows, Bun starts 2.5× faster.
| hello.js on Windows | Bun 1.4 | Bun 1.3.14 | Node.js 26 | | ------------------- | ----------- | ---------- | ---------- | | Startup time | 15.5 ms | 39.0 ms | 40.1 ms | | Peak memory | 16.8 MB | 46.5 MB | 32.5 MB |
On Linux, Bun starts 2× faster and uses less than half the memory.
| hello.js on Linux | Bun 1.4 | Bun 1.3 | Node.js 26 | | ----------------- | ----------- | ------- | ---------- | | Startup time | 5.1 ms | 10.9 ms | 27.2 ms | | Peak memory | 14.6 MB | 33.0 MB | 44.5 MB |
Binary size#
On Linux and Windows, Bun gets up to 17% smaller.
| | Bun 1.4 | Bun 1.3.14 | | ------------- | ----------- | ----------- | | Linux x64 | 77.0 MB | 88.5 MB | | Linux arm64 | 76.8 MB | 87.6 MB | | Windows x64 | 84.8 MB | 93.9 MB | | Windows arm64 | 75.1 MB | 90.2 MB | | macOS arm64 | 61.2 MB | 60.2 MB | | macOS x64 | 66.6 MB | 66.0 MB |
macOS binaries are about 1 MB larger.
Observability#
The tools you already use work with Bun 1.4.
Some of it is new in Bun.
#### `--cpu-prof-md`
`--cpu-prof-md` writes a CPU profile as Markdown, so you can find the hot function from a terminal: the top functions by self time, the call tree, and who calls whom. Read it over SSH, `grep` it, paste it into a bug report, or hand it to an LLM.
bun --cpu-prof-md ./app.ts# CPU Profile| Duration | Samples | Interval | Functions |
| -------- | ------- | -------- | --------- |
| 304.9ms | 279 | 1.0ms | 6 |
Top 10: `tokenize` 39.1%, `escapeHtml` 25.6%, `escapeHtml` 19.3%, `render` 15.8%
Hot Functions (Self Time)
| Self% | Self | Total% | Total | Function | Location |
| ----: | ------: | -----: | ------: | ------------ | ----------- |
| 39.1% | 119.4ms | 39.1% | 119.4ms | `tokenize` | `app.ts:14` |
| 25.6% | 78.1ms | 25.6% | 78.1ms | `escapeHtml` | `app.ts:5` |
| 19.3% | 58.9ms | 19.3% | 58.9ms | `escapeHtml` | `app.ts:4` |
| 15.8% | 48.3ms | 60.8% | 185.3ms | `render` | `app.ts:21` |
Call Tree (Total Time)
| Total% | Total | Self% | Self | Function | Location |
| -----: | ------: | ----: | ------: | ------------ | ----------- |
| 60.8% | 185.3ms | 15.8% | 48.3ms | `render` | `app.ts:21` |
| 60.8% | 185.3ms | 0.0% | 0us | `(module)` | `app.ts:30` |
| 39.1% | 119.4ms | 39.1% | 119.4ms | `tokenize` | `app.ts:14` |
| 39.1% | 119.4ms | 0.0% | 0us | `(module)` | `app.ts:28` |
| 25.6% | 78.1ms | 25.6% | 78.1ms | `escapeHtml` | `app.ts:5` |
| 19.3% | 58.9ms | 19.3% | 58.9ms | `escapeHtml` | `app.ts:4` |
Function Details
`tokenize`
`app.ts:14` | Self: 39.1% (119.4ms) | Total: 39.1% (119.4ms) | Samples: 109
Called by:
`(module)` (109) `escapeHtml`
`app.ts:5` | Self: 25.6% (78.1ms) | Total: 25.6% (78.1ms) | Samples: 72
Called by:
`render` (72) `escapeHtml`
`app.ts:4` | Self: 19.3% (58.9ms) | Total: 19.3% (58.9ms) | Samples: 54
Called by:
`render` (54) `render`
`app.ts:21` | Self: 15.8% (48.3ms) | Total: 60.8% (185.3ms) | Samples: 44
Called by:
`(module)` (170) Calls:
`escapeHtml` (72)
`escapeHtml` (54) `(module)`
`app.ts:28` | Self: 0.0% (0us) | Total: 39.1% (119.4ms) | Samples: 0
Expand
`BUN_CPU_PROFILE=1` turns on the CPU profiler for a process you cannot pass flags to, like a worker started by a framework.
#### `--heap-prof-md`
`--heap-prof-md` writes a heap profile as Markdown, so you can find what is holding memory from a terminal: total size, the types that retain the most, the largest objects, and the chains that keep them alive.
bun --heap-prof-md ./app.ts# Bun Heap ProfileGenerated by `bun --heap-prof-md`. This profile contains complete heap data in markdown format.
Quick Search Commands:
bash
grep '| `Function`' file.md # Find all Function objects
grep 'gcroot=1' file.md # Find all GC roots
grep '| 12345 |' file.md # Find object #12345 or edges involving it
---Summary
| Metric | Value |
| --------------- | ---------------------: |
| Total Heap Size | 4.2 MB (4507930 bytes) |
| Total Objects | 121116 |
| Total Edges | 244084 |
| Unique Types | 67 |
| GC Roots | 427 |
Top 50 Types by Retained Size
| Rank | Type | Count | Self Size | Retained Size | Largest Instance |
| ---: | ---------------------------- | -----: | --------: | ------------: | ---------------: |
| 1 | `<root>` | 1 | 0 B | 4.2 MB | 4.2 MB |
| 2 | `string` | 119883 | 4.1 MB | 4.1 MB | 67 B |
| 3 | `GlobalObject` | 1 | 10.3 KB | 83.1 KB | 83.1 KB |
| 4 | `Function` | 319 | 10.3 KB | 61.5 KB | 13.9 KB |
| 5 | `Structure` | 216 | 23.6 KB | 35.7 KB | 944 B |
| 6 | `FunctionExecutable` | 72 | 9.0 KB | 32.0 KB | 13.9 KB |
| 7 | `ModuleLoader` | 1 | 32 B | 20.1 KB | 20.1 KB |
| 8 | `ModuleRecord` | 2 | 3.0 KB | 19.4 KB | 15.3 KB |
| 9 | `NativeExecutable` | 228 | 17.8 KB | 17.8 KB | 80 B |
| 10 | `JSModuleEnvironment` | 2 | 128 B | 16.3 KB | 14.0 KB |
| 11 | `FunctionCodeBlock` | 5 | 12.3 KB | 12.3 KB | 4.1 KB |
| 12 | `ModuleProgramExecutable` | 2 | 224 B | 10.3 KB | 8.7 KB |
| 13 | `ModuleProgramCodeBlock` | 2 | 2.5 KB | 10.1 KB | 8.6 KB |
| 14 | `UnlinkedFunctionExecutable` | 69 | 6.4 KB | 6.4 KB | 96 B |
| 15 | `Array` | 61 | 1.0 KB | 6.1 KB | 5.1 KB |
| 16 | `console` | 1 | 48 B | 4.4 KB | 4.4 KB |
| 17 | `String` | 1 | 74 B | 4.3 KB | 4.3 KB |
| 18 | `Map` | 3 | 105 B | 4.2 KB | 2.6 KB |
| 19 | `GetterSetter` | 29 | 928 B | 3.7 KB | 256 B |
| 20 | `Iterator` | 2 | 54 B | 3.3 KB | 2.7 KB |
Expand
#### `bun build --metafile-md`
`bun build --metafile-md` writes the bundle analysis as Markdown, so you can see why a bundle is big: the largest modules, what each entry point loads, and the chain of imports that pulled each file in.
bun build ./src/index.ts --outdir ./dist --metafile-md=./dist/meta.md# Bundle Analysis ReportThis report helps identify bundle size issues, dependency bloat, and optimization opportunities.
Table of Contents
Quick Summary
Largest Modules by Output Contribution
Entry Point Analysis
Dependency Chains
Full Module Graph
Raw Data for Searching ---
Quick Summary
| Metric | Value |
| ------------------------- | ------------------ |
| Total output size | 56.1 KB |
| Input modules | 4 |
| Entry points | 1 |
| node_modules contribution | 1 files (55.74 KB) |
| ESM modules | 4 |
Largest Modules by Output Contribution
Modules sorted by bytes contributed to the output bundle. Large modules may indicate bloat.
| Output Bytes | % of Total | Module | Format |
| ------------ | ---------- | --------------------------------------- | ------ |
| 55.74 KB | 99.4% | `node_modules/marked/lib/marked.esm.js` | esm |
| 113 bytes | 0.2% | `src/escape.ts` | esm |
| 76 bytes | 0.1% | `src/render.ts` | esm |
| 52 bytes | 0.1% | `src/index.ts` | esm |
Entry Point Analysis
Each entry point and the total code it loads (including shared chunks).
Entry: `src/index.ts`
Output file: `./index.js`
Bundle size: 56.1 KB
Exports: `main`
Bundled modules (sorted by contribution):
| Bytes | Module |
| --------- | --------------------------------------- |
| 55.74 KB | `node_modules/marked/lib/marked.esm.js` |
| 113 bytes | `src/escape.ts` |
| 76 bytes | `src/render.ts` |
| 52 bytes | `src/index.ts` |
Dependency Chains
For each module, shows what files import it. Use this to understand why a module is included.
Most Commonly Imported Modules
Modules imported by many files. Extracting these to shared chunks may help.
| Import Count | Module | Imported By |
| ------------ | ------ | ----------- |
Full Module Graph
Complete dependency information for each module.
`node_modules/marked/lib/marked.esm.js`
Output contribution: 55.74 KB
Format: esm
Imported by (1 files): `src/index.ts` Expand
#### `process.on("memoryPressure")`
When the operating system is running low on memory, it notifies Bun, and Bun emits `"memoryPressure"` on `process`. Use it to free memory before the OS kills your process: clear a cache, close idle connections, stop idle workers. It works on macOS, Linux, and Windows.
process.on("memoryPressure", (level) => {
cache.clear();
pool.drainIdle();
});Streams and bodies#
`ReadableStream`, `WritableStream`, and `TransformStream` are now native. They use less memory, run faster, and pass 100% of the Web Platform Tests.
Four pipelines, each moving 64 MB in 4 KB chunks:
Throughput:
| Pipeline | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | ---------- | -------------- | -------- | ---------- | -------- | | Download | 1,519 MB/s | n/a | 204 MB/s | 530 MB/s | | Upload | 179 MB/s | n/a | 78 MB/s | 137 MB/s | | Transcode | 132 MB/s | 116 MB/s | 52 MB/s | 91 MB/s | | Subprocess | 751 MB/s | 505 MB/s | 256 MB/s | 170 MB/s |
Peak memory:
| Pipeline | Bun 1.4 | Bun 1.3 | Node.js 26.7 | Deno 2.9 | | ---------- | --------- | ------- | ------------ | -------- | | Download | 57 MB | n/a | 86 MB | 64 MB | | Upload | 60 MB | n/a | 84 MB | 61 MB | | Transcode | 62 MB | 92 MB | 72 MB | 57 MB | | Subprocess | 65 MB | 207 MB | 106 MB | 114 MB |
All four runtimes run the same script. The file streams use `Readable.toWeb()` and `Writable.toWeb()` from `node:stream`. Bun 1.3 is missing `CompressionStream` and `DecompressionStream`, so those rows are n/a.
Benchmark code: native-pipeline.mjs and serve-body.mjs
// End-to-end pipelines between native stream types (fetch body, DecompressionStream, TextDecoderStream,
// file streams via node:stream Readable/Writable.toWeb, child_process pipes). Portable: Bun, Node, Deno.
// Run: <runtime> native-pipeline.mjs --scenario=prep (writes the 64 MiB fixture files once)
// <runtime> native-pipeline.mjs --scenario=download-gunzip-decode --server=http://127.0.0.1:39872
// <runtime> native-pipeline.mjs --scenario=file-gzip-upload --server=http://127.0.0.1:39872
// <runtime> native-pipeline.mjs --scenario=file-decode-encode-file
// <runtime> native-pipeline.mjs --scenario=spawn-passthrough --server=http://127.0.0.1:39872
// Server: `bun run serve-body.mjs --gzip`. 64 MiB payloads/files, 4 KiB chunks end to end. Wrap in /usr/bin/time -v for peak RSS.
import fs from "node:fs";
import { Readable, Writable } from "node:stream";
import { spawn } from "node:child_process";const MB = 1024 * 1024;
const CHUNK = 4096;
const BYTES = 64 * MB;
const argv = globalThis.process?.argv ?? [];
const arg = (k, d) =>
argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3) ?? d;
const scenario = arg("scenario", "prep");
const server = arg("server");
const dir = arg("dir", "/tmp/native-pipeline");
const JSON_FILE = `${dir}/json-64mb.txt`;
const UTF8_FILE = `${dir}/utf8-64mb.txt`;
const OUT_FILE = `${dir}/out-${scenario}.txt`;
const jsonTemplate = new TextEncoder()
.encode(
JSON.stringify({
messages: Array.from({ length: 700 }, (_, i) => ({
id: i,
role: i % 2 ? "assistant" : "user",
ts: 1700000000 + i,
body: "the quick brown fox jumps over the lazy dog " + i,
})),
}),
)
.slice(0, CHUNK);
const utf8Text = (
"hello world \u{1F30A} stream ✨ café naïve 中文 " + "x".repeat(40)
).repeat(2000);
const utf8Template = new TextEncoder().encode(utf8Text).slice(0, CHUNK);
// Keep the UTF-8 fixture valid at 64 KiB chunk boundaries: cut the template at a char boundary.
const utf8Chunk = (() => {
let end = utf8Template.byteLength;
while ((utf8Template[end - 1] & 0xc0) === 0x80) end--;
if (end < utf8Template.byteLength) end--; // drop the lead byte of the truncated char too
return utf8Template.slice(0, end);
})();
const countBytes = async (rs) => {
let n = 0;
for await (const v of rs)
n += typeof v === "string" ? v.length : v.byteLength;
return n;
};
async function prep() {
fs.mkdirSync(dir, { recursive: true });
const write = (path, template, varyByte) => {
if (fs.existsSync(path) && fs.statSync(path).size === BYTES) return;
const fd = fs.openSync(path, "w");
let written = 0,
i = 0;
const buf = new Uint8Array(template.byteLength);
while (written < BYTES) {
buf.set(template);
if (varyByte) buf[0] = 32 + (i++ & 63);
fs.writeSync(fd, buf);
written += buf.byteLength;
}
fs.closeSync(fd);
console.log(`wrote ${path} (${(written / MB).toFixed(0)} MiB)`);
};
write(JSON_FILE, jsonTemplate, true);
write(UTF8_FILE, utf8Chunk, false);
}
const scenarios = {
// fetch(gzip body) -> DecompressionStream -> TextDecoderStream -> for await (count chars). MB/s over decompressed bytes.
"download-gunzip-decode": async () => {
const res = await fetch(`${server}/gzip`);
const expected = +res.headers.get("x-uncompressed-length");
const chars = await countBytes(
res.body
.pipeThrough(new DecompressionStream("gzip"))
.pipeThrough(new TextDecoderStream()),
);
if (chars !== expected)
throw new Error(
`decoded ${chars} chars, expected ${expected} (ASCII payload)`,
);
return expected;
},
// fs.createReadStream(64 MiB, 4 KiB reads) -> CompressionStream -> fetch POST body; server returns bytes received. MB/s over input bytes.
"file-gzip-upload": async () => {
const body = Readable.toWeb(
fs.createReadStream(JSON_FILE, { highWaterMark: CHUNK }),
).pipeThrough(new CompressionStream("gzip"));
const res = await fetch(`${server}/upload`, {
method: "POST",
body,
duplex: "half",
});
const received = +(await res.text());
if (!(received > 0 && received < BYTES))
throw new Error(`server received ${received} bytes`);
return fs.statSync(JSON_FILE).size;
},
// fs.createReadStream(64 MiB utf-8, 4 KiB reads) -> TextDecoderStream -> TextEncoderStream -> fs.createWriteStream. MB/s over file bytes.
"file-decode-encode-file": async () => {
await Readable.toWeb(
fs.createReadStream(UTF8_FILE, { highWaterMark: CHUNK }),
)
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextEncoderStream())
.pipeTo(Writable.toWeb(fs.createWriteStream(OUT_FILE)));
const n = fs.statSync(OUT_FILE).size;
if (n !== fs.statSync(UTF8_FILE).size) throw new Error(`wrote ${n} bytes`);
fs.unlinkSync(OUT_FILE);
return n;
},
// fetch(64 MiB body in 4 KiB chunks).body -> cat stdin ; cat stdout -> for await. MB/s over body bytes.
"spawn-passthrough": async () => {
const child = spawn("cat", [], { stdio: ["pipe", "pipe", "inherit"] });
const res = await fetch(`${server}/?bytes=${BYTES}&chunk=${CHUNK}`);
const [, n] = await Promise.all([
res.body.pipeTo(Writable.toWeb(child.stdin)),
countBytes(Readable.toWeb(child.stdout)),
]);
await new Promise((r) => child.on("close", r));
if (n !== BYTES) throw new Error(`got ${n} bytes from cat`);
return n;
},
};
if (scenario === "prep") {
await prep();
} else {
const fn = scenarios[scenario];
if (!fn)
throw new Error(
`unknown --scenario=${scenario}; prep | ${Object.keys(scenarios).join(
" | ",
)}`,
);
if (scenario !== "file-decode-encode-file" && !server)
throw new Error("--server=URL required (bun run serve-body.mjs --gzip)");
const t0 = performance.now();
const bytes = await fn();
const ms = performance.now() - t0;
console.log(
`${scenario.padEnd(26)} ${(bytes / MB / (ms / 1000))
.toFixed(0)
.padStart(6)} MB/s ${ms.toFixed(0).padStart(6)} ms ${(
bytes / MB
).toFixed(0)} MiB`,
);
}
// Streaming-body server for streams-throughput.mjs --scenario=fetch and native-pipeline.mjs.
// Run: bun run serve-body.mjs [--gzip] (listens on 127.0.0.1:39872)
// GET /?bytes=N&chunk=C fresh C-byte chunks (default 65536), N bytes total
// GET /gzip 64 MiB of JSON-like text gzip-compressed once at startup (--gzip), served in 4 KiB chunks,
// no content-encoding header (the client decompresses explicitly)
// POST /upload drains the request body, responds with the byte count
const MB = 1024 * 1024;
const CHUNK = 64 * 1024;
const argv = process.argv;
const GZIP_BYTES = 64 * MB;
const GZIP_CHUNK = 4096;const jsonTemplate = new TextEncoder()
.encode(
JSON.stringify({
messages: Array.from({ length: 700 }, (_, i) => ({
id: i,
role: i % 2 ? "assistant" : "user",
ts: 1700000000 + i,
body: "the quick brown fox jumps over the lazy dog " + i,
})),
}),
)
.slice(0, CHUNK);
const jsonSource = (total) => {
const count = Math.ceil(total / CHUNK);
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count) {
const b = new Uint8Array(CHUNK);
b.set(jsonTemplate);
b[0] = 32 + (i++ & 63);
c.enqueue(b);
} else c.close();
},
});
};
let gzipped = null;
if (argv.includes("--gzip")) {
const t0 = performance.now();
gzipped = new Uint8Array(
await new Response(
jsonSource(GZIP_BYTES).pipeThrough(new CompressionStream("gzip")),
).arrayBuffer(),
);
console.log(
`pre-compressed ${GZIP_BYTES / MB} MiB -> ${(
gzipped.byteLength / MB
).toFixed(1)} MiB gzip in ${(performance.now() - t0).toFixed(0)} ms`,
);
}
Bun.serve({
port: 39872,
hostname: "127.0.0.1",
idleTimeout: 255,
maxRequestBodySize: 8 1024 MB,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/upload") {
let n = 0;
for await (const c of req.body) n += c.byteLength;
return new Response(String(n));
}
if (url.pathname === "/gzip") {
if (!gzipped) return new Response("start with --gzip", { status: 500 });
let off = 0;
const body = new ReadableStream({
pull(c) {
if (off < gzipped.byteLength) {
c.enqueue(
gzipped.slice(
off,
Math.min(off + GZIP_CHUNK, gzipped.byteLength),
),
);
off += GZIP_CHUNK;
} else c.close();
},
});
return new Response(body, {
headers: {
"content-type": "application/gzip",
"x-uncompressed-length": String(GZIP_BYTES),
},
});
}
const total = +url.searchParams.get("bytes");
const chunk = +(url.searchParams.get("chunk") ?? CHUNK);
const count = Math.ceil(total / chunk);
let i = 0;
const body = new ReadableStream({
pull(c) {
if (i < count) c.enqueue(new Uint8Array(chunk).fill(i++ & 0xff));
else c.close();
},
});
return new Response(body, { headers: { "content-length": String(total) } });
},
});
console.log("listening on http://127.0.0.1:39872");
`Response.clone()` and `Request.clone()` no longer copy every chunk into the second branch. The clone shares the body's chunks with the original.
A 64 MB streaming body, `res.clone()`, then read both bodies:
| Runtime | Peak memory | Time | | ---------- | ----------- | --------- | | Bun 1.4 | 220 MB | 96 ms | | Bun 1.3 | 311 MB | 129 ms | | Node.js 26 | 382 MB | 230 ms | | Deno 2.9 | 297 MB | 134 ms |
Reading only the clone, and never the original:
| Runtime | Peak memory | Time | | ---------- | ----------- | --------- | | Bun 1.4 | 155 MB | 63 ms | | Bun 1.3 | 243 MB | 98 ms | | Node.js 26 | 318 MB | 162 ms | | Deno 2.9 | 233 MB | 104 ms |
The two `arrayBuffer()` results account for 128 MB of the peak in the first table. Bun 1.4 saves one full copy of the body in both cases.
Benchmark code: response-clone.mjs
// Response.clone() and ReadableStream.tee() with fresh 64 KiB buffers. Peak RSS (via /usr/bin/time -v) is the point.
// Run: bun run response-clone.mjs --scenario=clone-both --bytes=67108864
// node response-clone.mjs --scenario=clone-chain --depth=100 --bytes=104857600
// deno run -A response-clone.mjs --scenario=tee --bytes=2147483648
// clone-both: res.clone(), then read both bodies concurrently.
// clone-only: res.clone(), read only the clone; the original is never read.
// clone-chain: clone a streaming Response N times, read only the last clone.
// tee: split a stream and drain both branches concurrently. MB/s is over the source bytes.
const MB = 1024 * 1024;
const CHUNK = 64 * 1024;
const argv = globalThis.process?.argv ?? [];
const arg = (k, d) =>
argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3) ?? d;
const scenario = arg("scenario", "clone-chain");
const DEPTH = +arg("depth", 100);
const BYTES = +arg(
"bytes",
{ "tee": 2048 MB, "clone-chain": 100 MB }[scenario] ?? 1024 * MB,
);const freshSource = (total) => {
const count = Math.ceil(total / CHUNK);
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count) c.enqueue(new Uint8Array(CHUNK).fill(i++ & 0xff));
else c.close();
},
});
};
const drain = async (rs) => {
const r = rs.getReader();
let n = 0;
for (;;) {
const { done, value } = await r.read();
if (done) return n;
n += value.byteLength;
}
};
const scenarios = {
"clone-both": async () => {
const res = new Response(freshSource(BYTES));
const c = res.clone();
const [a, b] = await Promise.all([res.arrayBuffer(), c.arrayBuffer()]);
if (a.byteLength !== b.byteLength) throw new Error("clone mismatch");
return a.byteLength;
},
"clone-only": async () => {
const res = new Response(freshSource(BYTES));
const c = res.clone();
return (await c.arrayBuffer()).byteLength;
},
"clone-chain": async () => {
let cur = new Response(freshSource(BYTES));
const chain = [cur];
for (let i = 0; i < DEPTH; i++) chain.push((cur = cur.clone()));
return (await chain.at(-1).arrayBuffer()).byteLength;
},
"tee": async () => {
const [a, b] = freshSource(BYTES).tee();
const [x, y] = await Promise.all([drain(a), drain(b)]);
if (x !== y) throw new Error("branch mismatch");
return x;
},
};
const fn = scenarios[scenario];
if (!fn)
throw new Error(
`unknown --scenario=${scenario}; clone-both | clone-only | clone-chain | tee`,
);
const rss0 = globalThis.process?.memoryUsage?.().rss ?? 0;
const t0 = performance.now();
const got = await fn();
const ms = performance.now() - t0;
if (got !== BYTES)
throw new Error(`${scenario}: read ${got} bytes, expected ${BYTES}`);
const rssDelta = ((globalThis.process?.memoryUsage?.().rss ?? 0) - rss0) / MB;
console.log(
`${scenario.padEnd(12)} ${(BYTES / MB / (ms / 1000))
.toFixed(0)
.padStart(6)} MB/s ${ms.toFixed(0).padStart(6)} ms ${
BYTES / MB
} MiB rss +${rssDelta.toFixed(0)} MB`,
);
`CompressionStream` & `DecompressionStream` are now implemented natively. Bun 1.3 did not have them.
1 GB of JSON text through a gzip stream, 64 KB chunks:
| Stream | Bun 1.4 | Node.js 26 | Deno 2.9 | | ------------------- | -------------- | ---------- | -------- | | CompressionStream | 152 MB/s | 135 MB/s | 130 MB/s | | DecompressionStream | 2,291 MB/s | 491 MB/s | 679 MB/s |
Compression is bound by zlib itself, so the runtimes are close. Decompression is where the native stream path shows.
Benchmark code: compression-stream.mjs
// CompressionStream / DecompressionStream throughput on JSON-like text, generated in fresh 64 KiB chunks.
// Run: bun run compression-stream.mjs --scenario=compress --format=gzip --bytes=1073741824
// node compression-stream.mjs --scenario=decompress --format=deflate
// deno run -A compression-stream.mjs --scenario=compress
// MB/s is over uncompressed bytes. `decompress` compresses the input first (untimed), then times the inflate.
const MB = 1024 * 1024;
const CHUNK = 64 * 1024;
const argv = globalThis.process?.argv ?? [];
const arg = (k, d) =>
argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3) ?? d;
const scenario = arg("scenario", "compress");
const format = arg("format", "gzip");
const BYTES = +arg("bytes", 1024 * MB);const template = new TextEncoder()
.encode(
JSON.stringify({
messages: Array.from({ length: 700 }, (_, i) => ({
id: i,
role: i % 2 ? "assistant" : "user",
ts: 1700000000 + i,
body: "the quick brown fox jumps over the lazy dog " + i,
})),
}),
)
.slice(0, CHUNK);
const jsonSource = (total) => {
const count = Math.ceil(total / CHUNK);
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count) {
const b = new Uint8Array(CHUNK);
b.set(template);
b[0] = 32 + (i++ & 63);
c.enqueue(b);
} else c.close();
},
});
};
const drain = async (rs) => {
const r = rs.getReader();
let n = 0;
for (;;) {
const { done, value } = await r.read();
if (done) return n;
n += value.byteLength;
}
};
const collect = async (rs) => {
const parts = [];
const r = rs.getReader();
for (;;) {
const { done, value } = await r.read();
if (done) break;
parts.push(value);
}
const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
let off = 0;
for (const p of parts) out.set(p, off), (off += p.byteLength);
return out;
};
const chunked = (buf) =>
new ReadableStream({
start(c) {
for (let i = 0; i < buf.byteLength; i += CHUNK)
c.enqueue(buf.subarray(i, Math.min(i + CHUNK, buf.byteLength)));
c.close();
},
});
let run;
if (scenario === "compress")
run = () =>
drain(jsonSource(BYTES).pipeThrough(new CompressionStream(format)));
else if (scenario === "decompress") {
const compressed = await collect(
jsonSource(BYTES).pipeThrough(new CompressionStream(format)),
);
run = () =>
drain(chunked(compressed).pipeThrough(new DecompressionStream(format)));
} else throw new Error(`unknown --scenario=${scenario}; compress | decompress`);
const t0 = performance.now();
const got = await run();
const ms = performance.now() - t0;
if (scenario === "decompress" && got !== BYTES)
throw new Error(`inflated ${got} bytes, expected ${BYTES}`);
console.log(
`${scenario} (${format})`.padEnd(22) +
` ${(BYTES / MB / (ms / 1000)).toFixed(0).padStart(6)} MB/s ${ms
.toFixed(0)
.padStart(6)} ms ${BYTES / MB} MiB`,
);
`TextDecoderStream` & `TextEncoderStream` use about half the memory of Bun 1.3.
Peak memory, 1 GB of mixed UTF-8 text, 64 KB chunks:
| Stream | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | ----------------- | --------- | ------- | ---------- | --------- | | TextEncoderStream | 44 MB | 110 MB | 182 MB | 52 MB | | TextDecoderStream | 56 MB | 119 MB | 68 MB | 55 MB |
Throughput, same run:
| Stream | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | ----------------- | -------------- | ---------- | ---------- | ---------- | | TextEncoderStream | 1,963 MB/s | 1,881 MB/s | 75 MB/s | 612 MB/s | | TextDecoderStream | 1,489 MB/s | 1,507 MB/s | 1,540 MB/s | 1,059 MB/s |
Benchmark code: text-encoder-stream.mjs
// TextEncoderStream / TextDecoderStream throughput on mixed multi-byte UTF-8, fresh 64 KiB chunks.
// Run: bun run text-encoder-stream.mjs --scenario=encode --bytes=1073741824
// node text-encoder-stream.mjs --scenario=decode
// deno run -A text-encoder-stream.mjs --scenario=encode
// MB/s is over UTF-8 bytes (encoder output / decoder input).
const MB = 1024 * 1024;
const CHUNK = 64 * 1024;
const argv = globalThis.process?.argv ?? [];
const arg = (k, d) =>
argv.find((a) => a.startsWith(`--${k}=`))?.slice(k.length + 3) ?? d;
const scenario = arg("scenario", "encode");
const BYTES = +arg("bytes", 1024 * MB);const text = (
"hello world \u{1F30A} stream ✨ café naïve 中文 " + "x".repeat(40)
).repeat(2000);
const utf8Template = new TextEncoder().encode(text).slice(0, CHUNK);
const stringChunk = text.slice(0, CHUNK);
const stringChunkBytes = new TextEncoder().encode(stringChunk).byteLength;
const stringSource = (total) => {
const count = Math.ceil(total / stringChunkBytes);
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count)
c.enqueue(String(i++ & 0xffff).padStart(5, "0") + stringChunk.slice(5));
else c.close();
},
});
};
const bytesSource = (total) => {
const count = Math.ceil(total / CHUNK);
let i = 0;
return new ReadableStream({
pull(c) {
if (i < count) {
const b = new Uint8Array(CHUNK);
b.set(utf8Template);
b[0] = 32 + (i++ & 63);
c.enqueue(b);
} else c.close();
},
});
};
const drain = async (rs) => {
const r = rs.getReader();
let n = 0;
for (;;) {
const { done, value } = await r.read();
if (done) return n;
n += typeof value === "string" ? value.length : value.byteLength;
}
};
let run, expected;
if (scenario === "encode") {
const count = Math.ceil(BYTES / stringChunkBytes);
expected = count * stringChunkBytes;
run = () => drain(stringSource(BYTES).pipeThrough(new TextEncoderStream()));
} else if (scenario === "decode") {
expected = null; // output is chars, not bytes
run = () => drain(bytesSource(BYTES).pipeThrough(new TextDecoderStream()));
} else throw new Error(`unknown --scenario=${scenario}; encode | decode`);
const t0 = performance.now();
const got = await run();
const ms = performance.now() - t0;
if (expected !== null && got !== expected)
throw new Error(`encoded ${got} bytes, expected ${expected}`);
const bytes = expected ?? Math.ceil(BYTES / CHUNK) * CHUNK;
console.log(
`${scenario.padEnd(22)} ${(bytes / MB / (ms / 1000))
.toFixed(0)
.padStart(6)} MB/s ${ms.toFixed(0).padStart(6)} ms ${(bytes / MB).toFixed(
0,
)} MiB`,
);
All numbers: AMD EPYC 9R14, Linux x64. Bun 1.3.0, Bun 1.4.0, Node.js 26.7.0, Deno 2.9.5. Median of 3 runs, one process per run, peak RSS from `/usr/bin/time -v`.
Backpressure#
`Bun.serve` automatically pauses the `ReadableStream` request & response bodies when the connection can't accept more data, so a slow or stalled client holds at most one buffer's worth of server memory.
Bun.serve({
routes: {
"/": () => {
return new Response(
new ReadableStream({
// pauses when the socket's send buffer fills
pull(controller) {
controller.enqueue(new Uint8Array(65536));
},
}),
);
},
},
});`fetch()` does the same on the receiving side. This also works with `TransformStream` like `CompressionStream` & `DecompressionStream`, and `HTMLRewriter.transform`, `child_process`, `Bun.spawn`, `Bun.file(path).stream()`, `Blob.stream()` and more.
backpressure · Bun.serve ReadableStream
Bun 1.31.41.4RSS 238 MB▶ play◼ playing…▶ Bun 1.4: see the fix↻ replay▶ play
ReadableStream
pull(controller) {
controller.enqueue(…65536)
}
⏸ waiting for socket to drain
→
server heap
OOM
pause
💥 process killed (OOM)
→
slow client 🐌
for await (chunk of res.body)
reads every 480ms
The stream’s `pull()` outpaces a slow client 3:1. In Bun 1.3 the server buffered every unsent chunk on the heap until the process ran out of memory; in 1.4 `pull()` pauses when the socket’s send buffer fills and resumes when it drains. Illustrative — each chip is a batch of 64 KB chunks; the \~1 GiB/s figure is from the #32553 repro.
We rewrote Bun in Rust#
Bun is now written in Rust - and this is the first release (though Claude Code has been using Bun's Rust port for months now, and Prisma launched Prisma Compute on it). We wrote a blog post about the Rust rewrite that goes into more detail.
What's new#
This release makes Bun's builtin standard library bigger.
15 dependencies · now built in
0 dependencies left▶ play↻ replay◼ playing…▶ play
package.json · dependencies
sharp
puppeteer
marked
node-cron
node-pty
concurrently
npm-run-all
serve-static
json5
fast-xml-parser
tar
string-width
slice-ansi
cli-truncate
wrap-ansi
built into Bun 1.4
✓Bun.Imageimage processing
✓Bun.WebViewheadless browser
✓Bun.markdownmarkdown rendering
✓Bun.cronscheduled jobs
✓Bun.Terminalnative PTY
✓bun run --parallelparallel scripts
✓bun run --parallelparallel scripts
✓Bun.serve routesstatic files
✓Bun.JSON5JSON5 parsing
✓Bun.XMLXML parsing
✓Bun.Archivetarballs
✓Bun.stringWidthterminal columns
✓Bun.sliceAnsiANSI-aware slicing
✓Bun.sliceAnsiANSI-aware truncation
✓Bun.wrapAnsiANSI-aware wrapping
Every one of these ships in the Bun binary — no install step, no native build, no lockfile entry. Each is covered in its own section below.
`Bun.Image` v1.3.14#
Bun.Image is a built-in image library.
await Bun.file("photo.jpg")
.image()
.resize(1024, 1024, { fit: "inside" })
.rotate(90)
.webp({ quality: 85 })
.write("thumb.webp");// Stream straight into a Response
return new Response(new Bun.Image(upload).resize(200).jpeg());
Decode, resize, rotate, and encode JPEG, PNG, WebP, GIF, and BMP. HEIC, AVIF, and TIFF work on macOS and Windows.
The API looks like sharp, and no native addon is needed. ICC color profiles like Display P3 survive transcoding.
On a 1080p PNG resized to a 400×400 JPEG, it's 1.38× faster than sharp. On JPEG to WebP, 1.19×. #30032
Bun.Image · decode → resize → rotate → encode
photo.jpg → thumb.webp▶ play◼ playing…↻ replay▶ play
await Bun.file("photo.jpg")
.image()
.resize(1024, 1024, { fit: "inside" })
.rotate(90)
.webp({ quality: 85 })
.write("thumb.webp");
JPEGJPEGWEBP·124 KB✓ thumb.webp
zero `npm install` · 1.38× faster than sharp on decode→resize→encode · #30032
`Bun.WebView` v1.3.12v1.4.0#
Bun.WebView is headless browser automation built into Bun, without Puppeteer or Playwright.
await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate("https://bun.sh");
await view.click("a[href='/docs']");
const title = await view.evaluate("document.title");
await Bun.write("page.png", await view.screenshot());Navigate, click, scroll, run JavaScript, and take screenshots. Clicks and scrolls are real user input.
On macOS it uses the system WebKit, with nothing to install. On macOS, Linux, and Windows it can also drive an installed Chrome, Chromium, or Edge. #39423
`Bun.WebView` extends `EventTarget`, returns `Blob` screenshots, and exposes a `.cdp(method, params?)` escape hatch for raw Chrome DevTools Protocol commands. See the docs for advanced usage.
Bun.WebView · headless browser in 5 lines
▶ play◼ playing…↻ replay▶ play
await using view \= new Bun.WebView({ width: 800, height: 600 });
await view.navigate("https://bun.sh");
await view.click("a\[href='/docs'\]");
const title \= await view.evaluate("document.title");
await Bun.write("page.png", await view.screenshot());
https://bun.sh/docs https://bun.shhttps://bun.sh/docs
DocsGuidesBlog
Bun is a fast JavaScript all-in-one toolkit
Documentation
title → "Bun — Documentation"
✓ page.png
No Puppeteer, no Playwright, no `npm install` — a real browser (WebKit on macOS, or an installed Chrome/Edge via CDP) driven by five awaits. Clicks arrive as trusted input (`event.isTrusted === true`). Illustrative render — see the docs.
`Bun.markdown` v1.3.8v1.4.0#
Bun.markdown is a Markdown parser built into Bun.
const html = Bun.markdown.html("# Hello world");
// "<h1>Hello <strong>world</strong></h1>\n"// ANSI terminal output
const ansi = Bun.markdown.render("# Hello\n\nbold", {
heading: (children) => `\x1b1;4m${children}\x1b[0m\n`,
paragraph: (children) => children + "\n",
strong: (children) => `\x1b[1m${children}\x1b[22m`,
});
// React
export default function Page() {
return Bun.markdown.react(readme);
}
`Bun.markdown.html()` gives you an HTML string. `Bun.markdown.react()` gives you React elements, and you can swap in your own component for any tag. `Bun.markdown.render()` gives you a callback per element, for things like terminal output.
GFM tables, strikethrough, task lists, and autolinks are supported, `.md` is a bundler loader, and the parser runs in linear time on adversarial input.
The HTML output is not sanitized: raw HTML, event-handler attributes, and `javascript:` hrefs pass through verbatim.
`Bun.cron()` [v1.3.11v1.4.0#
Bun.cron() registers a scheduled job with the operating system: crontab on Linux, launchd on macOS, Task Scheduler on Windows.
Your script exports a `scheduled(controller)` handler, the same shape as Cloudflare Workers Cron Triggers.
Standard 5-field cron syntax works, including named days and `@daily`. #26999
// Register an OS-level cron job
await Bun.cron("./worker.ts", "30 2 MON", "weekly-report");// Parse a cron expression → next matching UTC Date
const next = Bun.cron.parse("/15 *");
// worker.ts
export default {
async scheduled(controller) {
// controller.cron === "30 2 1"
// controller.scheduledTime === 1737340200000
await doWork();
},
};
You can also pass a function instead of a file. Bun runs it on the event loop, with no system cron involved.
Jobs never overlap, and `using` stops the job when it goes out of scope.
using job = Bun.cron("/5 *", async () => {
await cleanupTempFiles();
});
job.cron; // "/5 *"
job.unref(); // allow process exit
job.stop(); // cancel (or let `using` dispose)`Bun.cron` schedules run in local time by default, with a new `{ tz }` option for explicit timezones; `parse()` rejects `from` timestamps outside the ECMAScript `Date` range. #35122 #29282
`Bun.Terminal` v1.3.5v1.4.0#
Bun.Terminal is a built-in pseudo-terminal, so you can drive `bash`, `vim`, or `htop` from JavaScript without node-pty.
Pass `terminal` to `Bun.spawn`, write input, resize, and read the colored output. It works on Linux, macOS, and Windows. #25415 #29522
const proc = Bun.spawn(["bash"], {
terminal: {
cols: 80,
rows: 24,
data(term, data) {
process.stdout.write(data);
},
},
});proc.terminal.write("echo Hello from PTY!\n");
`bun run --parallel` v1.3.9v1.4.0#
`bun run --parallel` runs multiple `package.json` scripts concurrently with name-prefixed output. Glob-match script names, fan out across every workspace with `--filter`, and keep going past failures with `--no-exit-on-error`. This replaces tools like npm-run-all and concurrently. #26551
# Run "build" and "test" concurrentlybun run --parallel build test# Glob-matched script namesbun run --parallel "build:*"# Run "build" in every workspace packagebun run --parallel --filter '*' build# Keep going even if one package failsbun run --parallel --no-exit-on-error --filter '*' testEach line of output is prefixed with the script name (or `package:script` under `--filter`), and `prebuild`/`postbuild` hooks are grouped with their main script so dependency order is preserved. `--sequential` runs scripts one at a time with the same prefixed output and filtering.
❯bun run --parallel "build:\*"↻ replay
build:client | $ bun build src/client.ts --outdir=dist/client --minify
build:server | $ bun build src/server.ts --outdir=dist/server --target=bun
build:worker | $ bun build src/worker.ts --outdir=dist/worker --target=bun
build:styles | $ bun run src/build-styles.ts
build:client | Bundled 1 module in 4ms
build:server | Bundled 1 module in 4ms
build:client | client.js 69 B (entry point)
build:worker | Bundled 1 module in 3ms
build:server | server.js 153 B (entry point)
build:client | Done in 25ms
build:worker | worker.js 98 B (entry point)
build:server | Done in 32ms
build:worker | Done in 34ms
build:styles | compiled base.css
build:styles | compiled components.css
build:styles | compiled utilities.css
build:styles | compiled theme.css
build:styles | ✓ 4 stylesheets → dist/styles.css
build:styles | Done in 560ms
3x faster `bun:ffi` v1.4.0#
bun:ffi now runs on FFI built into JavaScriptCore, replacing TinyCC. We added native support for FFI to JavaScriptCore.
| | Bun 1.3 | Bun 1.4 | | | ---------------------------- | ------- | ----------- | ----- | | no-op call | 2.13 ns | 0.70 ns | 3.0× | | new CString(ptr) | 92.5 ns | 24.1 ns | 3.8× | | opentui layout reads (1,000) | | | 2.08× |
The new `buffer_length` argument type passes a TypedArray's length alongside its pointer, so the two can't disagree.
import { dlopen } from "bun:ffi";const { symbols } = dlopen("libhash.so", {
hash: { args: ["buffer", "buffer_length"], returns: "cstring" },
});
const digest = symbols.hash(data, data);
typeof digest; // "string"
`returns: "cstring"` now gives you a plain string. `NULL` gives you `null`.
When a call site gets hot, the JIT compiles it into a direct call to the C function. It already knows the argument types from the signature, so it passes unboxed values in registers and skips the type checks and boxing a normal call would do.
Dev tooling v1.3.2v1.4.0#
HTTP/3 in `Bun.serve()` (experimental) v1.3.14v1.4.0#
Bun.serve() supports HTTP/3. Set `http3: true` next to `tls`, and Bun listens on UDP on the same port.
HTTP/1.1 keeps working over TCP, and responses advertise HTTP/3 with an `Alt-Svc` header so browsers upgrade on their own.
On a static-route benchmark, HTTP/3 is 2.7× faster than HTTPS/1.1 on the same server.
Bun.serve({
port: 443,
tls: { ... },
http3: true, // also listen on UDP/443 for HTTP/3
// h1: false, // optional: serve HTTP/3 only
fetch(req) {
return new Response("hi");
},
});Experimental: zero-round-trip connection resumption is disabled, `server.upgrade()` returns `false` over H3, and `unix:` sockets skip the H3 listener. Don't ship `http3: true` to production yet. #29768
HTTP/2 & HTTP/3 in `fetch()` (experimental) v1.3.14v1.4.0#
`fetch()` now supports HTTP/2 and HTTP/3. Pass `protocol: "http2"` or `protocol: "http3"`.
const [a, b, c] = await Promise.all([
fetch("https://api.example.com/a", { protocol: "http2" }),
fetch("https://api.example.com/b", { protocol: "http2" }),
fetch("https://api.example.com/c", { protocol: "http2" }),
]);const res = await fetch("https://example.com", { protocol: "http3" });
Over HTTP/2, concurrent requests to the same origin share one connection. Redirects, decompression, and streaming work the same as they do over HTTP/1.1.
To turn them on everywhere, set `BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1` or pass `--experimental-http3-fetch`. With the HTTP/3 flag, Bun remembers which origins support it and uses it for later requests on its own.
Serve files & folders v1.4.0#
Bun.serve() routes can now serve a directory.
Files stream with `sendfile`. `Content-Type`, `ETag`, `Last-Modified`, `304`, and `Range` are handled for you, and `index.html` is served for directories.
This replaces `express.static`, `serve-static`, and `sirv`. #36156
Bun.serve({
routes: {
"/static/*": { dir: "./public" },
},
});When serving files from disk, paths are normalized before lookup and on Linux files are opened with `openat2` with `O_RESOLVE_BENEATH`, so a symlink inside the directory can't reach above it.
Range and conditional requests v1.3.13v1.4.0#
`Bun.serve` honors `Range` headers for file responses, so video seeking and resumable downloads work. Both static routes and `Bun.file()` bodies return `206 Partial Content`.
Static routes and `Bun.file()` responses also handle conditional requests. `If-None-Match` and `If-Modified-Since` get a `304`, and `If-Match` and `If-Unmodified-Since` get a `412` when the precondition fails.
Bun.serve({
routes: {
"/video.mp4": new Response(Bun.file("./video.mp4")),
"/logo.png": new Response(Bun.file("./logo.png")),
},
});curl -H 'Range: bytes=0-1023' localhost:3000/video.mp4HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/104857600curl -H 'If-None-Match: "1a2b3c"' localhost:3000/logo.pngHTTP/1.1 304 Not ModifiedHTML routes sourcemaps disabled in production v1.4.0#
In production, `Bun.serve` no longer serves sourcemaps for HTML routes, so your original source stays on your server.
Development mode still serves them. Set `sourcemap` under `[serve.static]` in `bunfig.toml` to pick explicitly. #36982
[serve.static]
sourcemap = "linked"`fetch()` request compression v1.4.0#
`fetch()` gains a `compress` option. It compresses the request body before sending and sets the `Content-Encoding` header automatically. It supports `gzip`, `deflate`, `br`, and `zstd`, with an optional compression level. Buffered bodies (string, `ArrayBuffer`, `TypedArray`, `Blob`) are compressed, and `Content-Length` reflects the compressed size. Streaming bodies pass through unchanged. #32416
await fetch(url, {
method: "POST",
body: largeJsonString,
compress: "gzip", // or true, "deflate", "br", "zstd", { encoding, level }
});`fetch()` proxy headers v1.3.4#
`fetch()`'s `proxy` option now also accepts an object with `url` and `headers`, letting you send custom headers (like `Proxy-Authorization`) directly to the proxy server, whether the destination is HTTPS or plain HTTP. #25090
await fetch(url, {
proxy: {
url: "http://proxy.example.com:8080",
headers: { "Proxy-Authorization": "Bearer token" },
},
});TLS session resumption v1.4.0#
A second cold connection to an origin resumes at 1 RTT. A 32-entry LRU caches BoringSSL client sessions per origin, so reconnecting after the keep-alive pool evicts skips the full handshake and certificate-chain walk.
Connection reuse v1.3.10v1.4.0#
`fetch()` reuses connections through an HTTPS proxy, and reuses them for requests with custom TLS options like a client certificate or a custom CA. #28611 #37715 #27385
Also built in v1.3.3v1.4.0#
`bun install`#
`bun install` is an npm-compatible package manager.
On a T3-stack Next.js app, `bun install` is many times faster than yarn, pnpm, and npm, and uses a fraction of the memory.
That holds for a first install, a fresh checkout, CI with and without a cache, and a no-op reinstall:
One app, six installs
T3-stack Next.js app, 25 direct dependencies, \~220 packages in the lockfile · install time and peak memory
| Scenario | bunv1.4 | npmv12.0.2 | pnpmv11.21.0 | yarnv1.22.22 | | ------------------------------------------------------------------------------------------ | --------------------- | ----------- | ------------ | ------------ | | First install, everno cache · no lockfile · no node\_modules | 1.41s15× faster376 MB | 18.1s503 MB | 13.5s1.8 GB | 20.5s498 MB | | Fresh checkout, warm cacheno lockfile yet · every package already in the cache | 251ms30× faster52 MB | 7.61s798 MB | 2.38s1.1 GB | 1.83s204 MB | | CI without a cachelockfile committed · every tarball fetched from the registry | 951ms19× faster214 MB | 4.92s455 MB | 11.7s2.7 GB | 17.6s323 MB | | CI with a warm cachelockfile committed · dependency cache restored · node\_modules rebuilt | 210ms21× faster12 MB | 4.45s698 MB | 1.92s1.4 GB | 1.76s205 MB | | node\_modules there, cache gonealready installed · only the dependency cache was cleared | 12ms33× faster12 MB | 384ms129 MB | 399ms141 MB | 212ms107 MB | | Everything already up to datethe reinstall after nothing changed | 12ms33× faster12 MB | 337ms114 MB | 400ms141 MB | 211ms107 MB |
Linux x64, EPYC 9R14 · bench/install in oven-sh/bun · each package manager with its own lockfile and node\_modules, state prepared per scenario before every run, every package manager at defaults · medians of 3, peak memory is the largest of the 3 runs
Global virtual store: up to 7x faster installs v1.3.14v1.4.0#
`bun install --linker=isolated` now uses a shared global virtual store. Packages are extracted once into Bun's cache and symlinked into each project's `node_modules/.bun/` store, instead of being copied into `node_modules` on every install. #29489
On a warm isolated install, copying packages into `node_modules` (`clonefileat()` on macOS) was 95% of main-thread time, and macOS runs only one of those calls at a time.
Once a package exists anywhere on the machine, later installs do one `symlink()` per package instead of one `clonefileat()`.
On the common CI path (lockfile present, cache warm, `node_modules` wiped), a 1,400-package install is 7x faster. The global store is opt-in: it applies when you select the isolated linker, which is not the default for existing projects.
# bunfig.toml
[install]
linker = "isolated"`bun pm diff` v1.4.0#
bun pm diff shows you what changed between two versions of a package.
It starts with a summary: which files changed, any new install scripts, and any new imports of `child_process`, `fs`, `net`, or `vm`. Then it shows the diff.
Minified files are un-minified before diffing, and formatting-only changes are skipped, so you see the lines that actually changed. #39229
bun pm diff react # the version in bun.lock → latestbun pm diff react@18.2.0 19.0.0 # two published versionsbun pm diff ./vendored-pkg pkg@2.1.0 # a folder against a published versionbun pm diff react-dom@18.2.0 18.3.1 '*.min.js'`bun audit fix` v1.4.0#
bun audit fix upgrades vulnerable packages to a safe version and installs.
If a fix needs a new major version, it tells you, and `--latest` lets it do that. `--dry-run` shows what it would change. #38333
bun audit fixfixing:
ms@0.7.0 → 0.7.1
lodash@4.17.20 → 4.17.21
package.json: 4.17.20 → 4.17.21blocked by a dependent's range:
minimatch@0.3.0 → 3.0.2
express@3.21.2 depends on minimatch@0.3.0
Fixed 2 vulnerabilities in 2 packages
1 vulnerability remaining
`bun dedupe` v1.4.0#
bun dedupe removes duplicate versions of packages from `bun.lock`.
If you have `esbuild@0.15.10` and `esbuild@0.15.11` and one version satisfies both, you end up with one. It never changes `package.json`, and `--check` fails CI if there are duplicates. #38333
bun dedupebun dedupe v1.4.0 (abc12345)↳ esbuild 0.15.10 → 0.15.11
↳ react 18.2.0 → 18.3.1
2 duplicate versions removed, 3 packages installed (checked 5 packages) [12.00ms]
`bun prune` v1.4.0#
bun prune deletes packages from `node_modules` that aren't in `bun.lock` anymore.
`bun prune --production` also deletes `devDependencies`, so you can build with them and ship without them. #38333
bun prune --productionbun prune v1.4.0 (abc12345)typescript@5.4.0
@types/node@20.11.5
2 packages removed (checked 948) [22.00ms]COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
RUN bun prune --production`bun pm licenses` v1.4.0#
`bun pm licenses` lists your dependencies by license.
`--json` gives you machine-readable output, and `--prod` skips `devDependencies`. #38333
bun pm licenses --prod --json > licenses.json`bun update` updates transitive dependencies v1.4.0#
`bun update` now updates the dependencies of your dependencies too, not just the ones in your `package.json`.
bun updatebun update zodbun update '@types/*' --latest`bun update <name>` updates that package everywhere it appears, and `bun update '@types/*'` takes a pattern. #38333
`bun add --filter` v1.4.0#
`bun add`, `bun remove`, and `bun update` accept `--filter`, so you can add a package to one workspace from the root of your monorepo.
bun add zod --filter apibun run --filter 'web...' build`--filter 'web...'` means `web` and everything it depends on. `--filter '...web'` means everything that depends on `web`.
`bun add --catalog` v1.4.0#
`bun add <pkg> --catalog` adds the package to your root catalog and writes `"catalog:"` in the workspace's `package.json`.
bun add react --catalogIf the package is already in your default catalog, plain `bun add` uses it.
Nested overrides v1.4.0#
You can now override a dependency's dependency without overriding it everywhere. npm's nested form, yarn's `a/b`, and pnpm's `a>b` all work, and an override can be scoped to a version range.
{
"overrides": {
"express": { "qs": "6.13.0" },
"lodash@<4.17.21": "4.17.21"
}
}Lockfile integrity for GitHub and tarball dependencies v1.3.10#
`bun.lock` now records a SHA-512 hash for GitHub and tarball dependencies, the same way it always has for npm packages. Existing lockfiles pick up the hashes on the next install.
["pkg@github:user/repo#ref", {}, "resolved-commit"]
["pkg@github:user/repo#ref", {}, "resolved-commit", "sha512-..."]`trustedDependencies` only auto-trusts the npm registry v1.3.5#
Bun's default trusted-dependencies list applies only to packages from the npm registry.
A `file:`, `link:`, `git:`, or `github:` dependency named esbuild gets no trust from the real esbuild's entry. To run its lifecycle scripts, list it in `trustedDependencies` yourself.
{
"dependencies": {
"esbuild": "github:some-fork/esbuild#main"
},
"trustedDependencies": ["esbuild"]
}Trusted-dependency names, `.npmrc` scope names, and local `file:` paths are compared by their full bytes rather than a hash, and registry credentials stay scoped to their configured host — never sent cross-origin, downgraded to `http://`, or printed in error or verbose output.
`nativeDependencies` and `ignoreScripts` v1.3.2#
For packages that ship prebuilt binaries as per-platform `optionalDependencies` (esbuild and @esbuild/darwin-arm64), Bun links the right binary directly instead of running `postinstall`. List them in `nativeDependencies`.
`ignoreScripts` skips a package's lifecycle scripts entirely, even if it is also in trustedDependencies. #24283
Configure both in `package.json`, or disable native binary linking with BUN\_FEATURE\_FLAG\_DISABLE\_NATIVE\_DEPENDENCY\_LINKER=1 and script skipping with `BUN_FEATURE_FLAG_DISABLE_IGNORE_SCRIPTS=1`.
{
"nativeDependencies": ["esbuild", "my-custom-package"],
"ignoreScripts": ["sharp", "another-package"]
}`bun test`#
`bun test --parallel` runs test files across worker processes. `--shard` splits them across CI machines. `--timings` balances both by how long each file takes. `--changed` runs only the tests your diff touches.
bun test --changed=main # only what your branch touchesbun test --parallel --timings=timings.json --update-timingsbun test --parallel --shard=1/3 --timings=timings.json # in CI, per machine`bun test --parallel` v1.3.13v1.4.0#
`bun test --parallel[=N]` runs test files across N worker processes (defaulting to your CPU count). Files go to whichever worker frees up next. #29354
bun test --parallelbun test --parallel=4 --isolateCoverage and JUnit output are merged across workers. `--bail` stops every worker on the first failure.
`--parallel` implies `--isolate` (below). `--no-isolate` turns that off, so each worker keeps one global and one module registry for every file it runs.
Each worker exposes its 1-indexed slot as `JEST_WORKER_ID` / `BUN_TEST_WORKER_ID`, so Jest setups that key databases or ports off `JEST_WORKER_ID` work unchanged. Preload scripts with top-level `await` complete before any worker starts running tests.
bun test --parallel · files go to whichever worker frees up▶ play↻ replay▶ play
bun test
9.4s
bun test --parallel=4
2.4s3.9× faster
✓ done · 9.4s
w1
w2
w3
w4
✓ done · 2.4s · workers idle
Same 24 test files, same durations, one wall-clock. `--parallel=4` hands each file to whichever worker’s queue is shortest — so the 1.8s `sql/postgres.test.ts` outlier ties up one lane while the other three keep going, and all four finish within \~7% of each other. Durations illustrative. #29354
`bun test --isolate` v1.3.13v1.4.0#
`bun test --isolate` runs each test file in a fresh JavaScript global object, in the same process. This is how Jest and Vitest behave by default. It makes "passes alone, fails in the full suite" bugs go away. #29354
bun test --isolateBetween files, Bun:
Transpiled source and bytecode are cached at the process level and shared across globals. The second file to import a module skips reading, transpiling, and parsing it. Only the module's top-level code runs again.
Bun 1.4 fixes several stability problems from the first release of `--isolate`:
`bun test --shard` v1.3.13v1.4.0#
`bun test --shard=M/N` splits your test files across multiple CI runners. Files are sorted deterministically and distributed round-robin so every machine sees the same partition, with 1-based indexing matching Jest, Vitest, and Playwright. Works alongside `--changed` and `--randomize`. An empty shard exits 0 instead of failing. #29366
# In a matrix of 3 jobs:bun test --shard=1/3bun test --shard=2/3bun test --shard=3/3`bun test --timings` v1.4.0#
`--timings=<path>` reads per-file durations from a previous run so `--shard` and `--parallel` balance by wall time instead of file count. `--update-timings` records the durations. #36814
bun test --timings=timings.json --update-timings # record per-file durationsbun test --shard=1/3 --timings=timings.json # cut shards by equal timebun test --parallel --timings=timings.json # workers start slowest firstWith timings, each shard gets about the same total time instead of the same number of files. Files that share imports stay together, so the module cache stays warm.
`--parallel` starts each worker on its slowest file first. The timings file is written slowest-first, so it doubles as a slow-test report.
\`bun test --timings\` records how long each test file takes; \`bun test --parallel --shard=i/N\` uses timings to split CI shards via longest-processing-time-first scheduling, making large test suites run faster
`bun test --changed` v1.3.13v1.4.0#
`bun test --changed` runs only the test files affected by your uncommitted changes, or by the diff against a branch or commit with `--changed=main`. The flag is vitest-compatible. #29262
bun test --changed # uncommitted (unstaged + staged + untracked)bun test --changed=HEAD~1 # diff against a commit / branch / tagbun test --changed=mainbun test --changed --watch # re-filters on every restartBun scans every test file's imports, asks git which files changed, and walks the import graph backwards to find the tests that reach them.
tsconfig `paths` aliases like `@/*` work. With `--watch`, editing any source file re-filters on restart.
bun test --changed▶ next · edit cli.ts▶ next · edit README.md↻ replay▶ next · edit cli.ts
1 of 3 · you edit src/lib/utils.ts→ running 2 of 3 test files
TESTdate.testTESTauth.testTESTcli.testdate.tsauth.tscli.tsutils.tsREADME.md
Same graph, three edits. Bun resolves the import graph once (skipping `node_modules`), then walks it backwards from whatever `git diff` reports.
`bun test --retry` v1.3.3v1.4.0#
`test()` accepts `{ retry: n }` to re-run a flaky test up to `n` times, and `{ repeats: n }` to run it `n` times and fail if any run fails.
`bun test --retry <N>` sets a default for the whole suite. #23713 #26866
test(
"flaky network call",
async () => {
await fetch("https://example.com");
},
{ retry: 5 },
);test(
"stress",
() => {
if (Math.random() < 0.1) throw new Error("uh oh!");
},
{ repeats: 20 },
);
`jest.useFakeTimers()` v1.3.4v1.4.0#
`jest.useFakeTimers()` lets you control `setTimeout`, `setInterval`, and `Date` from your tests.
`@testing-library/react`'s `waitFor` detects the fake timers and advances them instead of waiting in real time. #23764 #25915
import { jest, test, expect } from "bun:test";test("debounce", () => {
jest.useFakeTimers();
let called = 0;
setTimeout(() => called++, 1000);
jest.advanceTimersByTime(1000);
expect(called).toBe(1);
jest.useRealTimers();
});
`jest.setSystemTime()` works with `advanceTimersByTime()`, and `Bun.cron` schedules can be driven by the fake clock. #33623
`bun build`#
Built-in React Compiler v1.4.0#
`bun build --react-compiler` (or `reactCompiler: true` in `Bun.build()`) runs React's auto-memoization compiler on your components and hooks with no Babel or SWC in the loop. The compiler runs inside Bun's own parser, so there is no separate parse/print round-trip.
On a large React codebase (\~860 components), enabling it adds 71 ms to the build (394 ms → 465 ms), about 20× faster than the Babel plugin's 9.15 s on the same input. A full `--compile` build finishes in 3.62 s vs 13.04 s (3.6×). #32504
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
reactCompiler: true,
});
\`bun build --react-compiler\` runs the React Compiler in Rust. On a large React codebase, it's 19x faster than the Babel plugin
Barrel import optimization v1.3.10v1.4.0#
When you write `import { Button } from "antd"`, Bun skips the hundreds of files behind the names you didn't import.
Packages that declare `"sideEffects": false` get this automatically. For everything else, opt in with `optimizeImports`. #26892
await Bun.build({
entrypoints: ["./src/index.tsx"],
optimizeImports: ["antd", "@mui/material"],
});barrel import optimization · import { Button } from "antd"
Bun 1.3 · barrel not detectedBun 1.4 · optimizeImportsBun 1.4 · optimizeImportsmodules parsed: 4 · 6 ms▶ play· playing ·▶ with optimizeImports↻ replay▶ play
AffixAlertAnchorAppAutoCompleteAvatarBackTopBadgebutton/styleButton\_util/waveconfig-providerCarouselCascaderCheckboxColCollapseColorPickerConfigProviderDatePickerDescriptionsDividerDrawerDropdownEmptyFlexFloatButtonFormGridImageInputInputNumberLayoutListMentionsMenuModalPaginationPopconfirmPopoverProgressQRCodeRadioRateResultRowSegmentedSelectSkeletonSliderSpaceSpinStatisticStepsSwitchTableTabsTagTimelineTooltipbutton/styleButton\_util/waveconfig-providerDatePickerFormModalSelectTable
import { Button } from "antd"
antd/es/index.js
barrel · 312 re-exports
60 of antd’s \~70 top-level exports shown. Module count and timing measured with Bun.build() on this exact import — #26892.
Compile-time feature flags with `bun:bundle` v1.3.5#
`feature("FLAG")` from `bun:bundle` becomes `true` or `false` at build time, and the dead branch is removed.
Set flags with `--feature=FLAG` or `features: [...]` in `Bun.build()`. They work in `bun build`, `bun run`, and `bun test`. #25462
import { feature } from "bun:bundle";if (feature("SUPER_SECRET")) {
console.log("Secret feature enabled!");
}
// bun build --feature=SUPER_SECRET index.ts
In-memory files in `Bun.build()` v1.3.6#
`Bun.build()` accepts a `files` option: a map of paths to strings, `Blob`s, or `TypedArray`s. Use it to bundle entirely from memory or mix virtual modules with real files on disk — virtual paths take precedence. Handy for codegen, or for stubbing a module in tests without touching disk. #25852
await Bun.build({
entrypoints: ["/app/index.ts"],
files: {
"/app/index.ts": `import { greet } from "./greet.ts"; console.log(greet("World"));`,
"/app/greet.ts": `export function greet(name: string) { return "Hello, " + name + "!"; }`,
},
});Single-file HTML with `--compile --target=browser` v1.3.10#
`bun build --compile --target=browser` produces one HTML file with every script, stylesheet, and asset inlined.
You can double-click it and open it from `file://`, with no web server. #27056
bun build ./index.html --compile --target=browser --outdir=dist# → dist/index.html (everything inlined, zero external requests)`metafile: true` v1.3.6v1.4.0#
`Bun.build()` supports `metafile: true`, returning build metadata in esbuild's metafile format: a full map of inputs, outputs, imports, exports, and byte sizes. `result.metafile` works as-is with https://esbuild.github.io/analyze/ and anything else that reads esbuild's format. #25842
const result = await Bun.build({
entrypoints: ["./index.js"],
metafile: true,
});console.log(result.metafile.inputs);
console.log(result.metafile.outputs);
`--metafile-md` v1.3.8v1.4.0#
`bun build --metafile-md` writes the module graph as a Markdown report: a quick summary, the largest input files, per-entry-point breakdowns, dependency chains, and a grep-friendly raw section. The report is plain Markdown, so you can paste it into an LLM to ask why a bundle is large. #26441
bun build entry.js --metafile-md --outdir=distbun build entry.js --metafile-md=analysis.md --outdir=distbun build entry.js --metafile=meta.json --metafile-md=meta.md --outdir=distStandard TC39 decorators v1.3.10v1.4.0#
You can now use standard TC39 decorators in Bun.
function logged(value, { kind, name }) {
if (kind === "method") {
return function (...args) {
console.log(`calling ${name}`);
return value.call(this, ...args);
};
}
}class C {
@logged
greet() {}
}
These are the decorators you get when `experimentalDecorators` is off in `tsconfig.json`. They work on classes, methods, fields, accessors, and private members.
Bun passes the esbuild decorator test suite.
`--asset` v1.4.0#
`bun build --compile --asset <path>` embeds a file or a whole directory into the executable, keeping the original filenames.
Use it for a `public/` folder, templates, or a SvelteKit `client/` build. `path.join(import.meta.dir, ...)` finds them the same way it does on disk. #36302
`node:fs` now treats `/$bunfs/` as a real directory tree: `existsSync`, `statSync`, `lstatSync`, `accessSync`, `readdirSync`, and `fs.promises.readdir` (including `{ withFileTypes: true }` and `{ recursive: true }`) all work on embedded paths, so static-file servers that enumerate a directory at startup run unmodified inside a compiled binary.
bun build ./build/index.js --compile \--asset ./build/client --asset ./build/prerendered \
--outfile server./server # every route + static asset served from the binaryBytecode compilation for ES modules v1.3.9v1.4.0#
`--bytecode` now supports ES modules. `--bytecode --format=esm` requires `--compile`, and enables top-level await, `import.meta`, dynamic imports, and code splitting in bytecode-compiled binaries; previously `--bytecode` forced CommonJS output. #26402
Code splitting on 20,000-module graphs is 14× faster v1.4.0#
The code-splitting reachability walk is now BFS and O(V+E). A 20,000-module diamond-shaped DAG links in 320 ms, from 4.65 s. The tree-shaking liveness, TLA validation, CSS-order, and part-visitor passes run on explicit stacks. So linear import chains of thousands of modules link without stack growth. #35310 #34554
Faster#
Between Bun 1.3 and 1.4 we bumped our WebKit pin 39 times, pulling in roughly eight months of upstream JavaScriptCore work; the regex engine, Promises, and most String/Array builtins moved from self-hosted JavaScript to C++, and Bun swapped in zlib-ng and SIMD kernels for its own hot paths.
`new URL()` is up to 4.6× faster v1.4.0#
Bun's URL parser was rewritten. WebKit's new parser does the parsing. On Bun's side, `href` reuses the input string, the last base URL is cached, and hosts that are already ASCII punycode skip ICU.
| Operation | Bun 1.3 | Bun 1.4 | Node.js 26 | | --------------------------------------------- | ------- | ---------- | ---------- | | new URL("http://localhost:3000/api/users/42") | 349 ns | 75 ns | 232 ns | | new URL("../x", base) | 523 ns | 168 ns | 612 ns | | url.href | 16 ns | 5 ns | 8 ns |
Faster RegExp v1.4.0#
The RegExp performance gap between JavaScriptCore and V8 has been fixed.
`marked.parse()` gets 138× faster. On an 80 KB Markdown fixture, it runs in \~6 ms, from 912 ms.
isbot gets 200× faster. One call on a typical user agent takes 1.07 µs, from 218 µs in Bun 1.3. Node.js 26 takes 1.47 µs.
Benchmark code: isbot-bench.mjs
import { isbot } from "isbot"; // isbot@5.2.1const uas = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
"curl/8.7.1",
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)",
];
let hits = 0;
for (let i = 0; i < 20_000; i++)
for (const ua of uas) hits += isbot(ua) ? 1 : 0; // warm up
const N = 200_000;
const t0 = performance.now();
for (let i = 0; i < N; i++) for (const ua of uas) hits += isbot(ua) ? 1 : 0;
const ms = performance.now() - t0;
console.log(`${((ms 1e6) / (N uas.length)).toFixed(0)} ns/call`, hits);
`node:zlib` uses zlib-ng v1.3.13v1.4.0#
Bun now uses zlib-ng, the same library Node.js 24 and Chromium use, for `node:zlib`, gzipped `fetch()` responses, and everything else that compresses. It picks the fastest code path for your CPU at runtime. #29433
Time per call on 1 MB of JSON, default level:
| Encoding | Operation | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | -------- | -------------------- | ----------- | ----------- | ---------- | -------- | | gzip | gzipSync | 9.36 ms | 9.11 ms | 10.27 ms | 9.76 ms | | gzip | gunzipSync | 1.28 ms | 1.56 ms | 2.11 ms | 2.06 ms | | deflate | inflateSync | 1.21 ms | 1.54 ms | 1.95 ms | 2.07 ms | | brotli | brotliDecompressSync | 1.38 ms | 1.40 ms | 2.11 ms | 2.39 ms | | zstd | zstdCompressSync | 2.09 ms | 2.18 ms | 2.09 ms | 2.18 ms | | zstd | zstdDecompressSync | 0.81 ms | 0.73 ms | 1.57 ms | 1.65 ms |
Peak memory, same runs:
| Encoding | Operation | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | -------- | -------------------- | --------- | ------- | ---------- | -------- | | gzip | gzipSync | 50 MB | 74 MB | 75 MB | 69 MB | | gzip | gunzipSync | 62 MB | 94 MB | 125 MB | 129 MB | | deflate | inflateSync | 63 MB | 94 MB | 126 MB | 128 MB | | brotli | brotliDecompressSync | 73 MB | 110 MB | 129 MB | 130 MB | | zstd | zstdCompressSync | 55 MB | 79 MB | 77 MB | 71 MB | | zstd | zstdDecompressSync | 62 MB | 95 MB | 128 MB | 136 MB |
Compression speed depends on the input. On JSON, gzip compression is the same speed as Bun 1.3. On repetitive HTML, `gzipSync` on 1 MB takes 3.9 ms instead of 5.75 ms. Decompression is about 20% faster on everything, and peak memory is 25–35 MB lower.
Benchmark code: zlib-bench.mjs
// node:zlib benchmark: compress/decompress a JSON-like text buffer, one scenario per process.
// Usage: <runtime> zlib-bench.mjs <gzip|deflate|brotli|zstd> <sync|async> <compress|decompress> [level] [--bytes=N] [--iters=N]
// e.g. bun zlib-bench.mjs gzip sync compress --bytes=1048576 --iters=50
// node zlib-bench.mjs brotli async compress
// deno run -A zlib-bench.mjs zstd sync decompress 3
// --bytes: input size (default 64 MiB). --iters: timed iterations (default 1); with >1, warms up
// 5 iterations and reports the median. Prints one JSON line: {encoding, api, op, level, ms, iters,
// inputBytes, compressedBytes}. Wrap with `/usr/bin/time -v` to get peak RSS.
import * as zlib from "node:zlib";const flags = Object.fromEntries(
process.argv
.slice(2)
.filter((a) => a.startsWith("--"))
.map((a) => a.slice(2).split("=")),
);
const [encoding, api, op, levelArg] = process.argv
.slice(2)
.filter((a) => !a.startsWith("--"));
const level = levelArg === undefined ? undefined : Number(levelArg);
const TARGET = Number(flags.bytes ?? 64 1024 1024);
const ITERS = Number(flags.iters ?? 1);
// Deterministic JSON-lines text; a few fields vary per record so it is not trivially repetitive.
function makeInput() {
let seed = 0x9e3779b9;
const rnd = () => (seed = (seed 1103515245 + 12345) >>> 0) / 2 * 32;
const cities = [
"Berlin",
"Tokyo",
"Austin",
"Lagos",
"Lima",
"Oslo",
"Pune",
"Quito",
];
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
];
const parts = [];
let size = 0;
for (let i = 0; size < TARGET; i++) {
const tags = Array.from(
{ length: 3 },
() => words[(rnd() * words.length) | 0],
);
const rec = {
id: i,
uuid: `${((rnd() 2 * 32) >>> 0)
.toString(16)
.padStart(8, "0")}-4c1e-8a2b-${i.toString(16).padStart(12, "0")}`,
user: `user_${(rnd() * 50000) | 0}`,
email: `person${(rnd() * 1e6) | 0}@example.com`,
city: cities[(rnd() * cities.length) | 0],
score: Math.round(rnd() * 10000) / 100,
active: rnd() > 0.5,
tags,
ts: 1700000000000 + ((rnd() * 1e9) | 0),
note:
"lorem ipsum dolor sit amet, consectetur adipiscing elit " +
words[i % words.length],
};
const line = JSON.stringify(rec) + "\n";
parts.push(line);
size += line.length;
}
return Buffer.from(parts.join(""), "latin1");
}
const fns = {
gzip: [zlib.gzipSync, zlib.gzip, zlib.gunzipSync, zlib.gunzip],
deflate: [zlib.deflateSync, zlib.deflate, zlib.inflateSync, zlib.inflate],
brotli: [
zlib.brotliCompressSync,
zlib.brotliCompress,
zlib.brotliDecompressSync,
zlib.brotliDecompress,
],
zstd: [
zlib.zstdCompressSync,
zlib.zstdCompress,
zlib.zstdDecompressSync,
zlib.zstdDecompress,
],
};
const [cSync, cAsync, dSync, dAsync] = fns[encoding];
if (!cSync) {
console.log(JSON.stringify({ encoding, api, op, error: "unsupported" }));
process.exit(0);
}
const opts =
level === undefined
? {}
: encoding === "brotli"
? { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } }
: { level };
const input = makeInput();
const compressed = op === "decompress" ? cSync(input, opts) : null;
const [syncFn, asyncFn, data] =
op === "decompress" ? [dSync, dAsync, compressed] : [cSync, cAsync, input];
const once = () =>
new Promise((resolve, reject) => {
const t0 = performance.now();
if (api === "sync")
return resolve([syncFn(data, opts), performance.now() - t0]);
asyncFn(data, opts, (err, out) =>
err ? reject(err) : resolve([out, performance.now() - t0]),
);
});
let out,
times = [];
if (ITERS > 1) for (let i = 0; i < 5; i++) await once();
for (let i = 0; i < ITERS; i++) {
const [o, ms] = await once();
out = o;
times.push(ms);
}
times.sort((a, b) => a - b);
const ms = times[times.length >> 1];
console.log(
JSON.stringify({
encoding,
api,
op,
level: level ?? "default",
ms: Math.round(ms * 1000) / 1000,
iters: ITERS,
inputBytes: input.length,
compressedBytes: op === "decompress" ? compressed.length : out.length,
}),
);
`Buffer.from(str, "hex")` is 8× faster and `"base64url"` 46× faster v1.4.0#
`Buffer.from(str, "hex")` and `Buffer.from(str, "base64url")` decode with SIMD.
Decoding 1 MiB:
| Encoding | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | --------- | ---------- | -------- | ---------- | -------- | | hex | 128 µs | 1,035 µs | 743 µs | 3,863 µs | | base64url | 84 µs | 3,897 µs | 68 µs | 104 µs |
Decoding 128 KiB:
| Encoding | Bun 1.4 | Bun 1.3 | Node.js 26 | Deno 2.9 | | --------- | ----------- | -------- | ---------- | -------- | | hex | 15.1 µs | 130.6 µs | 102.7 µs | 494.0 µs | | base64url | 11.6 µs | 486.2 µs | 17.0 µs | 14.4 µs |
Benchmark code: buffer-from-bench.mjs
import { Buffer } from "node:buffer";const sizes = [1024, 128 1024, 1024 1024];
const raw = (n) => {
const b = Buffer.alloc(n);
for (let i = 0; i < n; i++) b[i] = (i * 2654435761) >>> 24;
return b;
};
for (const enc of ["hex", "base64", "base64url"]) {
for (const n of sizes) {
const str = raw(n).toString(enc);
let sink = 0;
for (let i = 0; i < 200; i++) sink += Buffer.from(str, enc).length; // warm up
const iters = n >= 1024 1024 ? 300 : n >= 128 1024 ? 2000 : 100000;
const times = [];
for (let rep = 0; rep < 5; rep++) {
const t0 = performance.now();
for (let i = 0; i < iters; i++) sink += Buffer.from(str, enc).length;
times.push(((performance.now() - t0) * 1000) / iters);
}
times.sort((a, b) => a - b);
console.log(enc, n / 1024, "KiB", times[2].toFixed(2), "µs/op");
}
}
Source map decoding is 3.1× faster v1.4.0#
Source map decoding uses SIMD. `new SourceMap(json)` on a 9.5 MB map takes 12 ms, 3.1× faster than before and 24× faster than Node.js. #32556
\`new SourceMap(payload)\` & sourcemap decoding gets up to 3x faster
Promises are 1.5–2.4× faster v1.4.0#
JavaScriptCore rewrote their Promise implementation to reduce overhead.
Time per operation, 2 million iterations:
| Operation | Bun 1.4 | Bun 1.3 | | -------------------------------- | ---------- | ------- | | Promise.race of 4 promises | 142 ns | 342 ns | | Promise.all of 4 promises | 207 ns | 316 ns | | Promise.allSettled of 4 promises | 253 ns | 411 ns | | await a resolved promise | 84 ns | 143 ns | | .then() chain of 4 | 172 ns | 332 ns | | async function with no await | 37 ns | 89 ns |
Memory: 1,000,000 pending promises resolved at once.
| | Bun 1.4 | Bun 1.3 | | -------------- | ----------- | ------- | | Peak memory | 251 MB | 668 MB | | Time to settle | 12.5 ms | 39.0 ms |
Benchmark code: promise-bench.mjs and promise-rss.mjs
// Promise microbenchmarks. Prints ns/op per operation as JSON.
// Usage: bun promise-bench.mjs | node promise-bench.mjs | deno run -A promise-bench.mjs
const WARMUP = 1e5;
const ITERS = 2e6;const p1 = Promise.resolve(1),
p2 = Promise.resolve(2),
p3 = Promise.resolve(3),
p4 = Promise.resolve(4);
const arr = [p1, p2, p3, p4];
async function noAwait(x) {
return x;
}
const benches = {
"Promise.race (4 resolved)": () => Promise.race(arr),
"Promise.all (4 resolved)": () => Promise.all(arr),
"Promise.allSettled (4 resolved)": () => Promise.allSettled(arr),
"await resolved promise": async () => {
await p1;
},
".then() chain of 4": () =>
p1
.then((x) => x)
.then((x) => x)
.then((x) => x)
.then((x) => x),
"async fn, no await": () => noAwait(1),
};
async function time(fn, n) {
const t0 = performance.now();
for (let i = 0; i < n; i++) await fn();
return ((performance.now() - t0) * 1e6) / n;
}
const results = {};
for (const [name, fn] of Object.entries(benches)) {
await time(fn, WARMUP);
results[name] = Math.round((await time(fn, ITERS)) * 10) / 10;
}
console.log(JSON.stringify(results));
// promise-rss.mjs: peak memory of 1,000,000 pending promises resolved at once
const N = 1_000_000;
const resolvers = [];
const promises = Array.from(
{ length: N },
() => new Promise((r) => resolvers.push(r)),
);
const all = Promise.all(promises);
for (const r of resolvers) r(1);
const t0 = performance.now();
await all;
console.log(
`${N} promises settled in ${(performance.now() - t0).toFixed(1)} ms`,
);Security#
Bun 1.4 includes a lot of security fixes. We recommend everyone update. Most of them change nothing you'd notice. They are listed under Security hardening in the changelog. The handful below tighten a default, in most cases a TLS certificate check. That can turn a connection that worked on 1.3 into a verification error. Advisories will go up on GitHub once people have had time to upgrade.
`checkServerIdentity` runs before `fetch()` sends the request v1.4.0#
When you pass `tls: { checkServerIdentity }` to `fetch()`, the callback runs after the TLS handshake and before any of the request is written, and again on each redirect hop. If it returns an `Error`, `fetch()` rejects with that error and nothing is sent.
await fetch("https://api.example.com/upload", {
method: "POST",
body: secretPayload,
tls: {
checkServerIdentity(hostname, cert) {
if (cert.fingerprint256 !== PINNED) return new Error("pin mismatch");
},
},
});
// nothing is sent until checkServerIdentity returns undefinedIf you pin a certificate this way and the URL redirects through a host with a different one, the callback now sees that certificate too, so either accept every hop's certificate there or pass `redirect: "manual"` and follow `Location` yourself.
`tls.connect` now uses `host` as the default `servername` v1.3.13#
tls.connect({ host, port }) without a `servername` now uses `host` for both SNI and the certificate identity check. This matches Node.js. Connecting by IP address or to `localhost` now fails with `ERR_TLS_CERT_ALTNAME_INVALID` when the certificate was issued for another name. This applies whether you call `tls.connect()` yourself or a driver like `pg` or `ioredis` does. Pass the certificate's name as `servername`. Or pass `checkServerIdentity: () => undefined` if you deliberately trust the server by its CA alone.
tls.connect({
host: "10.0.0.12",
port: 5432,
ca,
servername: "db.internal",
});`Bun.connect` and `Bun.listen` enforce `rejectUnauthorized` v1.4.0#
Bun.connect({ tls }), `socket.upgradeTLS()`, and `Bun.listen()` with `requestCert: true` now default to `rejectUnauthorized: true`, as `node:tls` and `fetch()` do. The usual case is a `Bun.connect()` to a dev or staging server with a self-signed or private-CA certificate and no `ca`. It does not throw. The `handshake` handler runs with `socket.authorized` set to `false`. Writes return `-1`. The socket closes without delivering data. Pass the CA in `tls`, or pass `rejectUnauthorized: false` (`NODE_TLS_REJECT_UNAUTHORIZED=0` is honored here too).
`RedisClient` enforces TLS hostname verification v1.3.14#
A `rediss://` `RedisClient` checks the server certificate against the host in the URL, as the Postgres and MySQL clients do, and rejects the first command with `ERR_TLS_CERT_ALTNAME_INVALID` on a mismatch. If you reach Redis by IP or through a port-forward to `localhost`, connect by the name on the certificate instead, or pass `tls: { rejectUnauthorized: false }`.
HTTP request parsing hardening in `Bun.serve` v1.3.4#
`Bun.serve()` answers `400` and closes the connection for more kinds of malformed `Content-Length` and `Transfer-Encoding` headers and chunked bodies. Browsers, curl, `fetch()` and reverse proxies send none of them. If a hand-written client starts getting `400` responses, look at its framing headers first; Bun does not call your `fetch` handler or log anything for most of these.
Tarball extraction hardening v1.3.6#
Tarball extraction for `github:` and URL dependencies and `bun create` templates skips entries that would land outside the package directory. If one of those is missing a file after the upgrade and you get `Cannot find module` for it, look in its repo for a symlink that points outside the package, and replace it with the real file or a relative link.
Platforms#
Native FreeBSD builds v1.3.14v1.4.0#
Bun now ships official FreeBSD binaries for x86\_64 and aarch64. On FreeBSD 14.3+ the full runtime (`Bun.serve()`, `fetch()`, `node:fs`, `node:os`, `Bun.spawn`) works on a stock install with no extra system packages. This is a native port built against FreeBSD's own kernel APIs, not a Linux compatibility layer. #29676
curl -fsSL https://bun.sh/install | bashuname -smFreeBSD amd64bun --version1.4.0Windows on ARM64 v1.3.7v1.4.0#
Bun now builds natively for Windows on ARM64. Surface, Snapdragon X, and Ampere-based Windows machines run Bun natively. #26215
PS> powershell -c "irm bun.sh/install.ps1|iex"
PS> $env:PROCESSOR_ARCHITECTURE
ARM64
PS> bun --version
1.4.0Experimental Android support v1.4.0#
Bun ships experimental Android builds for aarch64 and x64 with every release.
Linux glibc minimum drops to 2.17 v1.3.13#
Bun's minimum glibc requirement on Linux drops from 2.26 to 2.17. Bun now runs on RHEL/CentOS 7, Amazon Linux 1, and ARM64 Linux distributions without needing a separate compatibility build. #29461
ldd --versionldd (GNU libc) 2.17bun --version1.4.0Fallback for in-memory files on older Linux kernels v1.3.13#
On Linux kernels older than 3.17, like RHEL 7, Bun detects that `memfd_create` is missing once and falls back. The documented minimum kernel is now 3.10. #29465
BUN_FEATURE_FLAG_DISABLE_MEMFD=1 bun run server.tsReady for TypeScript 7#
`bun init` and the React templates ship a `tsconfig.json` that works with TypeScript 7, and `@types/bun` resolves cleanly against it. #28542 #39341
{
"compilerOptions": {
"types": ["bun"]
}
}Sub-15 ms timers on Windows v1.4.0#
On Windows, `setTimeout(fn, 1)` fires in about 1.4 ms instead of 15.5 ms. Timers no longer round to the 15.6 ms system tick. #34834
Improved timer accuracy on Windows makes \`Bun.sleep\` faster
Bun runs inside an AppContainer v1.4.0#
Bun runs inside a Windows AppContainer, so embedders can sandbox it with a lowbox token.
`bun install`, `bun run`, `Bun.spawn`, `child_process.fork`, and `Bun.Terminal` all work inside the container.
Bun also now works on read-only directories like Program Files and read-only network shares, and no longer fails when an ancestor directory isn't readable.
Upgrading to 1.4#
Most code is unaffected. Five changes are the most likely to need a line in your project:
Node.js 26: `NODE_MODULE_VERSION` `147`, `res.writeHeader()` removed, paused `read()` returns one chunk v1.4.0#
Bun now reports Node.js 26. Three things change:
res.writeHeader(200, { "Content-Type": "text/plain" });
res.writeHead(200, { "Content-Type": "text/plain" });x64 builds are now baseline-only v1.4.0#
x64 releases now ship only the baseline build. The separate build compiled with `-march=haswell` is gone. The `-baseline` download URLs and npm packages still exist and contain the same binary. Existing install scripts and `bun upgrade` keep working. The `CPU lacks AVX support` startup warning is removed. #34782
`Temporal` is now defined by default, and `toEqual()` compares Temporal objects by value v1.4.0#
`Temporal` and `Date.prototype.toTemporalInstant` are now defined. Set `BUN_JSC_useTemporal=0` to turn them off. `Bun.deepEquals()`, `toEqual()`, `toStrictEqual()`, and `util.isDeepStrictEqual()` now compare Temporal objects by value. Before, any two instances of the same class were equal. #32978 #37024
Bun.deepEquals(
Temporal.PlainDate.from("2020-01-01"),
Temporal.PlainDate.from("1999-12-31"),
); // false`bun:ffi`: `cstring` values are plain strings and `CString` no longer has `.ptr` v1.4.0#
`bun:ffi` is now engine-native. This changes four things:
const str = new CString(ptr);
my_library_free(str.ptr);
my_library_free(ptr);`bun build --compile` no longer auto-loads `tsconfig.json` or `package.json` at runtime v1.3.4#
Standalone executables built with `bun build --compile` no longer auto-load `tsconfig.json` or `package.json` from the runtime working directory. Before, a compiled binary could pick up unrelated config files from the directory it ran in. To opt back in, pass `--compile-autoload-tsconfig` / `--compile-autoload-package-json` (or `compile.autoloadTsconfig` / `compile.autoloadPackageJson` in `Bun.build()`). `.env` and `bunfig.toml` still auto-load by default. They keep their existing `--compile-autoload-dotenv` / `--compile-autoload-bunfig` flags. #25340
`bun install` defaults to the isolated linker for new monorepos v1.3.2#
New monorepos (projects with workspaces) now use `linker: "isolated"`. This is a symlinked `node_modules` layout that prevents phantom dependencies. `bun.lock` records a `configVersion`. Existing lockfiles (config version 0) keep the hoisted linker they were created with. Your `node_modules` layout does not change on upgrade. #24236
# bunfig.toml: pin the old behavior if you need it
[install]
linker = "hoisted"`bun.lock` is now `lockfileVersion: 2` v1.4.0#
New lockfiles use version 2. Version 2 adds two stricter parse-time checks:
Lockfiles written as v0/v1 keep loading without these checks. Existing projects do not break. Run `bun install` to migrate.
{
"lockfileVersion": 1,
"lockfileVersion": 2,
"workspaces": { ... },Bun invoked as `node` no longer loads `.env` files v1.4.0#
When Bun runs as `node` (under `bun --bun`, `bunx --bun`, or a `node` symlink to Bun), it no longer loads `.env`, `.env.local`, or `.env.{development,production,test}`. This matches Node.js. `bun file.js` still loads them. A `package.json` script that calls `node` under `bun --bun run` now sees those variables as `undefined`. To keep them, pass `--env-file` to `node`. #36610
"scripts": {
"check": "node ./check.js"
"check": "node --env-file=.env ./check.js"`Bun.YAML` now parses `yes`/`no`/`on`/`off` as strings, not booleans v1.3.5#
`Bun.YAML` now parses booleans per the YAML 1.2 spec. `yes`/`no`/`on`/`off`/`y`/`Y` are plain strings, not booleans. These are YAML 1.1 legacy values that the 1.2 spec dropped. An `on:` key in a GitHub Actions workflow file now parses as the string `"on"`, not `true`. Only `true`/`True`/`TRUE` and `false`/`False`/`FALSE` resolve to booleans. #25537
Bun.YAML.parse("on: push");
// { on: "push" }`Bun.TOML.parse()` and `bunfig.toml` are stricter and throw `SyntaxError` v1.4.0#
The rewritten Bun.TOML parser throws `SyntaxError` instead of `BuildMessage`. It rejects TOML that the old parser let through:
A `bunfig.toml` with an unquoted value now fails at startup with `TOML Parse error: Strings must be quoted`. Quote the value. #32953
[install]
linker = isolated
linker = "isolated"`.xml` imports now return the parsed document instead of the file path v1.4.0#
`import` or `require()` of a `.xml` file now returns the same object as Bun.XML.parse(). This applies at runtime and in `bun build`. Before, it returned the file's path. A file that does not parse throws at runtime and fails the build. To keep getting the path, pass `--loader .xml:file`. #37048
`import "."` and `import ".."` now resolve as directories v1.4.0#
`"."` and `".."` in `import` and `require()` now resolve to the directory's index file or `package.json` `main`. This matches Node.js. Before, they resolved to a sibling file with the directory's name. So `"."` inside `lib/run.ts` loaded `lib.ts`; it now loads `lib/index.ts`. To keep the sibling, name it. #36969
import { e } from ".";
import { e } from "../lib";`.css` imports at runtime now export `{}` instead of the file path v1.4.0#
At runtime, the default export of a `.css` import is now `{}`. This applies to `import`, `require()`, dynamic `import()`, and Workers. Before, it was the file's absolute path as a string. `bun build` already emitted `{}`. `.module.css` still differs from `bun build`, which emits a class-name map. #35163
`"jsx": "react-jsx"` in `tsconfig.json` now emits `jsx` instead of `jsxDEV` v1.4.0#
With `"jsx": "react-jsx"`, `bun run` and `bun build` now import `jsx` and `jsxs` from `<pkg>/jsx-runtime`. Before, both imported `jsxDEV` from `<pkg>/jsx-dev-runtime` unless `NODE_ENV=production` or `--production` was set. An explicit `NODE_ENV` still wins. To keep the development runtime, set `"jsx": "react-jsxdev"`. #34422
{
"compilerOptions": {
"jsx": "react-jsx"
"jsx": "react-jsxdev"
}
}`useDefineForClassFields: false` in `tsconfig.json` is now honored v1.4.0#
With `useDefineForClassFields: false`, Bun now does what tsc does:
Before, the option was ignored. An initializer that reads a parameter property now works instead of throwing. Private and decorated fields keep their declarations. Static fields and classes with a computed non-literal field key are left as they were. To keep the old output, remove the option. #36664
`Bun.Socket#setKeepAlive()` now treats `initialDelay` as milliseconds v1.4.0#
`setKeepAlive(true, delay)` on a `Bun.Socket` now divides `delay` by 1000 before setting `TCP_KEEPIDLE`, as documented. Before, the raw value was used as seconds, so `4000` meant 4000 seconds. A value under 1000 now divides to `0` and leaves `TCP_KEEPIDLE` unchanged. Code that passed seconds should pass milliseconds. `setKeepAlive(true)` now returns `true` instead of `false`. `net.Socket#setKeepAlive()` still sets the same kernel value as before. #34269
socket.setKeepAlive(true, 60);
socket.setKeepAlive(true, 60_000);`Bun.mmap({ offset })` now starts the view at `offset` v1.4.0#
`Bun.mmap(path, { offset })` now returns a view whose index 0 is the byte at `offset`. Before, `offset` was rounded down to a page boundary. The view started at that boundary, for reads and for writes through `{ shared: true }`. Remove any `offset % pageSize` adjustment you added to compensate. #34120
const m = Bun.mmap("data.bin", { offset: 100 });
m[0]; // byte 0 of the file
m[0]; // byte 100 of the file`Bun.cron.parse()` and in-process `Bun.cron()` now use local time v1.4.0#
Bun.cron.parse() and the in-process `Bun.cron(schedule, handler)` overload now read schedules in the process's local time zone. Before, they used UTC. This matches the OS-registered overload. `"0 9 *"` under `TZ=America/Los_Angeles` now means 9:00 Pacific. To keep the old times, pass `{ tz: "UTC" }`. Both accept it as a new final argument. #35122
Bun.cron("0 9 *", handler);
Bun.cron("0 9 *", handler, { tz: "UTC" });`Bun.$` now globs only patterns written in the template itself v1.4.0#
Glob characters that arrive through `${...}`, a shell variable, command substitution, or quoted text are now literal. Only ``, ``, and braces written directly in the template expand. `?`, `[...]`, and a leading `!` are literal everywhere. Before, `` $`echo ${"/"}` `` matched recursively. It now fails with `no matches found`. Write the pattern in the template instead. #31220
await $`echo ${"*/"}`;
await $`echo */`;`fs.rmdir` no longer accepts `{ recursive: true }` v1.4.0#
Passing `recursive: true` to `fs.rmdir` now throws `ERR_INVALID_ARG_VALUE`. This matches Node.js, which removed the option after a long deprecation. Use fs.rm instead. #31830
await fs.rmdir("build", { recursive: true });
await fs.rm("build", { recursive: true, force: true });`X509Certificate` serial and modulus are now uppercase hex v1.4.0#
`X509Certificate#serialNumber`, `.toLegacyObject().modulus`, and `tls.TLSSocket#getPeerCertificate()` now return uppercase hex. This matches Node.js and `openssl x509 -serial`. If you pin certificates against a lowercase serial string, normalize the case first. #31519
const { serialNumber } = new X509Certificate(pem);
// "3b8e2a..."
// "3B8E2A..."`tls.createServer({ requestCert: true })` now rejects unverified client certificates v1.4.0#
A `node:tls` server with `requestCert: true` and no explicit `rejectUnauthorized` now applies the default of `true`. A connection whose client certificate does not verify is destroyed, and the server emits `tlsClientError`. Before, it reached your handler with `authorized: false`. To keep admitting those clients, pass `rejectUnauthorized: false`. #31322
tls.createServer({
ca,
requestCert: true,
rejectUnauthorized: false,
});`dgram.Socket` now throws synchronously on a second `bind()` and after `close()` v1.4.0#
Two `node:dgram` changes, both matching Node.js:
Code that handled a second `bind()` in an `error` listener needs a `try`/`catch`. #33037 #33024
`dns.lookup()` now uses the system resolver on Linux v1.4.0#
On Linux, `dns.lookup()`, `dns.promises.lookup()`, and hostname resolution in `net.connect()` now go through `getaddrinfo()`, as in Node.js. Before, they used c-ares. Names that only `systemd-resolved` or a split-DNS VPN knows now resolve. Before, they failed with `getaddrinfo EREFUSED`. `dns.setServers()` no longer affects these calls. `dns.resolve*()` and `Bun.dns.lookup()` still use c-ares. If you need the old behavior for a lookup, pass `{ backend: "c-ares" }` to `Bun.dns.lookup()`. #37383
Exceptions thrown in `node:fs`, `node:dns`, and `crypto.pbkdf2` callbacks are now `uncaughtException` v1.4.0#
An exception thrown inside a `node:fs`, `node:dns`, or `crypto.pbkdf2()` callback now reaches `process.on("uncaughtException")`, as in Node.js. Before, it surfaced as an `unhandledRejection`. A handler registered there no longer sees it. Move the handler. #34660
fs.readFile("config.json", () => { throw new Error("bad config"); });
process.on("unhandledRejection", onError);
process.on("uncaughtException", onError);`net.Server` and `tls.Server` no longer auto-resume accepted sockets; `tls.Server` checks `requestCert` and `rejectUnauthorized` literally v1.4.0#
tls.createServer({
key,
cert,
ca,
requestCert: 1,
rejectUnauthorized: null,
requestCert: true,
rejectUnauthorized: false,
});`fetch()` responses and `Bun.serve` requests now combine duplicate headers with `, ` v1.4.0#
Duplicate headers on a `fetch()` response or a `Bun.serve` request are now joined with `, `, per the Fetch spec. Before, only the last value was kept. Common headers were already combined. This change affects the rest, including every custom header. `fetch()` responses also keep empty values now. A header sent with no value reads `""` instead of `null`. `Set-Cookie` still comes back as separate values from `getSetCookie()`. #31734
// X-Dup: first
// X-Dup: second
res.headers.get("x-dup");
// "second"
// "first, second"`Request#clone()` and `Response#clone()` now throw once the body has been read v1.4.0#
`clone()` on a `Request` or `Response` whose body has been read, or whose stream is locked, now throws `TypeError: Body is disturbed or locked` (`ERR_BODY_ALREADY_USED`). This is per the Fetch spec. It includes the request passed to `Bun.serve` route handlers. Before, `clone()` succeeded and the problem showed up later, as an empty body or an error when the clone was read. Call `clone()` before reading the body. #33129
const text = await req.text();
const copy = req.clone();
const copy = req.clone();
const text = await req.text();`fetch()` network errors are now `TypeError`, and a failed body read sets `bodyUsed` v1.4.0#
`fetch()` and response body reads now reject a network error with a `TypeError`. Before, it was a plain `Error`. `.code` (for example `ECONNRESET`) is still set. After a body read fails, `bodyUsed` is `true`. A second read rejects with `ERR_BODY_ALREADY_USED` instead of the socket error. Issue a new `fetch()` to retry. `fetch(request)` with a request whose stream body was already used now rejects with the same `TypeError` before connecting. #35855 #36499
const res = await fetch(url); // connection drops mid-body
await res.text(); // rejects with Error, code "ECONNRESET"
await res.text(); // rejects with TypeError, code "ECONNRESET"`Bun.serve({ inspector })` has been removed v1.3.14#
The undocumented `inspector: true` option is now silently ignored. It mounted a `/bun:inspect` debugger WebSocket on your HTTP port. It predated `bun --inspect` and was never in the public types. Use the \--inspect flag to attach a debugger. #29613
Bun.serve({ inspector: true, fetch });
Bun.serve({ fetch });bun --inspect server.ts`server.publish()` and `ws.publish()` now return `0` or `-1` under backpressure v1.4.0#
`server.publish()`, `ws.publish()`, `ws.publishText()`, and `ws.publishBinary()` now return:
Before, they returned the byte count whenever the topic had a subscriber, even when the data was discarded. Code that compares the return value against the byte count should treat `0` as dropped and `-1` as queued. #32889
`server.stop()` now closes idle connections and waits for in-flight requests v1.4.0#
`server.stop()` now closes idle keep-alive connections immediately. It closes busy ones once their response is sent. It resolves when the last connection has closed. Before, it closed only the listener and resolved while requests were still being served. It now stays pending on a connection that has sent part of a request and stopped. `server.stop(true)` closes such connections. It now works after a graceful `stop()` too. #35130 #37074
`WebSocket` (global) no longer accepts an `agent` option v1.3.6#
The non-standard `agent` option on the Web-standard `WebSocket` constructor is removed. Node.js's global `WebSocket` uses an undici `dispatcher`, not an `http.Agent`. The ws package's `WebSocket`, which Bun polyfills natively, now accepts `agent` instead. This matches its documented API. #25935
const ws = new WebSocket(url, { agent }); // global
import WebSocket from "ws";
const ws = new WebSocket(url, { agent }); // ws module`WebSocket#close()`, `ping()`, and `pong()` now validate their arguments v1.4.0#
`close()` now throws `InvalidAccessError` for a code other than `1000` to `1003`, `1007` to `1014`, or `3000` to `4999`. It throws `SyntaxError` for a reason longer than 123 UTF-8 bytes. Before, an invalid code went out unchecked. With the default code, an over-long reason was silently sent as empty. `ping()` and `pong()` on the `WebSocket` client, `ServerWebSocket`, and the ws package now throw `RangeError` for a payload over 125 bytes. Before, they sent it. Shorten the reason or payload. #32820 #35030
`WebSocket` now fails the handshake if a requested subprotocol is not negotiated v1.4.0#
`new WebSocket(url, protocols)` now closes with code `1002` when the server's `101` response omits `Sec-WebSocket-Protocol`. This is per RFC 6455 and matches browsers. Before, it opened with `ws.protocol === ""`. Fix the server to echo a protocol, or stop passing `protocols`. Connections that request no subprotocol are unaffected. #33072
`WebSocket#close()` no longer fires `close` before it returns v1.4.0#
`ws.close()` and `ws.terminate()` on a `WebSocket` client now queue the `close` event, as in Node.js and browsers. When the call returns, `readyState` is `CLOSING` and `onclose` has not run yet. Code that read `CLOSED` on the next line, or relied on `onclose` having run, should await the `close` event instead. #27259
ws.close();
ws.readyState; // 3, CLOSED
ws.readyState; // 2, CLOSING`jest.resetAllMocks()` now drops mock implementations v1.4.0#
`jest.resetAllMocks()` and `vi.resetAllMocks()` now reset every mock's implementation as well as its call history. This matches Jest. Before, they behaved like `clearAllMocks()`. After the reset, a `jest.fn(() => 42)` returns `undefined`. A `spyOn()` spy returns `undefined` until `mockRestore()`. If you only want the call history cleared, call `clearAllMocks()`. #33374
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});`expect().toContain()` now compares with `===` instead of `Object.is` v1.4.0#
`toContain()` in `bun:test` now compares array and iterable elements with `===` instead of `Object.is`. This matches Jest. `expect([-0]).toContain(0)` passes and `expect([NaN]).toContain(NaN)` fails. `toBe()` still uses `Object.is`. `toContainEqual()` still uses deep equality. #32950
expect(values).toContain(NaN);
expect([...values].some(Number.isNaN)).toBe(true);`Bun.sql` now decodes MySQL `DATETIME` and `TIMESTAMP` as UTC v1.4.0#
MySQL `DATETIME` and `TIMESTAMP` columns are now decoded as UTC. This matches how `Bun.sql` encodes them, so a `Date` round-trips unchanged. Before, it came back shifted by the machine's UTC offset on any host not running in UTC. Postgres `timestamp` read through `.simple()` is decoded as UTC too. `timestamptz` is unaffected. Remove any offset correction you added. #31212
await sql`INSERT INTO t (dt) VALUES (${new Date("2024-06-15T12:00:00Z")})`;
const [{ dt }] = await sql`SELECT dt FROM t`;
dt.toISOString(); // "2024-06-15T16:00:00.000Z" under TZ=America/New_York
dt.toISOString(); // "2024-06-15T12:00:00.000Z"`Bun.sql` now parses MariaDB 10.5+ `JSON` columns instead of returning strings v1.4.0#
On MariaDB 10.5 and later, `Bun.sql` now parses `JSON` columns and JSON function results such as `JSON_OBJECT()` and `JSON_EXTRACT()`. Before, it returned the JSON text as a string. A `json` column holding `{"b": 1}` now reads as the object `{ b: 1 }`. Remove the `JSON.parse()`. #37130
const [row] = await sql`SELECT a FROM t`;
const a = JSON.parse(row.a);
const a = row.a; // { b: 1 }Other behavior changes v1.4.0#
* odd-length hex passed to `Bun.CryptoHasher#update()` * a primitive `options` argument to `TextDecoder#decode()` * invalid arguments to `crypto.createDiffieHellman()` (before, returned as an error object) * `NaN` or `undefined` seconds in `RedisClient#expire()` (before, sent `EXPIRE key 0`) * fractional or beyond-32-bit ports for `Bun.udpSocket()`, and `cost`, `timeCost`, or `memoryCost` values for `Bun.password` (before, wrapped or truncated into range) * `Bun.openInEditor()` with no editor found (before, returned silently) * an `fs.write()` `offset` past the end of the buffer when `length` is omitted (before, wrote 0 bytes) #35188 #35189 #36508 #36835 #36999 #37210 #37632
Changelog#
Everything below is the long tail: smaller features, compatibility fixes, and bug fixes, grouped by area. See the full changelog for the complete list.
Runtime#
#### `ServerWebSocket.subscriptions` v1.3.2
A new `subscriptions` getter returns an array of every topic the socket is currently subscribed to. #24299
`bun repl` is now native. It is built directly into the Bun binary instead of lazily downloading a separate npm package on first run. It ships a full TUI:
It supports top-level `await` and the `_`/`_error` special variables. Bare object literals work too: `{ a: 1 }` no longer needs to be wrapped in parens. #26304
❯bun repl↻ replay
Welcome to Bun v1.4.0
Type ".help" for more information.
\> { a: 1 }
{ a: 1 }
\> await fetch("https://bun.sh").then(r => r.status)
200
\> \_
200
`bun repl` now supports `-e <script>` to evaluate and `-p <script>` to evaluate and print, with full REPL semantics. Shell completions for `repl` ship for bash, fish, and zsh, and there's a new REPL docs page. #27436
bun repl -e 'console.log(1 + 1)'2bun repl -p '{ a: 1, b: 2 }'{ a: 1, b: 2 }bun repl -p 'await fetch("https://bun.sh").then(r => r.status)'200#### `bun ./README.md` v1.3.12
Bun pretty-prints Markdown files directly to your terminal: headings, tables, task lists, blockquotes, syntax-highlighted code blocks, and clickable hyperlinks, with correct alignment for emoji and Chinese/Japanese/Korean characters. No JavaScript VM is started. #28833
bun ./README.md❯bun ./README.md
project ═══════
│ Render Markdown to the terminal with bun ./README.md.
┌───────────────┬──────────────────────────┐ │ Command │ What it does │ ├───────────────┼──────────────────────────┤ │ bun run build │ parallel build:* scripts │ │ bun profile │ write a .cpuprofile │ └───────────────┴──────────────────────────┘
┌─ ts │ Bun_.serve_({ │ fetch: () => new Response("hello"), │ }); └─ ☒ tables ☒ task lists ☐ you, running bun ./README.md (https://bun.com)
Bun ships a native JSON5 parser. Bun.JSON5.parse() and `Bun.JSON5.stringify()` are built in, and `.json5` files can be imported directly in both the runtime and the bundler with no npm package needed. It passes the official JSON5 test suite. #26439
import config from "./config.json5";const data = Bun.JSON5.parse(`{
// comments work
unquoted: 'single quotes',
trailing: [1, 2, 3,],
}`);
#### `Bun.JSONL` v1.3.7
Bun.JSONL is a built-in newline-delimited JSON parser. It is implemented in C++ on top of JavaScriptCore's optimized JSON parser. `Bun.JSONL.parse()` parses a complete JSONL string or `Uint8Array` into an array. `Bun.JSONL.parseChunk()` parses as many complete values as possible from streaming input. It returns `{ values, read, done, error }`, so you can resume where you left off without losing partial results. It parses ASCII input without an extra copy, skips UTF-8 BOMs automatically, and guards against inputs larger than 4 GB. #26356
const results = Bun.JSONL.parse('{"a":1}\n{"b":2}\n');
// [{ a: 1 }, { b: 2 }]const chunk = Bun.JSONL.parseChunk('{"id":1}\n{"id":2}\n{"id":3');
chunk.values; // [{ id: 1 }, { id: 2 }]
chunk.read; // 17
chunk.done; // false
#### `Bun.JSONC.parse()` v1.3.6v1.4.0
Bun.JSONC.parse() parses JSON with `//` and `/ /` comments and trailing commas. It's the same parser Bun uses internally to read `tsconfig.json`, exposed as a runtime API alongside `Bun.YAML` and `Bun.TOML`. #22115
const config = Bun.JSONC.parse(`{
// This is a comment
"name": "my-app",
"dependencies": {
"react": "^18.0.0", // trailing comma allowed
},
}`);#### `Bun.XML` v1.4.0
Bun.XML is a native XML parser. `Bun.XML.parse()` and `Bun.XML.stringify()` are built in, and `.xml` files can be imported directly in both the runtime and the bundler, alongside `Bun.TOML`, `Bun.YAML`, and `Bun.JSON5`. Parsing is \~5× faster than `fast-xml-parser` and `xml2js` on \~200 KB feeds. #37048
import feed from "./atom.xml";Bun.XML.parse(`<order id="A1"><item>Tea</item><item>Mug</item><paid/></order>`);
// { order: { "@id": "A1", item: ["Tea", "Mug"], paid: "" } }
Bun.XML.parse(`<p>Hi <b>you</b></p>`, { compact: false });
// { name: "p", attributes: {}, children: ["Hi ", { name: "b", ... }] }
#### `Bun.TOML` v1.4.0
Bun.TOML was rewritten for full TOML v1.1.0 conformance and now passes 708/708 cases in the official toml-test suite.
const cfg = Bun.TOML.parse(`
released = 2026-08-10T12:00:00Z[package]
name = "app"
`);
cfg.released; // "2026-08-10T12:00:00Z"
Bun.TOML.stringify({ name: "app", deps: { bun: "1.4.0" } });
// name = "app"
//
// [deps]
// bun = "1.4.0"
#### `Bun.Archive` v1.3.6
Bun.Archive is a new built-in for creating and extracting tarballs without `node-tar` or any other dependency.
const archive = Bun.Archive.from({
"hello.txt": "Hello, World!",
"data.json": JSON.stringify({ foo: "bar" }),
});await Bun.Archive.write("archive.tar.gz", archive, "gzip");
// Extract to a directory
await archive.extract("./out");
#### `CompressionStream` and `DecompressionStream` v1.3.3v1.4.0
Bun 1.3.3 added the Web-standard CompressionStream and DecompressionStream APIs, supporting the spec formats `gzip`, `deflate`, and `deflate-raw` plus `brotli` and `zstd` as Bun-specific extensions.
const stream = new Blob([data])
.stream()
.pipeThrough(new CompressionStream("gzip"));const compressed = await new Response(stream).arrayBuffer();
#### `URLPattern` v1.3.4
Bun now implements the URLPattern Web API for declarative URL matching.
const pattern = new URLPattern({ pathname: "/users/:id" });pattern.test("https://example.com/users/123"); // true
const result = pattern.exec("https://example.com/users/123");
console.log(result.pathname.groups.id); // "123"
#### ML-DSA and ML-KEM in `crypto.subtle` v1.4.0
`crypto.subtle` supports the NIST post-quantum algorithms:
Keys import and export as `spki`, `pkcs8`, `jwk`, `raw-public`, and `raw-seed`. They survive `structuredClone`. #34838
const { publicKey, privateKey } = await crypto.subtle.generateKey(
"ML-KEM-768",
true,
["encapsulateBits", "decapsulateBits"],
);// Sender: derive a shared secret + ciphertext from the recipient's public key
const { sharedKey, ciphertext } = await crypto.subtle.encapsulateBits(
{ name: "ML-KEM-768" },
publicKey,
);
// Recipient: recover the same shared secret from the ciphertext
const secret = await crypto.subtle.decapsulateBits(
{ name: "ML-KEM-768" },
privateKey,
ciphertext,
);
#### ML-DSA and ML-KEM in `node:crypto` v1.4.0
node:crypto now supports the NIST post-quantum algorithms ML-DSA (FIPS 204 signatures) and ML-KEM (FIPS 203 key encapsulation).
Seven of Node v26.3.0's upstream `test-crypto-pqc-*` suites now pass byte-for-byte. ML-KEM-512 and SLH-DSA are not yet available, because BoringSSL does not expose them via `EVP_PKEY`. `crypto.encapsulate()`/`decapsulate()` land in a later release. #34549
import { generateKeyPairSync, sign, verify } from "node:crypto";const { publicKey, privateKey } = generateKeyPairSync("ml-dsa-65");
const sig = sign(undefined, Buffer.from("hello"), privateKey);
verify(undefined, Buffer.from("hello"), publicKey, sig); // true
publicKey.export({ format: "jwk" });
// { kty: "AKP", alg: "ML-DSA-65", pub: "..." }
#### `Response.textStream()` and `Request.textStream()` v1.4.0
`Request` and `Response` now implement textStream(), returning a `ReadableStream<string>` of the body decoded as UTF-8.
const res = await fetch("https://api.example.com/stream");
for await (const chunk of res.textStream()) {
process.stdout.write(chunk); // chunk is a string
}Multi-byte characters split across chunk boundaries are reassembled, a leading BOM is stripped, and invalid sequences become U+FFFD. Bun decodes each body backing directly rather than piping bytes through a `TextDecoderStream`, so in-memory bodies emit a single string chunk and `fetch()` responses decode inline as bytes arrive.
#### `process.on("memoryPressure")` v1.4.0
A new `"memoryPressure"` event on `process` fires when the OS signals low available memory, so applications can drop caches or reap idle subprocesses instead of polling. On macOS, Linux, and Windows, Bun subscribes to the operating system's built-in low-memory notification. The listener does not keep the event loop alive. #32594
process.on("memoryPressure", (level: "warning" | "critical") => {
cache.clear();
});
\`process\` emits a 'memoryPressure' event when the system runs low on memory (thanks @codebytere!)
#### `Bun.sliceAnsi()` v1.3.11v1.4.0
Bun.sliceAnsi() slices a string by terminal column width while preserving ANSI colors and terminal hyperlinks, without splitting emoji or other multi-codepoint characters. It replaces the `slice-ansi` and `cli-truncate` npm packages, is faster than both, and returns the original string when nothing is cut. #26963
// slice-ansi replacement
Bun.sliceAnsi("\x1b31mhello\x1b[39m", 1, 4); // "\x1b[31mell\x1b[39m"// cli-truncate replacement
Bun.sliceAnsi("unicorn", 0, 4, "…"); // "uni…"
Bun.sliceAnsi("unicorn", -4, undefined, "…"); // "…orn"
#### `Bun.wrapAnsi()` [v1.3.7v1.4.0
Bun.wrapAnsi() is a native, drop-in replacement for the `wrap-ansi` npm package. It word-wraps text to a column width with the same ANSI/hyperlink/Unicode handling as above, and is up to 88x faster than the JavaScript version. #26061
const wrapped = Bun.wrapAnsi("\x1b31mThe quick brown fox\x1b[39m", 10);
console.log(wrapped);
// \x1b[31mThe quick\x1b[39m
// \x1b[31mbrown fox\x1b[39m#### `Bun.stringWidth()` [v1.3.5v1.4.0
Bun.stringWidth() returns the number of terminal columns a string occupies, accounting for ANSI escape sequences, zero-width Unicode, and multi-codepoint emoji graphemes.
Bun.stringWidth("hello"); // 5
Bun.stringWidth("\x1b[31mhello\x1b[0m"); // 5 (ANSI color)
Bun.stringWidth("\x1b[5A"); // 0 (cursor movement)
Bun.stringWidth("👨👩👧"); // 2 (ZWJ sequence, one grapheme)
Bun.stringWidth("🇺🇸"); // 2 (regional indicator pair)
Bun.stringWidth("\x1b]8;;https://bun.sh\x07Bun\x1b]8;;\x07"); // 3 (terminal hyperlink)It strips every kind of ANSI escape code (color, cursor movement, terminal hyperlinks). It also strips zero-width codepoints such as soft hyphen and combining marks. Emoji measure 2 columns each. That includes flag pairs, skin-tone modifiers, keycaps, and multi-part emoji like 👨👩👧. `util.inspect`, `console.table`, and `readline` use the same implementation. #25447
Bun.stringWidth · terminal columns
example 1 of 5 · ascii▶ play↻ replay◼ playing…▶ next · ANSI color▶ next · ZWJ family▶ next · flag▶ next · OSC 8 hyperlink▶ play
Bun.stringWidth("hello")// 5
123456
hello
hello
codepoints
codepoints:5·bytes:5·columns:5
the easy case — one byte, one codepoint, one column each
Bun.stringWidth("\\x1b\[31mhello\\x1b\[0m")// 5
123456
hello
␛\[31mhello␛\[0m
codepoints
codepoints:14·bytes:14·columns:5
ANSI escapes are invisible in a terminal — stringWidth strips them
Bun.stringWidth("👨👩👧")// 2
123456
👨👩👧
👨ZWJ👩ZWJ👧
codepoints
codepoints:5·bytes:18·columns:2
zero-width joiners fuse three emoji into one grapheme — terminals give it 2 columns
Bun.stringWidth("🇺🇸")// 2
123456
🇺🇸
🇺🇸
codepoints
codepoints:2·bytes:8·columns:2
regional indicators U + S pair into one flag — still 2 columns
Bun.stringWidth("\\x1b\]8;;https://bun.sh\\x07Bun\\x1b\]8;;\\x07")// 3
123456
Bun
␛\]8;…␇Bun␛\]8;;␇
codepoints
codepoints:29·bytes:29·columns:3
OSC 8 wraps a hyperlink around the text — only the anchor 'Bun' has width
One string, four sizes. `stringWidth` returns the last one — what a terminal actually draws. Same engine backs `Bun.sliceAnsi`, `Bun.wrapAnsi`, `console.table`, and `readline`. #25447
`Bun.stringWidth()` is 7–56× faster on Chinese, Japanese, and Korean text.
Bun.stringWidth() 处理中文、日文、韩文字符的速度提升了 7\~56 倍 Bun.stringWidth() が中国語・日本語・韓国語の文字で 7〜56 倍高速化されました Bun.stringWidth()가 중국어·일본어·한국어 문자에서 7\~56배 빨라졌습니다
#### `Bun.spawn({ cgroup })` v1.4.0
On Linux, Bun.spawn() and `Bun.spawnSync()` accept a `cgroup` option. It takes the path of an existing cgroup directory, or an open file descriptor for one. The child is placed in the cgroup before it starts running. So limits such as `memory.max` and `pids.max` apply from its first instruction, and anything it forks stays inside. If the child exceeds its memory limit, the kernel kills it. The parent is unaffected. Bun only joins the cgroup. Create and configure it first (`node:fs` is enough), and remove it when you are done.
import { mkdirSync, writeFileSync } from "node:fs";const dir = "/sys/fs/cgroup/build-jobs";
mkdirSync(dir, { recursive: true });
writeFileSync(`${dir}/memory.max`, String(2 1024 * 3));
const proc = Bun.spawn({ cmd: ["make", "-j8"], cgroup: dir });
On cgroup v2 the child is created inside the cgroup with `clone3(CLONE_INTO_CGROUP)`. Where that is unavailable, Bun writes the child into `cgroup.procs` before `exec`. A missing directory fails the spawn with the errno and the path. A frozen cgroup is refused with `EBUSY`. Otherwise the child would freeze before `exec` and take the calling thread with it. `node:child_process` forwards the option. Other platforms ignore it. #37466
#### Async stack traces from native I/O v1.3.12
Errors thrown from async native APIs like `fs.promises`, `Bun.file()`, `Bun.S3Client`, DNS, crypto, and `fetch` now include async stack traces that point back to the `await` in your code. Previously these errors had empty stacks. There was no JavaScript on the call stack when the error was created in native code, so they were effectively impossible to trace. #28652
ENOENT: no such file or directory, open 'foo.txt'
at async foo (/path/to/app.js:5:8)The stack is only captured when an error is constructed for rejection, so successful awaits are unaffected.
#### `--cpu-prof` v1.3.2
Bun now ships a built-in CPU profiler. Pass `--cpu-prof` to generate a `.cpuprofile` in the Chrome CPU Profiler format. It opens directly in Chrome DevTools or VS Code. `--cpu-prof-md` writes the same profile as a Markdown report: a summary table, top-10 hot functions, self-time and total-time tables, and a call tree. That is useful for pasting into a bug report or feeding to an LLM. `--cpu-prof-name`, `--cpu-prof-dir`, and `--cpu-prof-interval` customize the output path and sampling interval. #24112 #26327 #26620
bun --cpu-prof ./app.ts# Writes CPU.20260615.120000.12345.0.001.cpuprofilebun --cpu-prof-md ./app.ts
A \`bun --cpu-prof\` profile opened in Chrome DevTools.
The CPU profiler can be enabled with `BUN_CPU_PROFILE=1` (plus optional `BUN_CPU_PROFILE_DIR` / `BUN_CPU_PROFILE_NAME`) for processes you can't easily pass `--cpu-prof` to. #26313
#### `--heap-prof` v1.3.7
`--heap-prof` generates a V8-compatible `.heapsnapshot` that opens directly in Chrome DevTools, and `--heap-prof-md` emits a grep-friendly Markdown report with total heap size, top types by retained size, and the largest individual objects. `--heap-prof-name` and `--heap-prof-dir` control where the output lands.
# V8-compatible heap snapshot (opens in Chrome DevTools)bun --heap-prof script.js# Markdown heap profile for CLI analysisbun --heap-prof-md script.js#### `--no-env-file` v1.3.3
A new `--no-env-file` flag (and `env = false` in bunfig.toml) disables Bun's automatic .env loading. This is useful in production and CI. There, environment variables are managed externally and stray `.env` files should be ignored. Explicit `--env-file` arguments are still honored. #24767
bun --no-env-file server.ts# bunfig.toml
env = false#### `--no-orphans` v1.3.14v1.4.0
A new `--no-orphans` flag (and [\[run\] noOrphans = true](https://bun.com/docs/runtime/bunfig#run-noorphans---dont-leave-orphan-processes-behind) in bunfig, or `BUN_FEATURE_FLAG_NO_ORPHANS=1`) makes Bun exit when its original parent process dies. It also recursively SIGKILLs all descendants on clean exit. So when your terminal dies, your dev servers die with it. It works on Linux and macOS with no extra thread or file descriptor. On Windows it uses a recursive kill-on-close Job Object plus a parent-process wait (#34768). It applies to `bun run <script>`, `bunx`, and `--filter`. #29930
bun --no-orphans run dev
\`bun --no-orphans <file|script>\` recursively terminates lingering processes on exit
#### `Bun.Transpiler` REPL mode v1.3.7
A new `replMode` option transforms code for interactive REPL evaluation: it captures the last expression's value, hoists declarations so they persist across lines, converts `const` to `let` for re-declaration, and auto-detects bare object literals. Pair it with `vm.runInContext` to build a Node.js-compatible REPL on Bun's transpiler. #26246
#### `Bun.YAML` passes 402/402 of yaml-test-suite v1.4.0
`Bun.YAML` now passes 402/402 yaml-test-suite. This fixes spec-compliance bugs in:
`YAML.stringify()` also now correctly quotes number-like strings (`"0e6836"`, `"0123"`), strings ending in `:`, and strings starting with ``/`{`. Cyclic anchors and aliases are supported in `parse()`. [#31527 #37055
\`Bun.YAML.parse\` supports cyclic anchors & aliases
#### `WebSocket` proxies and Unix sockets v1.3.6v1.4.0
The `WebSocket` client now supports connecting through HTTP and HTTPS proxies via a new `proxy` option. It supports `ws://` and `wss://` over both HTTP and HTTPS proxies, Basic auth via URL credentials, custom proxy headers, and full TLS configuration for the target connection. It also supports Unix domain sockets via `ws+unix://` and `wss+unix://` URL schemes. These use the same syntax as the npm ws package. #25614 #29203
new WebSocket("wss://example.com", { proxy: "http://user:pass@proxy:8080" });new WebSocket("ws+unix:///tmp/app.sock:/api/stream?x=1");
#### `WebSocket` URL credentials v1.3.7v1.4.0
`new WebSocket("ws://user:pass@host")` now forwards URL-embedded credentials as a Basic `Authorization` header. An explicitly provided `Authorization` header still takes precedence. #26278
#### SHA-3 and X25519 in Web Crypto v1.3.13
SHA-3 lands in both Web Crypto and `node:crypto`. `crypto.subtle.digest()` and HMAC `sign`/`verify` now accept `SHA3-256`/`SHA3-384`/`SHA3-512`. `createHash`/`createHmac` add `sha3-224/256/384/512`. `crypto.subtle.deriveBits` now supports X25519 as well, for deriving a shared secret between two key pairs. Small-order peer public keys are rejected, as RFC 7748 requires. #29323 #29152
await crypto.subtle.digest("SHA3-256", new TextEncoder().encode("hello"));const bits = await crypto.subtle.deriveBits(
{ name: "X25519", public: bobPublicKey },
alicePrivateKey,
256,
);
#### `structuredClone` preserves object identity v1.4.0
`structuredClone` preserves object identity for `Date`, `RegExp`, `Error` subclasses, `DOMException`, `CryptoKey`, `KeyObject`, `X509Certificate`, `Blob`, and `File`: the same instance referenced twice comes back as one object. #32796
#### `Bun.CSRF` `sessionId` v1.4.0
`Bun.CSRF.generate()` and `Bun.CSRF.verify()` accept a new `sessionId` option that binds a token to a specific principal via HMAC associated data. Tokens generated for one session won't verify under another, and verification fails closed if `sessionId` is supplied on only one side; tokens without `sessionId` are unchanged. #31215
#### `Bun.udpSocket()` connection-refused errors and truncation flags v1.3.12
On Linux, sending to a dead port now fires the `error` handler with `ECONNREFUSED` instead of silently timing out. The `data` callback also gains a fifth `flags` argument with `{ truncated: boolean }` to detect when a datagram exceeded the receive buffer. #28827
#### Grapheme clusters for Indic scripts v1.3.7
We rewrote grapheme cluster segmentation with Indic Conjunct Break support. Devanagari and other Indic conjuncts now segment as single clusters. #26376
#### `Bun.S3Client` v1.3.1v1.4.0
`Bun.S3Client` now supports AWS Requester Pays buckets. Pass `requestPayer: true` on the client or per-operation to send the `x-amz-request-payer` header on every request, including each part of a multipart upload. #25514
const s3 = new Bun.S3Client({
bucket: "hl-mainnet-evm-blocks",
requestPayer: true,
});
const data = await s3.file("0/0/1.rmp.lz4").arrayBuffer();Other S3 fixes:
#25363 #26149 #25999 #27273 #29813 #23880
#### `Bun.sql` is more reliable v1.3.11v1.4.0
const sql = new Bun.SQL({ prepare: false });
await sql`SELECT 1`; // now safe behind PgBouncer transaction pooling#### Named parameters in `Bun.sql` for SQLite v1.4.0
`sql.unsafe()` and `sql.file()` accept an object of named parameters for `:name`, `$name`, and `@name` placeholders. Previously an object bound nothing, so a `SELECT` returned no rows and no error. Keys keep their prefix (`{ ":id": 1 }`) unless the connection sets `strict: true`. #37109
#### SQLite 3.53.0 v1.3.14
`bun:sqlite`'s bundled SQLite is upgraded to 3.53.0; `db.close(true)` no longer throws "database is locked" after `db.transaction()`; non-UTF-8 `TEXT` values under 64 bytes decode leniently to U+FFFD instead of returning `""`. #27912
#### `Bun.isStandaloneExecutable` v1.4.0
`Bun.isStandaloneExecutable` is a new read-only boolean, `true` inside a `bun build --compile` binary; unlike checking `Bun.embeddedFiles.length > 0`, reading it allocates nothing. #32583
#### `bun init --react=tanstack` v1.3.4
`bun init --react=tanstack` is a new template that scaffolds a TanStack Start project with file-based routing, Vite, and Tailwind, running on Bun. #24648
`fetch()`#
#### `fetch()` response backpressure v1.4.0
`fetch()` now applies backpressure when streaming a response body. Once a chunk is delivered to JavaScript and nothing has consumed it, the HTTP thread pauses reading from the socket instead of buffering the entire body in memory. Buffered consumers like `.text()`, `.json()`, and `.arrayBuffer()` opt out and still receive the body in one shot. #29831
const res = await fetch("https://example.com/large.bin");
for await (const chunk of res.body) {
// The HTTP thread pauses reading while this loop is busy,
// instead of buffering the whole file in memory.
await process(chunk);
}#### HTTPS proxies reuse `CONNECT` tunnels v1.3.12v1.4.0
`fetch()` through an HTTPS proxy now reuses CONNECT tunnels. The tunneled TLS session is pooled across sequential requests to the same target. Before, every request paid a fresh CONNECT + TLS handshake. The tunnel is also reused when the request passes `tls` options such as `ca`, `cert`, or `key`. Previously every such request opened a new connection to the proxy and redid the `CONNECT` and both TLS handshakes. #37715
#### Custom TLS options reuse keep-alive connections v1.3.10
`fetch()` with custom TLS options now reuses keepalive connections. Identical SSL configs (client certificates, custom CA, mTLS) are interned with reference counting for O(1) pointer-equality matching, with an LRU-bounded context cache.
#### Header name casing is preserved on the wire v1.3.7
`fetch()` preserves header name casing on the wire instead of lowercasing it. Headers are case-insensitive per RFC 7230, but plenty of real-world servers reject `content-type` while accepting `Content-Type`. #26425
#### `HTTP_PROXY` is re-read at runtime v1.3.12
`process.env.HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` set at runtime now take effect on the next `fetch()` call instead of only being read once at startup. #28614
Test runner#
#### `onTestFinished()` v1.3.2
`bun:test` exports `onTestFinished()`, a Vitest-compatible hook that registers a callback to run after the current test completes, after all `afterEach` hooks. It can only be called inside a test body, so you can clean up resources that were created during the test. #24038
import { test, onTestFinished } from "bun:test";test("uses a temp resource", () => {
const handle = open();
onTestFinished(() => handle.close());
// ...
});
#### `bun test --only-failures` v1.3.1
`bun test --only-failures` suppresses output for passing and skipped tests, printing only failures and the final summary. #23312
bun test --only-failurestest/example.test.ts:
(fail) failing test
error: expect(received).toBe(expected)Expected: 3
Received: 2
5 pass
1 skip
1 fail
Ran 7 tests across 1 file.
#### `bun test --path-ignore-patterns` v1.3.11
`bun test --path-ignore-patterns` (and `test.pathIgnorePatterns` in bunfig.toml) excludes test files by glob, so you can skip integration tests, generated fixtures, or whole directories without renaming files. #28089
#### `bun test --pass-with-no-tests` v1.3.1
`bun test --pass-with-no-tests` exits with code 0 when no tests match, matching Jest and Vitest. Useful in monorepos where a shared pattern runs across packages that may not all contain tests. #23424
#### `bun test --grep` v1.3.6
`bun test` now accepts `--grep` as an alias for `-t` / `--test-name-pattern`, matching Mocha and Jest. #25788
#### `using spy = spyOn(...)` v1.3.9
`spyOn()` and `mock()` now implement `Symbol.dispose`, so `using spy = spyOn(obj, "method")` automatically restores the original implementation when the spy goes out of scope. No manual `mockRestore()` or `afterEach` cleanup needed. #26692
#### The `vi` global v1.3.1v1.4.0
The `vi` global (Vitest compatibility alias) is now defined in `bun test`, so files that call `vi.fn()` or `vi.mock()` without importing run unmodified. #23674
#### Timeouts on `beforeAll` / `afterAll` hooks v1.3.2v1.4.0
`beforeAll`, `beforeEach`, `afterAll`, and `afterEach` now accept a numeric timeout or `{ timeout }` options object as the second argument instead of throwing. #24039
Bundler#
#### Execute-only standalone binaries on Linux v1.3.12
Standalone executables on Linux now embed the module graph in a segment the OS loads into memory with the rest of the binary. Before, they opened and read their own executable file at startup. `bun build --compile` binaries start with zero file I/O and run with execute-only permissions (`chmod 111`). This matches the existing behavior on macOS and Windows. #26923
bun build --compile ./app.ts --outfile=appchmod 111 ./app./app # works, no read permission needed#### `useDefineForClassFields` and `"jsx": "react-jsx"` in `tsconfig.json` v1.4.0
`useDefineForClassFields: false` is honored. Instance field initializers move into the constructor after parameter-property assignments, as tsc does. And `"jsx": "react-jsx"` selects the production automatic runtime (`jsx`/`jsxs` from `<pkg>/jsx-runtime`). `"react-jsxdev"` selects `jsxDEV`. #36664 #34422
#### Native `using` and `await using` for `--target=bun` v1.3.14v1.4.0
`using` and `await using` declarations are no longer lowered to helper functions when targeting Bun. JavaScriptCore supports explicit resource management natively, so the runtime transpiler, `bun build --target=bun`, and the REPL now emit them as-is. Browser and Node targets continue to lower as before. #29538
var __using = (stack, value, async) => { / ... / };
var __callDispose = (stack, error, hasError) => { / ... / };
let __stack = [];
try {
const x = __using(__stack, { [Symbol.dispose]() {} }, 0);
console.log("hi");
} catch () { var _err = , _hasErr = 1; }
finally { __callDispose(__stack, _err, _hasErr); }
using x = { [Symbol.dispose]() {} };
console.log("hi");#### `emitDecoratorMetadata` implies legacy decorators v1.3.11
Setting `emitDecoratorMetadata: true` in `tsconfig.json` now implies legacy decorator semantics even when `experimentalDecorators` is omitted, fixing NestJS, TypeORM, and Angular projects that crashed with `descriptor.value is undefined`.
#### `--compile-executable-path` v1.3.6
`bun build --compile` gains `--compile-executable-path` to point at a local Bun binary instead of downloading one when cross-compiling, and using an already-compiled standalone executable as that base no longer panics or produces a corrupt Mach-O binary.
#### `reactFastRefresh` and `allowUnresolved` in `Bun.build()` v1.3.6
`Bun.build()` gains `reactFastRefresh: true` (works on all targets). It also gains `allowUnresolved: string[]`, which controls which dynamic-import glob shapes are permitted at bundle time. Other fixes:
`define` values starting with ``, `?`, `(`, or `)` are now correctly auto-quoted.
#### `browser` field remaps for Node builtins v1.4.0
`--target=browser` now applies package.json `"browser"` remaps to Node builtins before falling back to Bun's polyfills. It also applies them to `main`/index resolutions written without an extension. `jszip` bundles at 149 KB instead of 459 KB. The legacy `jsnext:main` field gets the same `module`→`main` fallback. #36597 #36620 #35447
#### Code splitting on 20,000-module graphs is 14× faster v1.4.0
The code-splitting reachability walk is now BFS and O(V+E), taking a 20,000-module diamond-shaped DAG from 4.65 s to 320 ms. Separately, the tree-shaking liveness, TLA validation, CSS-order, and part-visitor passes moved from recursion to explicit stacks, so linear import chains of thousands of modules no longer stack-overflow.
Node.js compatibility#
The node:http client has been rewritten as a direct port of Node's \_http\_client.js. It replaces the previous `fetch()`\-based shim. `http.ClientRequest` now runs on `net`/`tls` sockets, Node's own HTTP parser, and an `Agent` socket pool. So keep-alive reuse, `Upgrade`/`CONNECT`, 1xx `'information'` events, and `createConnection` all behave exactly as they do in Node.
import http from "node:http";const agent = new http.Agent({ keepAlive: true, maxSockets: 4 });
const req = http.request({ host: "example.com", agent }, (res) => {
res.on("data", (chunk) => process.stdout.write(chunk));
});
req.on("information", (info) => console.log("1xx:", info.statusCode));
req.end();
Keep-alive agents reuse sockets exactly as in Node: subsequent requests through an `http.Agent` with `keepAlive: true` are 65.9% faster, with 190% higher overall throughput. #24351
On the server, `headersTimeout`, `requestTimeout`, and `keepAliveTimeout` fire with Node's `connectionsCheckingInterval` sweep and raw `408` reply. Also:
import http from "node:http";const server = http.createServer((req, res) => res.end("ok"));
server.headersTimeout = 10_000; // 408 if headers not complete in 10s
server.requestTimeout = 30_000; // 408 if request not complete in 30s
server.keepAliveTimeout = 5_000; // close idle keep-alive sockets after 5s
server.listen(3000);
#### `node:http2` v1.4.0
node:http2 has a spec-compliant HTTP/2 parser. Server push works end to end via `pushStream()` and `createPushResponse()`, and Node API parity now covers raw headers, graceful connection shutdown, and `respondWithFD`. 93.2% of Node v26.3.0's byte-identical test suite passes. #31584
import http2 from "node:http2";const server = http2.createSecureServer({ key, cert });
server.on("stream", (stream, headers) => {
stream.pushStream({ ":path": "/app.css" }, (err, push) => {
push.respondWithFile("./public/app.css", { "content-type": "text/css" });
});
stream.respond({ ":status": 200, "content-type": "text/html" });
stream.end("<link rel=stylesheet href=/app.css>");
});
#### `node:net` and `node:tls` v1.4.0
`socket.end()` now half-closes the connection instead of full-closing it. `resetAndDestroy()`, write-after-end errors, and `ECONNRESET` shapes are now correct. `net.Socket#connect()` and `Bun.connect()` accept `localAddress` and `localPort`. These bind outgoing sockets to a specific local interface. TLS gains:
import tls from "node:tls";
import { appendFileSync } from "node:fs";const socket = tls.connect(443, "example.com", { servername: "example.com" });
socket.on("keylog", (line) => appendFileSync("keys.log", line)); // for Wireshark decryption
socket.on("session", (session) => {
// Session ticket for TLS resumption
});
#### `node:worker_threads` v1.4.0
A worker with an unsettled top-level `await` exits with code 13. `process.abort()` inside a worker ends only that worker. node:worker\_threads gains:
`MessagePort`'s `close()` callback timing, `'close'` event delivery to both ends of a channel, and `DataCloneError` transfer-list messages now pass Node's own suite. That suite is at 73.9% overall, up from \~37% in Bun 1.3.
import { Worker, SHARE_ENV } from "node:worker_threads";const w = new Worker("./worker.js", {
env: SHARE_ENV, // live, write-through process.env
stdout: true, // captured stdio
});
w.stdout.pipe(process.stdout);
const stats = await w.getHeapStatistics();
#### `node:fs` v1.4.0
`fs.cp` and `cpSync` get Node's full `ERR_FS_CP_*` error semantics, `fs.watch` gains the `ignore` option, and `fs.promises.watch` gains `AbortSignal` support. node:fs now passes 97.5% of Node v26.3.0's fs tests (99.1% excluding tests that require `--expose-internals`).
import { watch } from "node:fs/promises";const ac = new AbortController();
for await (const { eventType, filename } of watch("./src", {
recursive: true,
ignore: (path) => path.includes("node_modules"),
signal: ac.signal,
})) {
console.log(eventType, filename);
}
#### `node:stream` v1.4.0
The experimental `stream/iter` and `zlib/iter` APIs land behind `--experimental-stream-iter`, `readable.read()` returns one buffered chunk at a time (Node 26's semver-major change), and `pipeline()` reports the real failure ahead of any internal `AbortError`. node:stream passes 96.9% of Node v26.3.0's stream tests.
// bun --experimental-stream-iter app.js
import { map, filter } from "node:stream/iter";
import { createReadStream } from "node:fs";const lines = createReadStream("access.log", "utf8");
for await (const hit of filter(
map(lines, (l) => l.trim()),
(l) => l.includes("POST"),
)) {
console.log(hit);
}
#### `process` v1.4.0
Setting `process.env.TZ` now updates existing `Date` instances. `process.env` now behaves like Node's: assigned values are coerced to strings, and `structuredClone(process.env)` works. `--no-warnings`, `--trace-warnings`, `--trace-deprecation`, `--redirect-warnings`, and `--disable-warning` are all wired up. process jumps from 60.5% to 84.2% on Node v26.3.0's tests. #31831
const now = new Date();
process.env.TZ = "America/Los_Angeles";
console.log(now.toString()); // existing Date reflects the new timezoneprocess.env.PORT = 3000;
console.log(process.env.PORT); // "3000" (coerced to string)
#### `AsyncLocalStorage` v1.4.0
AsyncLocalStorage gains the Node 26 `defaultValue`/`name` constructor options and `AsyncLocalStorage.prototype.withScope()`. `AsyncResource.bind()` now preserves the original function's `.length` and `this`. Coverage of Node's `async_hooks` tests doubles to 50%. #31825
import { AsyncLocalStorage } from "node:async_hooks";const requestId = new AsyncLocalStorage({
name: "requestId",
defaultValue: "-",
});
{
using scope = requestId.withScope(crypto.randomUUID());
console.log(requestId.getStore()); // the UUID
}
console.log(requestId.getStore()); // "-"
#### `node:vm` v1.4.0
Top-level `await` in a `SourceTextModule` resumes after the `await`, and node:vm adds the v26 module-linking API (`linkRequests()`, `instantiate()`, `moduleRequests`, `hasTopLevelAwait()`) and implements `microtaskMode: 'afterEvaluate'`, taking Node v26.3.0's vm suite from 72% to 97%. #32018
import vm from "node:vm";const ctx = vm.createContext({ console }, { microtaskMode: "afterEvaluate" });
const mod = new vm.SourceTextModule(
`export const n = await Promise.resolve(42);`,
{ context: ctx },
);
await mod.linkRequests(() => {});
mod.instantiate();
await mod.evaluate();
console.log(mod.namespace.n); // 42
#### `node:cluster` v1.4.0
node:cluster shares sockets between workers. Bun 1.4 implements round-robin scheduling (the primary accepts connections and hands the file descriptors to workers over IPC), `SCHED_NONE` shared handles, UDP clustering, and `worker.send(msg, socket)` handle passing. #31829
import cluster from "node:cluster";
import http from "node:http";
import { availableParallelism } from "node:os";if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
} else {
http
.createServer((req, res) => res.end(`hello from ${process.pid}`))
.listen(3000);
}
#### `vitest --coverage` v1.4.0
node:inspector now implements the V8 `Profiler` coverage methods, so `vitest --coverage` with the default v8 provider works under Bun. Per-file statement, branch, function, and line percentages, and the uncovered-line report, match Node exactly on the same project. #32476
bun --bun vitest run --coverage% Coverage report from v8
-----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------|---------|----------|---------|---------|-------------------
All files | 87.43 | 76.19 | 88.88 | 87.43 |#### `chrome://inspect` v1.4.0
`inspector.open()`, `inspector.url()`, `inspector.close()`, and `inspector.waitForDebugger()` are implemented. Bun starts a WebSocket server that speaks the Chrome DevTools Protocol. It serves the same discovery endpoints as Node and prints Node's `Debugger listening on ws://...` line. So `chrome://inspect` and VS Code's debugger discover and attach to a running Bun process. #32479
import inspector from "node:inspector";inspector.open(9229, "127.0.0.1");
console.log(inspector.url()); // ws://127.0.0.1:9229/...
inspector.waitForDebugger(); // blocks until DevTools attaches
#### `node:test` v1.4.0
Subtests work. `t.test()`, `t.describe()`, and top-level `test()`/`describe()` called inside a running test now execute inline instead of throwing `NotImplementedError`. node:test also gains:
`t.mock` is now a per-test tracker. It resets automatically when the test finishes. 20 of Node v26.3.0's `test_runner` tests now pass, up from 7. #32631
import { test } from "node:test";test("parent", async (t) => {
t.plan(2);
await t.test("a", () => {});
await t.test("b", () => {});
});
test("timers", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
let fired = false;
setTimeout(() => (fired = true), 1000);
t.mock.timers.tick(1000);
t.assert.strictEqual(fired, true);
});
node:test gains the programmatic `run()` API. Each file runs in its own child process. The returned `TestsStream` emits Node's event sequence (`test:enqueue`/`dequeue`, `test:pass`/`fail`, per-file and run-level `test:summary`) as both events and objectMode chunks. Node v26's `expectFailure` option lands. A throwing body is the expected outcome. A passing one fails with `failureType: 'expectedFailure'`. Two divergences reachable from plain `bun test` are also fixed. A skipped `describe` no longer runs its callback. `{ skip: true, todo: true }` is treated as a skip. Node v26.3.0's `test_runner` suite goes from 20 to 26 of 81 passing. #34444
import { run } from "node:test";const stream = run({ files: ["./a.test.js", "./b.test.js"] });
stream.on("test:fail", ({ name, details }) =>
console.error(name, details.error.message),
);
#### `node:quic` v1.4.0
node:quic is now implemented. It is backed by lsquic, which Bun already vendors for HTTP/3. The full experimental Node v26 API is covered:
All 235 vendored Node v26.3.0 `node:quic` tests pass on a release build. #32602
Bun-to-Bun runs at 1.31× Node-to-Node throughput (64,591 vs 49,239 req/s, one HTTP/3 stream per request at concurrency 50, Linux x64). Node's published binaries compile QUIC out, so running the same code there requires a from-source `--experimental-quic` build.
import { listen } from "node:quic";const endpoint = await listen(
(session) => {
session.onstream = (stream) => stream.closed.catch(() => {});
},
{
sni: { "*": { keys: [key], certs: [cert] } },
onheaders() {
this.sendHeaders({ ":status": "200" });
this.writer.endSync();
},
},
);
#### `node:sqlite` v1.3.2v1.4.0
node:sqlite is now implemented. It passes all 18 of Node v26.3.0's `test-sqlite-*` files (319 subtests, 0 failures, 4 skips, on Linux x64). macOS skips more, because Apple's system libsqlite3 omits some features. It is backed by the same bundled SQLite as `bun:sqlite`. Supported:
import { DatabaseSync } from "node:sqlite";const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE data(key INTEGER PRIMARY KEY, value TEXT) STRICT`);
const insert = db.prepare("INSERT INTO data (key, value) VALUES (?, ?)");
insert.run(1, "hello");
insert.run(2, "world");
console.log(db.prepare("SELECT * FROM data ORDER BY key").all());
// [ { key: 1, value: 'hello' }, { key: 2, value: 'world' } ]
node:sqlite is fully implemented and passes 100% of Node.js’ test suite
#### `node:repl` v1.4.0
node:repl is now a working port of Node v26.3.0's REPL. Before, it was a stub that threw on use. It passes 75.2% of Node's repl tests. This includes line editing and keybindings, multi-line continuation (unfinished statements get a `| ` prompt instead of a syntax error), top-level `await`, tab completion, and persistent history. A new `--interactive` flag drops you straight into it. For interactive use, prefer bun repl, which has Bun-specific features like syntax highlighting. `--interactive` and `node:repl` are for tools that programmatically embed Node's REPL. #31827
bun --interactiveconst res = await fetch("https://bun.sh")res.status200#### `node:trace_events` v1.4.0
node:trace\_events is now fully implemented. It passes 100% of Node v26.3.0's trace tests. Bun supports `--trace-events-enabled`, `--trace-event-categories`, and `--trace-event-file-pattern`. It writes `node_trace.${rotation}.log` in Chrome trace format. It instruments timers, fs, and http with category-gated events that are zero-cost when tracing is off. #31824
bun --trace-events-enabled --trace-event-categories=node.fs.async,node.http app.jsls node_trace.*.lognode_trace.1.log#### `node:domain`
node:domain is now a real implementation instead of a \~70-line stub. It passes 66.7% of Node's domain tests. Domains propagate via Bun's `AsyncLocalStorage` machinery. Uncaught exceptions route into the active domain before `'uncaughtException'`. `EventEmitter` integrates with domains the same way it does in Node. #31828
import domain from "node:domain";const d = domain.create();
d.on("error", (err) => console.error("caught by domain:", err.message));
d.run(() => {
setTimeout(() => {
throw new Error("boom");
}, 10);
});
#### `node:buffer` v1.4.0
node:buffer now passes 92.9% (65/70) of Node.js v26.3.0's `test-buffer*.js` suite. `Buffer.indexOf`/`lastIndexOf`/`includes` gain the `end` parameter. `Buffer.concat`/`copy` range validation matches Node. `ERR_INVALID_ARG_TYPE`/`ERR_OUT_OF_RANGE` messages are formatted identically to Node's, including grouped type lists and numeric separators for large values. #32626
`Buffer.byteLength()` no longer under-counts unpaired surrogates, so it agrees with `Buffer.from(s).length`, and `lastIndexOf` with `utf16le` encoding no longer matches on odd byte offsets. #32784
const s = "\ud800a".repeat(17);
Buffer.byteLength(s, "utf8"); // before: 51, after: 68
Buffer.from(s, "utf8").length; // 68 (now matches)#### `TextDecoder` supports all WHATWG encodings v1.4.0
`TextDecoder` now supports every encoding in the Encoding Standard. Sixteen previously-missing single-byte encodings (`iso-8859-2`, `-5`, `-16`, `koi8-r`, `windows-1251`, and others) are added, all 228 spec labels are recognized, and EUC-JP, Big5, ISO-2022-JP, and BOM handling during streaming decode now match the spec byte-for-byte. #32837
new TextDecoder("iso-8859-5"); // no longer throws RangeError
new TextDecoder("csisolatin5"); // label alias, also works#### `uid` and `gid` for child processes v1.4.0
`Bun.spawn`, `Bun.spawnSync`, and node:child\_process now honor the `uid` and `gid` options. They change the child's user and group IDs before exec. Behavior matches Node/libuv: `setgroups` then `setgid` then `setuid` on POSIX, and `ENOTSUP` on Windows. `EPERM` is surfaced synchronously when the caller lacks permission. #33060
import { spawnSync } from "node:child_process";// as root:
spawnSync("id", { uid: 65534, gid: 65534 });
// child reports uid=65534 gid=65534, supplementary groups dropped
#### `NODE_COMPILE_CACHE` v1.4.0
module.enableCompileCache() and the `NODE_COMPILE_CACHE` environment variable now persist bytecode to disk between runs. `--cpu-prof` and `--heap-prof` write V8-format profiles with Node's filename convention. `--watch-kill-signal` delivers the configured signal to JS listeners before restarting. The Node 26 Assert class and a native `assert.partialDeepStrictEqual` are implemented. Throws inside `fs`/`dns`/`pbkdf2` callbacks now route to `'uncaughtException'` instead of `'unhandledRejection'`. node:url reaches 83.0% on Node's test suite. `new URL(bad)` throws Node's exact `TypeError: Invalid URL` shape. #34660
NODE_COMPILE_CACHE=~/.cache/bun bun app.js#### Datadog continuous profiling works v1.4.0
`dd-trace` with `profiling: true` and @datadog/pprof now load and profile in Bun. `v8::CpuProfiler` is backed by JavaScriptCore's sampling profiler. The nan `ObjectWrap` template pattern (`FunctionTemplate::SetClassName`, `InstanceTemplate`, `PrototypeTemplate`, `Signature`) is implemented. So are the rest of the 89 V8 and Node symbols pprof's prebuild links against. The returned profile correctly attributes wall-clock samples to the hot function with full stack depth. #36747
import { time } from "@datadog/pprof";time.start({ intervalMicros: 1000 });
// ... code to profile ...
const profile = await time.stop(); // pprof-format profile
#### `await worker.terminate()` waits for the thread v1.4.0
Worker threads are joined by their parent. `await worker.terminate()` resolves with the exit code only once the thread is gone, and every worker it spawned too. Everything the worker posted before exiting is delivered before `'exit'`. A worker's "may run script" gate closes the moment its stop is requested. So no timer, socket callback, or thread-pool completion dispatches into a worker that is being stopped. Off-thread completions reach their VM through a handle that teardown closes. A late one is refused and released instead of touching freed memory.
import { Worker } from "node:worker_threads";const w = new Worker("./heavy.js");
const code = await w.terminate(); // resolves 1 for a running worker
// the thread and its nested workers are gone
This work landed in #38299, #38436, #38457, and #38660.
The event loop stays fair under a worker that posts faster than its parent can deserialize: message drains take a bounded budget per turn, so a message flood no longer starves timers, I/O, or the worker's own pending stop.
#### Node-API version 10 v1.4.0
Bun now reports Node-API version 10, syncs the public headers from Node 26, and implements the five `NAPI_EXPERIMENTAL` `node_api_*` functions Node 26 added. #34146 #36804 #34147
#### N-API finalizers run in LIFO order at exit v1.3.13
`napi_wrap` finalizers now run in LIFO order at process exit. This fixes shutdown segfaults in kuzu, duckdb, sqlite3, node-llama-cpp, and @napi-rs/canvas. Two crashes in `napi_create_external_buffer` and UTF-8 property-name handling were fixed. This unblocks napi-rs addons like `impit`. `ThreadSafeFunction` finalizer cleanup no longer crashes or hangs. `napi_delete_reference` is now callable from inside a finalizer. `napi_typeof` correctly reports wrapped callbacks and boxed primitives.
#### `tls.setDefaultCACertificates()` and `secureContext.addCACert()` v1.4.0
`node:tls` gains per-context `secureContext.addCACert()` and process-wide `tls.setDefaultCACertificates()`. #31155
#### `node:http2` `diagnostics_channel`, AltSvc, extended CONNECT, and `allowHTTP1` v1.4.0
`node:http2` gains `diagnostics_channel` instrumentation, AltSvc and Origin frames, extended CONNECT, and `allowHTTP1` fallback for the compatibility server. #31584
#### `fs.mkdtempDisposable()` and `FileHandle#pull()`/`#writer()` v1.4.0
`fs.mkdtempDisposable()` and `FileHandle.prototype.pull()`/`.writer()` are implemented, and `fs.watch` no longer drops events when two files change in the same millisecond. #31830
#### `node:zlib` brotli and zstd dictionaries v1.4.0
`node:zlib` supports brotli and zstd dictionaries, `reset()` is Node-compatible, async `write()` accepts growable `SharedArrayBuffer`, and `zstdCompress` accepts explicit `undefined`/`null` options. Node's zlib suite passes at 96.8%. #34427 #36555 #36423
#### `node:util`: `styleText` hex colors, `getCallSites`, `tty.WriteStream` v1.4.0
`styleText` hex colors, `getCallSites`, regexp highlighting, and `tty.WriteStream` are now implemented. `parseArgs` matches Node's property insertion order and treats `null` boolean flags as absent. `MIMEType` error messages match `ERR_INVALID_MIME_SYNTAX`. `types.isBigIntObject`/`isSymbolObject`/`isBoxedPrimitive` handle modified boxed primitives. #34434 #34601 #33769 #34611 #34872
#### `v8.GCProfiler` v1.4.0
`node:v8` adds `v8.GCProfiler` (backed by JavaScriptCore's `HeapObserver`) and `v8.isStringOneByteRepresentation()`.
#### ws-compatible `'upgrade'` and `'unexpected-response'` events v1.4.0
Bun's `WebSocket` client emits `'upgrade'` and `'unexpected-response'` events, matching the ws package. #36272
#### `dns.promises.getDefaultResultOrder()` and `getServers()` v1.3.12
`node:dns/promises` now exports `getDefaultResultOrder()` and `getServers()`, fixing Vite 8 builds. #28949
#### `SIGWINCH` on Windows v1.3.3
On Windows, `SIGWINCH` now fires on terminal resize (unblocking TUI libraries like opentui and opencode), and `SIGHUP`/`SIGBREAK` fire on console close and Ctrl+Break. #24704
#### `--use-system-ca` reads Node's Windows certificate stores v1.3.14
`--use-system-ca` on Windows now reads the same certificate stores Node.js does (`ROOT`, `CA`, and `TrustedPeople` across local-machine, current-user, group-policy, and enterprise locations), fixing intranet servers that omit their intermediate chain. #30408
Package manager#
#### Lockfile migration v1.4.0
`bun install`'s npm `package-lock.json` migrator now supports lockfileVersion 1 through 4, including bundled and nested dependencies, `optionalPeers`, and `overrides`.
Migrating `pnpm-lock.yaml` v9 lockfiles now supports `patchedDependencies`, snapshot aliases, catalogs, git `path:`, multi-document files, `runtime:` entries, and named registries.
A project's `bunfig.toml` now takes precedence over any `.npmrc` for the same key; `.npmrc`\-only settings such as `//host/:_authToken` still attach to registries declared in `bunfig.toml`.
#### `bun.lock` versioning v1.3.2
`bun.lock` now records a `configVersion` alongside the existing `lockfileVersion`. So future Bun releases can change install defaults for new projects without affecting existing ones. New lockfiles get `configVersion: 1` and default to the isolated linker. Existing lockfiles without the field are treated as `configVersion: 0` and keep the hoisted default. #24236
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {#### `publicHoistPattern` and `hoistPattern` v1.3.1
The isolated linker now reads `publicHoistPattern` and `hoistPattern` from `bunfig.toml` and `.npmrc`. `publicHoistPattern` hoists matching transitive packages (like `@types*` or `eslint`) to the root `node_modules` so every workspace can resolve them; `hoistPattern` controls what's hoisted into `node_modules/.bun/node_modules`. #23567
[install]
publicHoistPattern = ["@types*", "eslint"]
hoistPattern = ["*"]#### `install.hoist = false` v1.4.0
`install.hoist = false` in `bunfig.toml` (or `hoist=false` in `.npmrc`) disables the isolated linker's hidden `node_modules/.bun/node_modules` fallback directory, so packages that `require()` an undeclared dependency fail with `MODULE_NOT_FOUND` instead of resolving through the fallback, matching pnpm's `hoist` setting. #36972
#### `bun update --recursive` and `--filter` update every selected workspace v1.4.0
`bun update --recursive` and `bun update --filter <pattern>` now update the dependencies of every workspace they select and write each workspace's `package.json`. `--filter` can be repeated, and `--filter '!name'` excludes a workspace. `bun outdated` accepts the same repeated `--filter`. Previously two filters selected nothing. A dependency declared as `catalog:` is left as written. #36360
bun update --recursive --latest
bun update --filter 'pkg-*' --filter '!pkg-c'#### `bun update <name>` updates every copy v1.4.0
`bun update <name>` now re-resolves every copy of `<name>` in the lockfile, including the copies other workspaces and your transitive dependencies depend on. #36360
#### Happy Eyeballs for registry connections v1.4.0
`bun install` now correctly interleaves IPv6 and IPv4 addresses per RFC 8305 when connecting to the registry, so a blackholed IPv6 route (Starlink, some corporate networks, misconfigured Docker bridges) costs nothing per manifest fetch. #36295
#### Streaming tarball extraction v1.3.13
`bun install` now decompresses and extracts packages while they download, with incremental integrity hashing computed on the fly. #29404
#### Peer dependency resolution is up to 8x faster v1.3.13
`bun install --linker=isolated` is up to 8x faster on monorepos with heavy peer dependency graphs. The first resolution pass now deduplicates subtrees with the same resolved peer dependencies. Before, it expanded every position in the dependency tree. On one reported monorepo this cut the first-pass node count from millions to \~75K. The `.bun/` store layout is byte-identical. #29342
#### `bun list` v1.3.2
`bun list` is now a shorthand for `bun pm ls`, printing your dependency tree without the extra `pm` subcommand. #24159
#### `bun publish` sends your README v1.3.14
`bun publish` now sends `readme` and `readmeFilename` to the registry, so packages published with Bun show their README on npmjs.com. #30257
#### Per-path `.npmrc` auth tokens v1.3.11v1.4.0
`.npmrc` auth tokens are now matched by host and path, so multiple registries on the same host (Azure Artifacts, JFrog) each get their own token instead of last-one-wins. #26351
#### `bun update` updates catalogs v1.4.0
`bun update` now updates `catalog` version definitions in non-interactive mode, and re-resolves catalog references when run from the workspace root. #36304 #36379
#### `patchedDependencies` cache keyed by full patch hash v1.4.0
`patchedDependencies` cache entries are now keyed by a SHA-1 of the whole patch file. Previously the key was a Wyhash, and only the first 16 KiB of the file was hashed. So two projects sharing an install cache got each other's patched package if their patches collided or were identical for their first 16 KiB. Existing `node_modules` are re-patched once on the next install. #32749
#### `bun pm ls --trusted` v1.4.0
`bun pm ls --trusted` filters the dependency tree to packages allowed to run lifecycle scripts, honoring both `trustedDependencies` in `package.json` and Bun's default trusted list. Combines with `--all`. #32478
Performance#
JavaScriptCore#
#### `Temporal` v1.4.0
Temporal, the replacement for `Date`, is now enabled by default.
const departure = Temporal.ZonedDateTime.from(
"2026-08-10T09:00[America/Los_Angeles]",
);
departure.add({ hours: 11, minutes: 5 }).withTimeZone("Asia/Tokyo").toString();
// "2026-08-11T12:05:00+09:00[Asia/Tokyo]"new Date().toTemporalInstant().toZonedDateTimeISO(Temporal.Now.timeZoneId());
#### WebAssembly v1.4.0
const suspending = new WebAssembly.Suspending(async (url) => {
const res = await fetch(url);
return res.arrayBuffer();
});const { instance } = await WebAssembly.instantiate(module, {
env: { fetch: suspending },
});
await WebAssembly.promising(instance.exports.run)();
Per-builtin speedups (String, Array, Map/Set, Object/JSON/Intl) are tabulated in the changelog.
#### ES module loader rewritten in C++
JavaScriptCore's ES module loader has been rewritten (242740, 4a638109b905). The previous implementation was partly written in JavaScript, followed an outdated spec draft, and had long-standing bugs: assertion failures on valid ES modules, incorrect evaluation ordering with top-level `await`, and improperly cached resolution failures.
The new loader is pure C++ following the modern ECMAScript spec. Top-level-await evaluation order and `import()` error propagation now match the spec exactly, which fixes several ESM/CJS interop bugs (`require(ESM)`, dependency graphs with top-level `await`). #29393
#### Promises and async functions
JSC moved its Promise implementation from JavaScript builtins to C++, then layered allocation-elimination on top. This directly affects every async path in Bun (`Bun.serve` handlers, `fetch`, database drivers).
Moved to C++: `Promise.all` / `allSettled` / `any` / `resolve` / `reject` / `prototype.finally` (303603, 303706, 303732, 300603, 305030, 300130) and the MicrotaskQueue code driving AsyncGenerator (304772).
Allocation elimination:
#### String
| Operation | Commit | Speedup | | -------------------------------------------------- | -------------------------- | ---------------------- | | String#indexOf (1-char) on concatenated strings | 3a21b7550526 | 44.98× | | String#endsWith constant arg, inlined in the JIT | 901865149859 | up to 10.5× | | String#includes in DFG/FTL (constant-folded) | cc5da5a661a3 | 9.76× | | 1-char startsWith/endsWith on concatenated strings | fa83cf53f871 | 9.35× / 8.65× | | String \=== equality on Latin-1 strings in the JIT | 2fc620c5697b | 5.85× | | startsWith in DFG/FTL (constant) | 1f7d7d5a8c23 | 5.76× | | isWellFormed/toWellFormed (SIMD) | e5f7abfaed5d | 5.36× / 5.19× | | String#indexOf 1-char without rope resolve | 8d918b4794c4 | 3.99× | | startsWith/endsWith 1–16B constant immediate | 67969621f703 | 3.10× / 2.83× | | replace/replaceAll in C++ | 2b37638c2082 | up to 3.0× | | String#includes 1-char without rope resolve | dbe0b0a08f9a | 2.73× | | UTF-16↔UTF-8 conversion (SIMD) | fda0e1e0c823, 3fbc2eefcf86 | 2–5.5× (non-ASCII) | | repeat in C++ | 2526a45e199d | up to 2.1× | | padStart/padEnd in C++ | 62667c9f166e | up to 2.0× | | String#indexOf strength reduction | 81e56564a0b0 | 1.81× | | toUpperCase DFG/FTL intrinsic | 47263c1fcdf7 | \~1.4× | | String#split in C++ + DFG node | 1a0160315f98 | \~1.21–1.23× | | String#concat in C++ | fe7e0f57289e | | | String#replace (string arg) builds ropes | 69162bbdb602 | | | RegExp.prototype\[Symbol.match\] in C++ | e922a2cecfac | | | String#matchAll in C++ | 31e38187aad2 | |
#### Array
| Operation | Commit | Speedup | | -------------------------------------------------- | ------------ | ----------------- | | indexOf/includes string compare, 8 bytes at a time | f9477aedc258 | 5.39× / 5.25× | | Resizing arrays with arr.length = N | da43fc766f60 | 4.30× | | Array.of compiled as an array literal in the JIT | 445df9a64416 | up to 3.2× | | Array.prototype.flat in C++ | 546d47afe6bf | 2.0–3.2× | | Array.from(arguments) fast path | 12bbbd4b4f3c | 2.5–2.85× | | lastIndexOf in the JIT | bc818abb8d4b | up to 2.57× | | Array.from(map.keys()/values()) | 87d58dfdc60b | 1.3–2.8× | | Array.from(Set) fast path | ff70a7d00968 | 1.4–2.2× | | FTL indexOf/includes string loop layout | 6846e1b56c4b | 1.68× | | Array.of in C++ | 8d5b7e4c8f2e | 1.43–1.55× | | Array#sort on partially-sorted input | cd9b98604840 | 1.34× | | Small-array sort inlined in DFG/FTL (≤16 elements) | cab7a45a413c | | | Dedicated Array#concat DFG/FTL nodes | 94e35cf4d86c | | | unshift grow-and-shift fast path | dde627a1d427 | |
#### Map / Set / WeakMap
| Operation | Commit | Speedup | | ---------------------------------------------------- | --------------------------- | ----------------- | | \[...set\] spread walks the hash table directly | 38411ab91e01 + 4f4154bf1726 | \~6× total | | \[...map.keys()\] / \[...map.values()\] | a82152a94870 | \~3.78× | | Set#size / Map#size no longer a function call | 2e2c23521a24 | 2.24× / 2.74× | | Cloning large Maps/Sets (new Map(map)) | 253bd0c20582 | 2.09× / 1.99× | | Map/Set fast iteration (for-of) | 433704897995 | 2.05× / 1.59× | | Map/Set iterator .next() JIT-inlined | e4a0db79b3fb | | | new WeakMap() / new WeakSet() allocation JIT-inlined | d61d43e680e1, 8317f5c80ed4 | | | Map/Set backing storage shrunk to actual usage | f72ea56abf5c | memory |
#### Object, JSON, Intl, and JIT
| Operation | Commit | Speedup | | ----------------------------------------------------------- | --------------------------------------------------- | ------------ | | Intl.DurationFormat#format per-unit formatter cache | 7e34b0157828 | 26.5× | | Set class-field function names at parse time | b6a9b84dae1f | 9.32× | | Object.defineProperty | #31562 | 8.6× | | Object.hasOwn JIT-inlined | aafd2ae78418 | 4.31× | | JSON.stringify Int32-array fast path | 2ec81a993d35 | 3.08× | | Double % for positive integers | 899d00c4aa72 | 2.84× | | Math.hypot with 4+ args | 60dfa5abdbb3 | 1.6–2.4× | | JSON.parse short-string value cache | 128591e63775 | 1.50× | | Intl.Segmenter segment iteration | 31d781e3386c | 1.21× | | Cached BigInt remainder (multiplicative inverse) | 559de0581a46 | | | Objects stay on JIT fast path up to 128 properties (was 64) | f6414a9cee54 | | | Dedicated Array.isArray DFG/FTL node | b7c903516b25 | | | for...of over arrays skips redundant bounds checks | 2d4d8ad1273a | | | \=== between objects (reference equality) | 8f52a29461ca | | | Error.isError in DFG/FTL | 6c34b6a708cd | | | Eager AST build for IIFEs (skip syntax-only pass) | 709e4e7e7ec7 | cold start | | Parser/Lexer memory-layout optimization | 8243c6b69d66 | | | JSON.parse numbers via fast\_float | 1625d084a7f4 | | | JSON.stringify 16-bit→8-bit characters | 26ddd2802cf5 | | | Intl.NumberFormat creation | 367f77ea6640 | | | toLocaleLowerCase/UpperCase root-locale fast path | e14189a28e4c | |
Plus more JIT compiler work landed across the pin bumps:
Other runtime fixes#
Other `fetch()` fixes#
Other test runner fixes#
Other bundler fixes#
Other Node.js fixes#
Other package manager fixes#
`Bun.serve()`#
`Bun.$` (shell)#
`Bun.sql`#
`Bun.spawn()`#
Web Streams#
`WebSocket` client#
Windows#
Memory and reliability#
Other performance improvements#
Other JavaScriptCore changes#
Security hardening#
Platform#
Bug fixes#
We fixed over 2,900 issues since Bun 1.3. Many were found through continuous fuzzing of runtime APIs with Fuzzilli, coverage-guided fuzzing of system calls and parsers, AddressSanitizer in CI, LeakSanitizer in CI, and a continuously-running Claude Code session fuzzing Bun canary's outputs against Bun v1.3.14 and Node.js.
Thank you
Bun is free, open source, and MIT-licensed. We receive a lot of contributions from the community, and we'd like to thank everyone who fixed a bug or contributed a feature in this release.









