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 upgrade## Node.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%.

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 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.

bun --bun next build works on Next.js 16.3 with Turbopack and the React Compiler.

vitest runs under Bun, including --coverage, with the threads and forks pools.

OpenTelemetry's http and fs instrumentation export spans, and shimmer and require-in-the-middle patch bundled code.

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 devconnects HMR and the Nuxt DevTools.
  • testcontainersand- dockerode:- container.exec()works.
  • https-proxy-agentand- socks-proxy-agent:- http.request()tunnels through them.
  • crawlee: crawls through- proxy-chain.
  • @grpc/grpc-jsand- 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- httpand- httpsrequests.
  • Fastifyand- inject()- light-my-request: work.
  • happy-dom: no longer breaks- console.log.
  • piscina: runs.

New Node.js APIs in Bun:

  • worker_threads- resourceLimits,- stdout,- stderr, and- evaloptions.
  • ws:- 'upgrade'and- 'unexpected-response'events.
  • socket.upgradeTLS({ isServer: true })
  • node:cluster
  • node:repl- node:trace_events- node:domain

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.

Startup

On Windows, Bun starts 2.5Γ— faster.

| hello.json 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.json 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.

  • bun --cpu-prof- .cpuprofile. Open it in Chrome DevTools or VS Code.
  • bun --heap-prof- .heapsnapshot. Open it in Chrome DevTools.
  • node:inspector- Sessioncan start and stop a CPU profile while the app runs, with- Profiler.startand- Profiler.stop. #25939
  • Datadog:- dd-tracetraces requests and- @datadog/pprofprofiles CPU continuously. #36747
  • OpenTelemetry: the- @opentelemetry/instrumentation-httpand- @opentelemetry/instrumentation-fspackages from npm work with- node:httpand- node:fsin Bun. The- shimmerand- require-in-the-middlepackages they depend on can patch bundled code.
  • Async stack traces: an error from- fs.promises,- fetch(), S3, DNS, or crypto points at the- awaitin your code, not at native frames.

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_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 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.

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(); });
- macOS:- kqueuewith- EVFILT_MEMORYSTATUS, the same event libdispatch uses for- DISPATCH_SOURCE_TYPE_MEMORYPRESSURE.- levelis- "warning"or- "critical".
- Linux: a PSI trigger written to- /proc/pressure/memory(or the cgroup's- memory.pressure), watched with- epollfor- EPOLLPRI.- levelis- "critical".
- Windows:- CreateMemoryResourceNotification(LowMemoryResourceNotification), waited on with- RegisterWaitForSingleObject.- levelis- "critical".

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:

  • Download:- fetch()β†’- DecompressionStream("gzip")β†’- TextDecoderStreamβ†’- for await
  • Upload:- fs.createReadStream()β†’- CompressionStream("gzip")β†’- fetch()POST body
  • Transcode:- fs.createReadStream()β†’- TextDecoderStreamβ†’- TextEncoderStreamβ†’- fs.createWriteStream()
  • Subprocess:- fetch()body β†’- catstdin, then- catstdout β†’- for await

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.

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.

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.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.

all-in-one toolkit

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 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\n**bold**", { heading: (children) => `\x1b[1;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() 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 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 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" concurrently``bun run --parallel build test``# Glob-matched script names``bun run --parallel "build:*"``# Run "build" in every workspace package``bun run --parallel --filter '*' build``# Keep going even if one package fails``bun 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: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.

bun:ffi gets up to 3x faster- --cpu-prof,- --cpu-prof-md- .cpuprofilefor Chrome DevTools, or the same profile as a Markdown report for pasting into a bug or an LLM;- BUN_CPU_PROFILE=1for processes you can't pass flags to. #24112 #26327
- --heap-prof,- --heap-prof-md- .heapsnapshot, or a Markdown report of the biggest types and objects. #26326
- Async stack traces: Errors from async native APIs (- fs.promises,- Bun.file(), S3, DNS, crypto,- fetch) point back to the- awaitin your code. #28652
- --no-orphans
- --no-env-file- .envloading in production and CI (- env = falsein- bunfig.toml). #24767

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

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.

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.

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.mp4```` HTTP/1.1 206 Partial Content Content-Range: bytes 0-1023/104857600 ```curl -H 'If-None-Match: "1a2b3c"' localhost:3000/logo.png`HTTP/1.1 304 Not ModifiedIn 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() 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()'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" }, }, });
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.

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

  • Bun.JSON5- Bun.JSON5.parse()/- stringify(); import- .json5files directly. Replaces json5.
  • Bun.JSONL- parse()and streaming- parseChunk()for newline-delimited JSON. Replaces ndjson.
  • Bun.JSONC.parse()- tsconfig.json. Replaces jsonc-parser.
  • Bun.XML- .xmlfiles directly. Replaces fast-xml-parser and xml2js.
  • Bun.TOML- toml-test; new- stringify(). Replaces @iarna/toml.
  • Bun.Archive
  • Bun.sliceAnsi(),- Bun.wrapAnsi(),- Bun.stringWidth()
  • URLPattern
  • CompressionStream/- DecompressionStream- gzip,- deflate,- deflate-raw, plus- brotliand- zstd.
  • Response.textStream()- ReadableStream<string>of the body decoded as UTF-8.
  • process.on("memoryPressure")
  • ML-DSA and ML-KEM: NIST post-quantum signatures and key encapsulation in- crypto.subtleand- node:crypto.
  • Bun.spawn({ cgroup })
  • bun repl- -e/- -p.
  • bun ./README.md

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:

| Scenario | bunv1.4 | npmv12.0.2 | pnpmv11.21.0 | yarnv1.22.22 |
|---|---|---|---|---|
| First install, ever no cache Β· no lockfile Β· no node_modules | 1.41s 15Γ— faster 376 MB | 18.1s 503 MB | 13.5s 1.8 GB | 20.5s 498 MB |
| Fresh checkout, warm cache no lockfile yet Β· every package already in the cache | 251ms 30Γ— faster 52 MB | 7.61s 798 MB | 2.38s 1.1 GB | 1.83s 204 MB |
| CI without a cache lockfile committed Β· every tarball fetched from the registry | 951ms 19Γ— faster 214 MB | 4.92s 455 MB | 11.7s 2.7 GB | 17.6s 323 MB |
| CI with a warm cache lockfile committed Β· dependency cache restored Β· node_modules rebuilt | 210ms 21Γ— faster 12 MB | 4.45s 698 MB | 1.92s 1.4 GB | 1.76s 205 MB |
| node_modules there, cache gone already installed Β· only the dependency cache was cleared | 12ms 33Γ— faster 12 MB | 384ms 129 MB | 399ms 141 MB | 212ms 107 MB |
| Everything already up to date the reinstall after nothing changed | 12ms 33Γ— faster 12 MB | 337ms 114 MB | 400ms 141 MB | 211ms 107 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

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` 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 β†’ latest``bun pm diff react@18.2.0 19.0.0 # two published versions``bun pm diff ./vendored-pkg pkg@2.1.0 # a folder against a published version``bun pm diff react-dom@18.2.0 18.3.1 '*.min.js'``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 fix```` fixing: ms@0.7.0 β†’ 0.7.1 lodash@4.17.20 β†’ 4.17.21 package.json: 4.17.20 β†’ 4.17.21 blocked 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 deduperemoves duplicate versions of packages frombun.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 dedupe```` bun 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 prunedeletes packages fromnode_modulesthat aren't inbun.lock` anymore.

bun prune --production also deletes devDependencies, so you can build with them and ship without them. #38333

bun prune --production```` bun 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` 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 now updates the dependencies of your dependencies too, not just the ones in your package.json.

bun update``bun update zod``bun update '@types/*' --latest``bun update <name> updates that package everywhere it appears, and bun update '@types/*' takes a pattern. #38333

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 api``bun run --filter 'web...' build``--filter 'web...' means web and everything it depends on. --filter '...web' means everything that depends on web.

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.

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" } }
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-..."]
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.

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 touches``bun test --parallel --timings=timings.json --update-timings``bun test --parallel --shard=1/3 --timings=timings.json # in CI, per machine``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 --parallel``bun 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.

--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. #29354bun 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:

  • creates a new globalThis, so properties a file put onglobalThis, patched built-ins, and module-level state are gone
  • clears the ESM and CommonJS module registries, so every file re-evaluates its imports
  • closes servers, sockets, file watchers, and subprocesses the file left open, cancels its timers, and restores fake timers
  • re-runs --preloadscripts in the new global

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:

  • Fake timers a file left installed no longer leak into the next file. #36385
  • Subprocesses started at module scope are killed when the file ends, instead of outliving the run. #38750
  • process.chdir()in one file no longer changes the working directory of the next file. #36175
  • Servers, sockets, and other handles a file leaked no longer pin its global object in memory. #31793
  • A --preloadscript with top-levelawaitfinishes before the first test runs. #30888
  • Native addons (N-API) work across files, instead of pointing at the previous file's global. #30216
  • Fixed a crash when garbage collection ran during the swap between two files. #29573
  • The debugger resolves breakpoints in files loaded under --isolate. #37352

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/3``bun test --shard=2/3``bun test --shard=3/3``--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 durations``bun test --shard=1/3 --timings=timings.json # cut shards by equal time``bun 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 --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 / tag``bun test --changed=main``bun 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.

node_modules), then walks it backwards from whatever git diff reports.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() 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#

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, });
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"], });
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
Bun.build() accepts a files option: a map of paths to strings, Blobs, or TypedArrays. 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 + "!"; }`, }, });
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)``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);
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=dist``bun build entry.js --metafile-md=analysis.md --outdir=dist``bun build entry.js --metafile=meta.json --metafile-md=meta.md --outdir=distYou 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.

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 binary`--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

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.

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 |

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.1 const 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);
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") 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 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

JavaScriptCore rewrote their Promise implementation to reduce overhead.

Time per operation, 2 million iterations:

| Operation | Bun 1.4 | Bun 1.3 |
|---|---|---|
| Promise.raceof 4 promises | 142 ns | 342 ns |
| Promise.allof 4 promises | 207 ns | 316 ns |
| Promise.allSettledof 4 promises | 253 ns | 411 ns |
| awaita resolved promise | 84 ns | 143 ns |
| .then()chain of 4 | 172 ns | 332 ns |
| asyncfunction with noawait | 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.

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 undefined
If 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({ 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({ 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).

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 }.

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 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

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 | bash``uname -sm``FreeBSD amd64``bun --version``1.4.0Bun 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.0
Bun ships experimental Android builds for aarch64 and x64 with every release.

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 --version``ldd (GNU libc) 2.17``bun --version``1.4.0On 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.ts### Ready 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"] } }
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

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:- process.versions.modulesis now- 147. Packages that pick a prebuilt native addon by- NODE_MODULE_VERSIONneed a build for- 147.- res.writeHeader()is gone; use- res.writeHead(). Paused-mode- readable.read()returns one chunk. #31991
  • New monorepos default to the isolated linker.- bun.lockrecords- configVersion: 1. Existing lockfiles keep the hoisted linker. To opt out, pin- linker = "hoisted"in- bunfig.toml. #24236
  • Bun invoked as(- node- bun --bun,- bunx --bun, a- nodesymlink) does not load- .envfiles. This matches Node. Pass- --env-fileto keep them. #36610
  • Bun.YAMLfollows YAML 1.2- yes/- no/- on/- offare strings.- on:in a GitHub Actions workflow parses as- "on". #25537
  • Bun.TOMLand- bunfig.tomlare strict- Number.MAX_SAFE_INTEGERare- SyntaxErrors. #32953

Every behavior change in 1.4

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:

  • process.versions.modulesis- 147. Packages that pick a prebuilt native addon by- NODE_MODULE_VERSIONneed a build for- 147.
  • res.writeHeader()in- node:httpis removed. Call- res.writeHead().
  • In paused mode, readable.read()with no size returns one buffered chunk. Before, it returned the whole buffer. (setEncoding()keeps the old behavior.) Loop until it returnsnull.

res.writeHeader(200, { "Content-Type": "text/plain" }); res.writeHead(200, { "Content-Type": "text/plain" });
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 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 is now engine-native. This changes four things:

  • A returns: "cstring"value or acstringcallback argument is a plain string. ANULLpointer isnull.
  • new CString(ptr)returns a string with no- .ptr,- .byteLength, or- .arrayBuffer. Keep the original pointer if you need to free it.
  • napi_envand- napi_valueargument types throw- TypeErroroutside- cc().
  • dlopen()and the other entry points throw- TypeErrorwhen the JIT is disabled.

const str = new CString(ptr); my_library_free(str.ptr); my_library_free(ptr);
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

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"
```
New lockfiles use version 2. Version 2 adds two stricter parse-time checks:

  • npm packages resolved to a tarball outside your configured registry must carry an integrity hash.
  • git dependency entries are validated to block path traversal (no /,\, or..).

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": { ... },
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 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" }
The rewritten Bun.TOML parser throws SyntaxError instead of BuildMessage. It rejects TOML that the old parser let through:

  • unquoted string values
  • missing newlines between key/value pairs
  • integers outside Number.MAX_SAFE_INTEGER

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"
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

"." 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";
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

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" } }
With useDefineForClassFields: false, Bun now does what tsc does:

  • Instance field initializers move into the constructor, after parameter-property assignments.
  • Plain declaration-only fields are dropped.

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

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(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 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" });
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 withno matches found`. Write the pattern in the template instead. #31220

await $`echo ${"**/"}*`; await $`echo **/*`;
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#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..."
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, });
Two node:dgram changes, both matching Node.js:

  • bind()on a socket that is already bound throws- ERR_SOCKET_ALREADY_BOUND. Before, it emitted an- errorevent.
  • bind(),- send(),- address(),- remoteAddress(), and- close()on a closed socket throw- ERR_SOCKET_DGRAM_NOT_RUNNING. Before, they threw an uncoded- TypeError(or, for- bind(), emitted an- errorevent).

Code that handled a second bind() in an error listener needs a try/catch. #33037 #33024

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#

  • Sockets accepted by net.Serverortls.Serverare no longer resumed automatically. Bytes that arrive before a'data'listener is attached are buffered, as in Node.js.
  • Only a literal rejectUnauthorized: falsedisables verification. This applies totls.connect()andtls.Server. Before,nulldid too.
  • requestCertmust be literally- true.
  • A tls.Serverno longer readsNODE_TLS_REJECT_UNAUTHORIZEDfor its default.
  • handshakeTimeoutnow also emits the socket's- 'timeout'event (after- 'tlsClientError'). It leaves the socket open instead of destroying it.
  • An exception thrown in an onreadcallback or'secureConnection'listener is now an uncaught exception.

tls.createServer({ key, cert, ca, requestCert: 1, rejectUnauthorized: null, requestCert: true, rejectUnauthorized: false, });
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"
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() 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"
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(), ws.publish(), ws.publishText(), and ws.publishBinary() now return:

  • 0if the message was dropped for any subscriber, or the topic had no subscribers
  • -1if any subscriber has backpressure
  • the byte count otherwise

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 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

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
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

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

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() 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(); });
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);
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"
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 }
- The - bun feedbackcommand is removed. #38444
- Bun.password.hash()with argon2 now requires- memoryCostof at least 8. Hashes made by Bun 1.3 with a lower- memoryCoststill verify. #39596
- bun updatenow moves transitive packages.- bun update <name>errors (exit 1) on a name nothing depends on. Before, it added the package.- --production/- --prodon- updatemeans "only update- dependenciesand- optionalDependencies".- -iupdates only the selection. #38333
- A project's - bunfig.tomlnow overrides any- .npmrcfor the same key. #38333
- bun install <pkg> --filter xnow edits- x, not the root.- bun add y --filter xno longer installs a package named- x.- add/- remove --filter '*'no longer includes the root. #38333
- A plain - bun add xin a workspace whose default catalog lists- xnow writes- catalog:.- audit fixmay rewrite exact pins.- --frozen-lockfile --lockfile-onlywrites nothing. Overrides/catalog changes fail frozen installs. #38333
- Projects with - catalog:peers or dead- pkg@rangeoverride rows see one-time lockfile churn after upgrading. Lockfiles that use nested or version-scoped overrides are- lockfileVersion: 3. Older Bun cannot read version 3. Turborepo and Nx changes to accept it are open upstream. Dependabot needs nothing. #38333
- bun initnow writes typescript- ^7. Before, it wrote- ^5, or nothing in the React templates. A fresh project installs TypeScript 7. #33265 #39341
- bun initwith a non-TTY stdin (CI, a piped- spawn) now behaves as- bun init -y. Before, it opened the template picker. #35165
- bun update -iwith a non-TTY stdin now exits with code- 1and an error. Before, it opened the picker. Use- bun updateor- bun outdated. #35165
- bun updatewith no package names now rewrites the root- catalogand- catalogsentries (to the newest version with- --latest). It leaves- catalog:references in workspace- package.jsonfiles in place. Before, it replaced them with- ^<version>. With- --recursiveor- --filter, it rewrites each selected workspace's- package.json. It touches the root catalog only when the root is selected. #36304 #36360 #36379
- bun installand- bun removenow drop a package from- bun.lockwhen only an optional peer still points at it. A lockfile whose nested optional-peer placement differs from a fresh install may be rewritten once, on the first install after upgrading. #35681
- trustedDependenciesand- --trustentries now match the exact package name. Before, they matched a truncated name hash. A package that only collides with an entry's hash no longer runs lifecycle scripts. If you meant to trust it, add the package's exact name. Entries loaded from a legacy- bun.lockbstill match by hash. #31218
- bun install --registry <url>no longer sends the configured registry's credentials to- <url>when it is a different host, or when it downgrades from- https://to- http://. #36165
- workspace:ranges are now honored only in the root and workspace- package.jsonfiles. Inside a downloaded package, they fail to resolve like any other unknown range. Before, they created a workspace package. #37669
- Bun.JSONC.parse()now throws- SyntaxErroron invalid input. Before, it threw a- BuildMessage.- Bun.JSONC.parse("")also throws- SyntaxError. Before, it returned- {}. #35066
- Wildcard - exportsand- importstargets in- package.jsonthat do not name an existing file are now retried with each known extension, or with- .tsin place of- .js. A subpath such as- @modelcontextprotocol/sdk/server/stdionow resolves. Before, it failed with- Cannot find module. #36299
- bun buildnow bundles an unresolvable- require(),- require.resolve(), or- await import()inside- catchas a runtime throw. Before, it failed with- Could not resolve. #35659
- Assigning to an imported binding is no longer a parse error at runtime. The module loads, and the assignment throws - TypeErrorwhen reached.- bun buildstill reports it as an error. #36046
- bun build --target browsernow honors a package's- browserfield entry for a Node builtin (- "crypto": falseor a remap). Before, it bundled the polyfill. It also resolves- require()of a package that has- jsnext:mainbut no- modulefield to its- main, as it already did for- module. #35447 #36597
- A bundled - import * as nsnamespace now enumerates its exports in sorted order. The spec requires this, and unbundled code already did it. Update snapshots that pinned the old order. #35957
- bun build --minifyno longer generates a bare- $identifier. That identifier shadowed jQuery's- $when a bundle was loaded as a classic script. #35668
- ESM imports of builtin modules ( - node:fs,- node:process,- node:module), and- export * from "bun"or a non-literal- import()of- "bun", no longer evaluate every lazy export at import time. Each export is evaluated when something first binds to it. For- "bun", a property that throws when constructed (- Bun.rediswith an invalid- REDIS_URL) now throws at the binding that uses it. Before, it failed the whole module. #37525 #37714 #37726
- bun build --metafilenow sets a bundled import's- pathto the imported file's- inputskey (- src/b/shared.js). Before, it was the raw specifier or an absolute path, so- metafile.inputs[path]never matched. #34534
- Bun.randomUUIDv7()now throws- RangeErrorfor a timestamp of- 2**48or more. Before, values up to- 2**53 - 1were truncated to 48 bits. It also throws for a- NaNtimestamp, an invalid- Date, or a- Datebefore 1970. Before, these were encoded as- 0. #34021
- Bun.udpSocket({ connect: { port } })now throws for a port outside- 1to- 65535. Before, it connected to port- 0and dropped every datagram. #34029
- Bun.YAML.parse()now throws- SyntaxErroron a NUL byte. Before, it silently stopped there. If you pad a buffer with zeros, pad with newlines instead.
- Bun.color()output changed for- "ansi-16"(a real 16-color escape such as- \x1b[91m),- "hsl"and- "lab"(valid CSS such as- hsl(0, 100%, 50%)), and near-black- "ansi-256"colors. A 24-bit number such as- 0xff0000is now opaque. Before, it had alpha- 0. #33328 #33046
- Bun.Cookienow serializes- Expireslike- Date#toUTCString(). Before, the weekday was one day off, the day was unpadded, and the zone was- -0000instead of- GMT. Update tests that assert the old string. #32926
- structuredClone(),- self.postMessage()inside a worker, and- new Worker(path, { transferList })now throw- TypeErrorfor a transfer entry that is not an object, such as- null. Before, they skipped it. #32809
- bun:ffi- viewSource()and- new JSCallback()now throw on invalid arguments. Before,- viewSource()returned the error, and- JSCallbackreturned an instance whose- ptrwas- undefined. #34396
- Bun.FileSystemRouter.match()now returns- nullfor a non-empty path string that does not start with- /. Before,- "Xtop"matched- /top. Full URLs are unaffected. #34028
- Bun.Terminal#write()now returns the full input length, because the whole input is buffered. Before, it returned only the bytes flushed synchronously, and re-sending the rest duplicated input.- drainnow fires on POSIX. #34289
- new Bun.RedisClient(url)now throws- Invalid database number in Redis URL: "notadb"when the URL path is not a database index, such as- redis://host/notadb. Before, it connected to database- 0. #34039
- Bun.spawn()and- Bun.spawnSync()now throw- ERR_INVALID_ARG_VALUEfor a NUL byte in- argv0or- cwd. Before,- argv0was silently cut at the NUL.
- Bun.$now fails with- ambiguous redirectwhen a redirect target such as- > *.txtexpands to more than one word. Before, the words were joined into one path. #34324
- Nine input validation hardening rounds tightened input validation and bounds checks across the runtime. Each PR lists the subsystems it touched.
- Bun.spawn()and- Bun.spawnSync()now throw- ERR_OUT_OF_RANGEfor- timeout: NaNand- ERR_UNKNOWN_SIGNALfor- killSignal: 0. Before,- timeout: NaNmeant no timeout, and- killSignal: 0sent a no-op signal. The child kept running either way. #35348
- Bun.spawn()and- Bun.spawnSync()now throw- AbortError(with- causeset to- signal.reason) for a- signalthat is already aborted. No process is created. Before,- Bun.spawn()started the child and then killed it, and- Bun.spawnSync()ran it to completion.
- bun:sqlite- db.close()now finalizes every- db.query()statement, not only the cached ones.- db.prepare()statements keep working until finalized.- db.close(true)finalizes those too. Before, it threw- database is locked. A statement that- close()finalized throws when used. #36573 #36793
- bun:sqliterow objects and- stmt.columnNamesnow keep a column aliased- AS "". Before, it was dropped, and a trailing one made- .all()return a number.- columnNamesnow throws after- finalize(). #34925
- Two robustness passes changed several edge cases. - Bun.spawn({ stdout: typedArray })throws instead of aborting.- FileSystemRouterno longer matches a URL shorter than the route pattern. CSS serialization escapes identifiers consistently. Workers read- process.envat runtime instead of at transpile time. The PR bodies list the rest.
- S3Client.list()entries now expose- checksumAlgorithm. The misspelled- checksumAlgorithmestill works but is non-enumerable. It no longer appears in- Object.keys()or- JSON.stringify()output. #36502
- More inputs that were silently accepted now throw: - odd-length hex passed to Bun.CryptoHasher#update()
- a primitive optionsargument toTextDecoder#decode()
- invalid arguments to crypto.createDiffieHellman()(before, returned as an error object)
- NaNor- undefinedseconds in- RedisClient#expire()(before, sent- EXPIRE key 0)
- fractional or beyond-32-bit ports for Bun.udpSocket(), andcost,timeCost, ormemoryCostvalues forBun.password(before, wrapped or truncated into range)
- Bun.openInEditor()with no editor found (before, returned silently)
- an fs.write()``offsetpast the end of the buffer whenlengthis omitted (before, wrote 0 bytes)

  • odd-length hex passed to
  • new URL(bad)now throws Node's- TypeError: Invalid URLwith- codeand- inputset. It rejects an invalid punycode- xn--host for special schemes. #34660
  • assert.deepStrictEqual()and- util.isDeepStrictEqual()now compare prototypes, as in Node.js.- Bun.deepEquals()and- expect()are unchanged. #34660
  • child_process.spawn()now ignores- options.encoding, as Node does.- stdoutand- stderralways emit- Bufferchunks. Call- child.stdout.setEncoding()to get strings. #36050
  • N-API status codes on validation and failure paths now match Node 26. For example, - napi_wrap()on a non-object returns- napi_invalid_arg.- napi_reference_ref()returns- 0once the referent has been collected.- napi_get_buffer_info()rejects a bare- ArrayBuffer. Addons that branch on a specific status see Node's values. #36805 #36850
  • fs.open()now throws- ERR_INVALID_ARG_VALUEwhen an object is passed as- flags. Before,- {}opened the file read-only. #34505
  • fs.rm()and- fs.rmSync()now reject- recursive,- force,- retryDelay, or- maxRetriesexplicitly set to- undefined, as Node does. Omit the key instead. #34505
  • On Windows, - process.binding("uv")and every- node:fserror now use libuv's error numbers (- -4058for- ENOENT). Before, the binding and some fs calls such as- fs.access()reported CRT values like- -2. POSIX is unchanged. #34505
  • fs.write(),- fs.writev(), and- fs.readv()now operate at the current file offset when- positionis not a safe integer (- NaN,- Infinity, a BigInt), as Node does.- fs.createWriteStream()no longer overwrites the start of the file after a short write. #36135
  • fs.appendFile()and- fs.appendFileSync()with- { flag: "w" }now truncate the file, as the flag says. Before, they appended. #36553
  • fs.watch()with- recursive: trueon Linux and FreeBSD now emits- 'error'(for example- ENOSPC, with the subdirectory's- path) for a subdirectory it cannot watch. It keeps watching the rest. Before, the subdirectory was skipped silently. #36415
  • session.remoteSettingsin- node:http2is now- {}while the session is connecting or destroyed, as in Node. Before, it was- null. Reading a setting right after- connect()now returns- undefinedinstead of throwing.- session.localSettingsis- {}at that point too. Before the peer's ACK, it shows only the defaults plus your- customSettings. #34358
  • node:http2- stream.end(chunk)now sets- END_STREAMon the- DATAframe carrying- chunk, as Node does. Before, it sent an empty frame after it. #34432
  • node:http2- pushStream()now reports invalid headers only through its callback, as Node does. The pushed stream no longer also emits- 'error'. #36551
  • node:testsuites marked- skipno longer run their callback. Before, the body ran and its tests were registered.- { skip: true, todo: true }now counts as a skip, not a todo. #34444
  • process.execve()now throws an error carrying- code,- syscall,- errno, and- pathwhen the exec fails. This matches Node 26. Before, it printed an error and aborted.
  • process.titlenow defaults to- argv[0]as invoked. Before, it was- "bun". #31831
  • require(),- (await import()).default, and- process.getBuiltinModule()now return the same object for a natively implemented builtin such as- node:buffer.- module.builtinModulesno longer lists- bun:wrap. #31831
  • process.reallyExit()no longer emits- 'exit'before exiting. This matches Node. If you rely on- 'exit'listeners running, call- process.exit(). #34997
  • util.styleText()now follows the Node 26 API. It returns plain text when the target stream (- process.stdoutby default) is not a TTY. Pass- { validateStream: false }to always get escape codes.- util.inspect()now brackets- ArrayBufferinternals (- [byteLength]: 4).- util.format("%s", date)prints the ISO form.- vmmodule namespaces have a- nullprototype. #34434
  • Warnings are now printed as - (node:PID) [CODE] Name: message. Adding a- 'warning'listener no longer replaces the default printer (see- process). Silence it with- process.removeAllListeners("warning")or- --no-warnings. #31831 #37344
  • crypto.subtleis now a getter on- Crypto.prototype. It throws- ERR_INVALID_THISwhen read off anything but a- Crypto.- subtle.importKey("jwk", ...)with a non-JWK object now rejects with- DataError. Before, it threw- TypeError. An unknown key format is reported as- ERR_INVALID_ARG_VALUE. #34838
  • fetch()now returns a rejected promise when reading its options throws. Before, it threw synchronously. A synchronous- try/- catcharound an unawaited call no longer catches it. #33649
  • Response.redirect(url)now parses and re-serializes an absolute- urlbefore writing- Location.- http://example.combecomes- http://example.com/. A relative- urlis written as-is. A relative- urlcontaining a code point above U+00FF now throws- TypeError. #33126
  • fetch()now rejects the body read when a compressed response with neither- Content-Lengthnor- Transfer-Encodingis cut off early. Before, it resolved with partial data. #34922
  • fetch()now errors the response body when its- signalaborts, even if the whole body has already arrived. Pending and later reads reject with- AbortError(or the abort reason). Before, they resolved with the buffered bytes. This matches Node.js.
  • fetch()now parses- Connection,- Transfer-Encoding,- Content-Encoding, and- Upgradeas token lists. Any- closetoken disables connection reuse.- Transfer-Encoding: gzip, chunkedis framed as chunked instead of rejected.- identitycodings are ignored. A connection that carried an HTTP/1.0 response is reused only if the response said- Connection: keep-alive. #36777 #37530
  • fetch()now sends Latin-1 request header values byte-for-byte, per the Fetch spec. Before, it UTF-8 encoded them.- cafΓ©goes out as- 63 61 66 e9. #35338
  • fetch()with- redirect: "error"now rejects only on- 301,- 302,- 303,- 307, and- 308, per the Fetch spec. Other- 3xxsuch as- 304now resolve. Before, they rejected with- UnexpectedRedirect. #36539
  • fetch()now treats its idle timeout (still 300 seconds by default) as one deadline for receiving the whole response header block. A server that trickles header bytes now times out. Before, each byte reset the timer. #36145
  • Bun.serve({ port })now throws a- RangeErrorfor non-integer, negative, or out-of-range port values. Before, it silently clamped:- port: 65536started a server on port 65535, and- port: -1bound a random port. Numeric strings and- null/- undefinedstill work. #34957
  • Bun.servenow treats a returned- Responsewith a status outside- 100to- 999, such as- Response.error(), like a thrown error. It goes to- error()and answers- 500by default. Before, it wrote an invalid status line. #33400
  • Bun.serveper-method route objects (- { GET: handler }) now answer- HEADwith the- GEThandler when no- HEADkey is set. Before, the request fell through to the next route or- 404. #32822
  • Bun.serveWebSocket connections now close with code- 1006and reason- Received an incorrectly masked framewhen a client sends an unmasked frame, per RFC 6455. Before, the frame was parsed as if it were masked. #32820
  • Bun.servenow answers- 413and closes the connection when a single chunk of a chunked request carries more than 16 KiB of chunk extensions. This matches- node:http. #34504
  • Bun.serveHTML routes with- development: falseno longer emit- sourceMappingURLor- debugIdcomments.- .mapURLs answer- 404.- [serve.static] sourcemap = "linked"in- bunfig.tomlrestores them. #36982
  • Bun.serve({ tls: [...] })now enforces- requestCertand- rejectUnauthorizedset on a per-- serverNameentry. Before, they were ignored. Clients of that name without an acceptable certificate are refused. With- http3: true, they are enforced over QUIC too. #36174 #37669
  • Bun.servenow answers- 400to a request whose- Transfer-Encodingnames anything besides a single final- chunked(- gzip, chunked,- chunked, chunked). Before, such requests got- 200with the body still encoded.- node:httpstill accepts- gzip, chunkedbut now rejects- chunked, chunked. #35295
  • server.upgrade()now returns- falseunless the request has- Upgrade: websocketand a well-formed- Sec-WebSocket-Key. It answers- 426when- Sec-WebSocket-Versionis not- 13. Before, any- GETwith a 24-byte key was upgraded. #35298
  • ws.subscribe()and- ws.unsubscribe()now return- falseon a closed- ServerWebSocket(and are typed- boolean). #35236
  • ws.send()and- publish()of an in-memory- Blobnow send its bytes as a binary frame. Before, they sent the text- [object Blob]. A- Bun.file()blob throws; read it first. #36032
  • Bun.servestatic and file routes now evaluate- If-Matchand- If-Unmodified-Sinceon- GETand- HEAD. They answer- 412when the precondition fails. Before, both headers were ignored. #35169
  • new WebSocket(url, { proxy })now throws- SyntaxErrorat construction for a proxy scheme other than- httpor- https. Before, it failed later with- Connection ended. #35147
  • Bun.deepEquals()now distinguishes boxed BigInts and Symbols with different contents (- Object(1n)vs- Object(2n)). In strict mode (- toStrictEqual(),- assert.deepStrictEqual()), it also distinguishes a boxed string or typed array that carries extra own properties. Before, all of these compared equal. #34434
  • Bun.sql's- connectionTimeoutnow bounds the whole handshake. Before, it restarted on every packet. A Postgres server that sends a second authentication request now fails the connection with- ERR_POSTGRES_UNEXPECTED_MESSAGE. #36308
  • Bun.sqlnow honors- PGSSLMODEfrom the environment. A URL- ?sslmode=still wins.- PGSSLMODE=requireagainst a server without TLS now fails. Before, it connected in plaintext.- ?ssl=and- ?ssl-mode=are accepted as spellings.- tls: { caFile }enables verification like- ca. #36840 #37669
  • Bun.sqlnow decodes a Postgres- date,- timestamp, or- timestamptzof- infinityor- -infinityas the number- Infinityor- -Infinity. Before, it was an invalid- Date. Check for it before calling- Datemethods on the value. #35121
  • On Linux, Bun no longer sets - prctl(PR_SET_THP_DISABLE)at startup. That flag was inherited across- execve. It disabled transparent huge pages in every child process spawned via- Bun.spawn,- bun run, or lifecycle scripts. Bun's own allocations now opt out per-mapping via- MADV_NOHUGEPAGE. Child processes inherit the system THP setting. #36990

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:

  • syntax highlighting
  • the standard terminal line-editing shortcuts (Ctrl-A, Ctrl-E, Ctrl-K)
  • persistent history (~/.bun_repl_history)
  • tab completion
  • multi-line input with automatic continuation detection
  • the standard .help/.load/.save/.editorcommands

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 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)'``2``bun 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.mdproject═══════ β”‚ 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({ β”‚ fetch: () => new.serveResponse("hello"), β”‚ }); └─ β˜’ tables β˜’ task lists ☐ you, runningbun ./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() 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");
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:

  • ML-DSA (FIPS 204 signatures) at parameter sets 44/65/87, for sign/verify.
  • ML-KEM (FIPS 203 key encapsulation) at 768/1024, via four new SubtleCryptomethods:encapsulateBits,encapsulateKey,decapsulateBits, anddecapsulateKey.

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).

  • generateKeyPairaccepts- ml-dsa-44/- -65/- -87and- ml-kem-768/- -1024.
  • sign()and- verify()work with ML-DSA keys.
  • createPublicKey/- createPrivateKeyimport PEM, DER, encrypted PKCS#8, and JWK (- kty: "AKP").

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(); });
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("\x1b[31mhello\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() 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("\x1b[31mThe quick brown fox\x1b[39m", 10); console.log(wrapped); // \x1b[31mThe quick\x1b[39m // \x1b[31mbrown fox\x1b[39m
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

5Β·bytes:

5Β·columns:

5

Bun.stringWidth() is 7–56Γ— faster on Chinese, Japanese, and Korean text.

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.cpuprofile``bun --cpu-prof-md ./app.tsThe 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 analysis``bun --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
`` A new--no-orphansflag (and[run] noOrphans = truein bunfig, orBUN_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 tobun run