首页 > AI前沿 > Bun 1.4

Bun 1.4

Hacker News 2026-08-20 22:10 1 阅读 查看原文
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 | bash powershell powershell -c "irm bun.sh/install.ps1 | iex" npm npm install -g bun brew brew install oven-sh/bun/bun docker docker pull oven/bun Bun 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 v1.4.0# Playwright now runs on Bun: drive a browser with connectOverCDP(), run your suite with playwright test and a playwright.config.ts, open --ui, and launch Chromium on Windows. Next.js 16 v1.3.2# bun --bun next build works on Next.js 16.3 with Turbopack and the React Compiler. vitest v1.4.0# vitest runs under Bun, including --coverage, with the threads and forks pools. OpenTelemetry v1.4.0# OpenTelemetry's http and fs instrumentation export spans, and shimmer and require-in-the-middle patch bundled code. dd-trace v1.4.0# dd-trace traces and @datadog/pprof profiles continuously; the V8 C++ APIs they link against are implemented. Additional Node.js compatibility improvements# Every day, Bun gets closer to 100% Node.js compatibility. More packages now work in Bun without changes: Nuxt: nuxt dev connects HMR and the Nuxt DevTools. testcontainers and dockerode: container.exec() works. https-proxy-agent and socks-proxy-agent: http.request() tunnels through them. crawlee: crawls through proxy-chain. @grpc/grpc-js and ConnectRPC: servers behind Envoy and clients behind AWS ALB work. amqplib: connects to RabbitMQ. @aws-sdk/client-s3: streaming uploads work. TypeORM: starts with the decorator settings in your tsconfig.json. nock: intercepts http and https requests. Fastify inject() and light-my-request: work. happy-dom: no longer breaks console.log. piscina: runs. New Node.js APIs in Bun: worker_threads: resourceLimits, stdout, stderr, and eval options. ws: 'upgrade' and 'unexpected-response' events. socket.upgradeTLS({ isServer: true }): server-side STARTTLS. node:cluster: shares listening sockets between workers. node:repl, node:trace_events, node:domain: implemented. 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-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. On Linux, Bun starts 2× faster and uses less than half the memory. Binary size# On Linux and Windows, Bun gets up to 17% smaller. macOS binaries are about 1 MB larger. Observability# The tools you already use work with Bun 1.4. bun --cpu-prof writes a .cpuprofile. Open it in Chrome DevTools or VS Code. bun --heap-prof writes a V8-compatible .heapsnapshot. Open it in Chrome DevTools. node:inspector: a Session can start and stop a CPU profile while the app runs, with Profiler.start and Profiler.stop. #25939 Datadog: dd-trace traces requests and @datadog/pprof profiles CPU continuously. #36747 OpenTelemetry: the @opentelemetry/instrumentation-http and @opentelemetry/instrumentation-fs packages from npm work with node:http and node:fs in Bun. The shimmer and require-in-the-middle packages they depend on can patch bundled code. Async stack traces: an error from fs.promises, fetch(), S3, DNS, or crypto points at the await in your code, not at native frames. Some of it is new in Bun. --cpu-prof-md --cpu-prof-md writes a CPU profile as Markdown, so you can find the hot function from a terminal: the top functions by self time, the call tree, and who calls whom. Read it over SSH, grep it, paste it into a bug report, or hand it to an LLM. bun --cpu-prof-md ./app.ts # CPU Profile | Duration | Samples | Interval | Functions | | -------- | ------- | -------- | --------- | | 304.9ms | 279 | 1.0ms | 6 | **Top 10:** `tokenize` 39.1%, `escapeHtml` 25.6%, `escapeHtml` 19.3%, `render` 15.8% ## Hot Functions (Self Time) | Self% | Self | Total% | Total | Function | Location | | ----: | ------: | -----: | ------: | ------------ | ----------- | | 39.1% | 119.4ms | 39.1% | 119.4ms | `tokenize` | `app.ts:14` | | 25.6% | 78.1ms | 25.6% | 78.1ms | `escapeHtml` | `app.ts:5` | | 19.3% | 58.9ms | 19.3% | 58.9ms | `escapeHtml` | `app.ts:4` | | 15.8% | 48.3ms | 60.8% | 185.3ms | `render` | `app.ts:21` | ## Call Tree (Total Time) | Total% | Total | Self% | Self | Function | Location | | -----: | ------: | ----: | ------: | ------------ | ----------- | | 60.8% | 185.3ms | 15.8% | 48.3ms | `render` | `app.ts:21` | | 60.8% | 185.3ms | 0.0% | 0us | `(module)` | `app.ts:30` | | 39.1% | 119.4ms | 39.1% | 119.4ms | `tokenize` | `app.ts:14` | | 39.1% | 119.4ms | 0.0% | 0us | `(module)` | `app.ts:28` | | 25.6% | 78.1ms | 25.6% | 78.1ms | `escapeHtml` | `app.ts:5` | | 19.3% | 58.9ms | 19.3% | 58.9ms | `escapeHtml` | `app.ts:4` | ## Function Details ### `tokenize` `app.ts:14` | Self: 39.1% (119.4ms) | Total: 39.1% (119.4ms) | Samples: 109 **Called by:** - `(module)` (109) ### `escapeHtml` `app.ts:5` | Self: 25.6% (78.1ms) | Total: 25.6% (78.1ms) | Samples: 72 **Called by:** - `render` (72) ### `escapeHtml` `app.ts:4` | Self: 19.3% (58.9ms) | Total: 19.3% (58.9ms) | Samples: 54 **Called by:** - `render` (54) ### `render` `app.ts:21` | Self: 15.8% (48.3ms) | Total: 60.8% (185.3ms) | Samples: 44 **Called by:** - `(module)` (170) **Calls:** - `escapeHtml` (72) - `escapeHtml` (54) ### `(module)` `app.ts:28` | Self: 0.0% (0us) | Total: 39.1% (119.4ms) | Samples: 0 BUN_CPU_PROFILE=1 turns on the CPU profiler for a process you cannot pass flags to, like a worker started by a framework. --heap-prof-md --heap-prof-md writes a heap profile as Markdown, so you can find what is holding memory from a terminal: total size, the types that retain the most, the largest objects, and the chains that keep them alive. bun --heap-prof-md ./app.ts # Bun Heap Profile Generated by `bun --heap-prof-md`. This profile contains complete heap data in markdown format. **Quick Search Commands:** ```bash grep '| `Function`' file.md # Find all Function objects grep 'gcroot=1' file.md # Find all GC roots grep '| 12345 |' file.md # Find object #12345 or edges involving it ``` --- ## Summary | Metric | Value | | --------------- | ---------------------: | | Total Heap Size | 4.2 MB (4507930 bytes) | | Total Objects | 121116 | | Total Edges | 244084 | | Unique Types | 67 | | GC Roots | 427 | ## Top 50 Types by Retained Size | Rank | Type | Count | Self Size | Retained Size | Largest Instance | | ---: | ---------------------------- | -----: | --------: | ------------: | ---------------: | | 1 | ` ` | 1 | 0 B | 4.2 MB | 4.2 MB | | 2 | `string` | 119883 | 4.1 MB | 4.1 MB | 67 B | | 3 | `GlobalObject` | 1 | 10.3 KB | 83.1 KB | 83.1 KB | | 4 | `Function` | 319 | 10.3 KB | 61.5 KB | 13.9 KB | | 5 | `Structure` | 216 | 23.6 KB | 35.7 KB | 944 B | | 6 | `FunctionExecutable` | 72 | 9.0 KB | 32.0 KB | 13.9 KB | | 7 | `ModuleLoader` | 1 | 32 B | 20.1 KB | 20.1 KB | | 8 | `ModuleRecord` | 2 | 3.0 KB | 19.4 KB | 15.3 KB | | 9 | `NativeExecutable` | 228 | 17.8 KB | 17.8 KB | 80 B | | 10 | `JSModuleEnvironment` | 2 | 128 B | 16.3 KB | 14.0 KB | | 11 | `FunctionCodeBlock` | 5 | 12.3 KB | 12.3 KB | 4.1 KB | | 12 | `ModuleProgramExecutable` | 2 | 224 B | 10.3 KB | 8.7 KB | | 13 | `ModuleProgramCodeBlock` | 2 | 2.5 KB | 10.1 KB | 8.6 KB | | 14 | `UnlinkedFunctionExecutable` | 69 | 6.4 KB | 6.4 KB | 96 B | | 15 | `Array` | 61 | 1.0 KB | 6.1 KB | 5.1 KB | | 16 | `console` | 1 | 48 B | 4.4 KB | 4.4 KB | | 17 | `String` | 1 | 74 B | 4.3 KB | 4.3 KB | | 18 | `Map` | 3 | 105 B | 4.2 KB | 2.6 KB | | 19 | `GetterSetter` | 29 | 928 B | 3.7 KB | 256 B | | 20 | `Iterator` | 2 | 54 B | 3.3 KB | 2.7 KB | bun build --metafile-md bun build --metafile-md writes the bundle analysis as Markdown, so you can see why a bundle is big: the largest modules, what each entry point loads, and the chain of imports that pulled each file in. bun build ./src/index.ts --outdir ./dist --metafile-md=./dist/meta.md # Bundle Analysis Report This report helps identify bundle size issues, dependency bloat, and optimization opportunities. ## Table of Contents - [Quick Summary](#quick-summary) - [Largest Modules by Output Contribution](#largest-modules-by-output-contribution) - [Entry Point Analysis](#entry-point-analysis) - [Dependency Chains](#dependency-chains) - [Full Module Graph](#full-module-graph) - [Raw Data for Searching](#raw-data-for-searching) --- ## Quick Summary | Metric | Value | | ------------------------- | ------------------ | | Total output size | 56.1 KB | | Input modules | 4 | | Entry points | 1 | | node_modules contribution | 1 files (55.74 KB) | | ESM modules | 4 | ## Largest Modules by Output Contribution Modules sorted by bytes contributed to the output bundle. Large modules may indicate bloat. | Output Bytes | % of Total | Module | Format | | ------------ | ---------- | --------------------------------------- | ------ | | 55.74 KB | 99.4% | `node_modules/marked/lib/marked.esm.js` | esm | | 113 bytes | 0.2% | `src/escape.ts` | esm | | 76 bytes | 0.1% | `src/render.ts` | esm | | 52 bytes | 0.1% | `src/index.ts` | esm | ## Entry Point Analysis Each entry point and the total code it loads (including shared chunks). ### Entry: `src/index.ts` **Output file**: `./index.js` **Bundle size**: 56.1 KB **Exports**: `main` **Bundled modules** (sorted by contribution): | Bytes | Module | | --------- | --------------------------------------- | | 55.74 KB | `node_modules/marked/lib/marked.esm.js` | | 113 bytes | `src/escape.ts` | | 76 bytes | `src/render.ts` | | 52 bytes | `src/index.ts` | ## Dependency Chains For each module, shows what files import it. Use this to understand why a module is included. ### Most Commonly Imported Modules Modules imported by many files. Extracting these to shared chunks may help. | Import Count | Module | Imported By | | ------------ | ------ | ----------- | ## Full Module Graph Complete dependency information for each module. ### `node_modules/marked/lib/marked.esm.js` - **Output contribution**: 55.74 KB - **Format**: esm - **Imported by** (1 files): `src/index.ts` 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: kqueue with EVFILT_MEMORYSTATUS, the same event libdispatch uses for DISPATCH_SOURCE_TYPE_MEMORYPRESSURE. level is "warning" or "critical". Linux: a PSI trigger written to /proc/pressure/memory (or the cgroup's memory.pressure), watched with epoll for EPOLLPRI. level is "critical". Windows: CreateMemoryResourceNotification(LowMemoryResourceNotification), waited on with RegisterWaitForSingleObject. level is "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 → cat stdin, then cat stdout → for await Throughput: Peak memory: 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. // 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: native-pipeline.mjs --scenario=prep (writes the 64 MiB fixture files once) // native-pipeline.mjs --scenario=download-gunzip-decode --server=http://127.0.0.1:39872 // native-pipeline.mjs --scenario=file-gzip-upload --server=http://127.0.0.1:39872 // native-pipeline.mjs --scenario=file-decode-encode-file // 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: Reading only the clone, and never the original: 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. // 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: Compression is bound by zlib itself, so the runtimes are close. Decompression is where the native stream path shows. // 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: Throughput, same run: // 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. 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 v1.3.14# Bun.Image is a built-in image library. await Bun.file("photo.jpg") .image() .resize(1024, 1024, { fit: "inside" }) .rotate(90) .webp({ quality: 85 }) .write("thumb.webp"); // Stream straight into a Response return new Response(new Bun.Image(upload).resize(200).jpeg()); Decode, resize, rotate, and encode JPEG, PNG, WebP, GIF, and BMP. HEIC, AVIF, and TIFF work on macOS and Windows. The API looks like sharp, and no native addon is needed. ICC color profiles like Display P3 survive transcoding. On a 1080p PNG resized to a 400×400 JPEG, it's 1.38× faster than sharp. On JPEG to WebP, 1.19×. #30032 Bun.WebView v1.3.12v1.4.0# Bun.WebView is headless browser automation built into Bun, without Puppeteer or Playwright. await using view = new Bun.WebView({ width: 800, height: 600 }); await view.navigate("https://bun.sh"); await view.click("a[href='/docs']"); const title = await view.evaluate("document.title"); await Bun.write("page.png", await view.screenshot()); Navigate, click, scroll, run JavaScript, and take screenshots. Clicks and scrolls are real user input. On macOS it uses the system WebKit, with nothing to install. On macOS, Linux, and Windows it can also drive an installed Chrome, Chromium, or Edge. #39423 Bun.WebView extends EventTarget, returns Blob screenshots, and exposes a .cdp(method, params?) escape hatch for raw Chrome DevTools Protocol commands. See the docs for advanced usage. Bun.markdown v1.3.8v1.4.0# Bun.markdown is a Markdown parser built into Bun. const html = Bun.markdown.html("# Hello **world**"); // "

Hello world

\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() v1.3.11v1.4.0# Bun.cron() registers a scheduled job with the operating system: crontab on Linux, launchd on macOS, Task Scheduler on Windows. Your script exports a scheduled(controller) handler, the same shape as Cloudflare Workers Cron Triggers. Standard 5-field cron syntax works, including named days and @daily. #26999 // Register an OS-level cron job await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report"); // Parse a cron expression → next matching UTC Date const next = Bun.cron.parse("*/15 * * * *"); // worker.ts export default { async scheduled(controller) { // controller.cron === "30 2 * * 1" // controller.scheduledTime === 1737340200000 await doWork(); }, }; You can also pass a function instead of a file. Bun runs it on the event loop, with no system cron involved. Jobs never overlap, and using stops the job when it goes out of scope. using job = Bun.cron("*/5 * * * *", async () => { await cleanupTempFiles(); }); job.cron; // "*/5 * * * *" job.unref(); // allow process exit job.stop(); // cancel (or let `using` dispose) Bun.cron schedules run in local time by default, with a new { tz } option for explicit timezones; parse() rejects from timestamps outside the ECMAScript Date range. #35122 #29282 Bun.Terminal v1.3.5v1.4.0# Bun.Terminal is a built-in pseudo-terminal, so you can drive bash, vim, or htop from JavaScript without node-pty. Pass terminal to Bun.spawn, write input, resize, and read the colored output. It works on Linux, macOS, and Windows. #25415 #29522 const proc = Bun.spawn(["bash"], { terminal: { cols: 80, rows: 24, data(term, data) { process.stdout.write(data); }, }, }); proc.terminal.write("echo Hello from PTY!\n"); bun run --parallel v1.3.9v1.4.0# bun run --parallel runs multiple package.json scripts concurrently with name-prefixed output. Glob-match script names, fan out across every workspace with --filter, and keep going past failures with --no-exit-on-error. This replaces tools like npm-run-all and concurrently. #26551 # Run "build" and "test" 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 '*' test Each 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. 3x faster bun:ffi v1.4.0# bun:ffi now runs on FFI built into JavaScriptCore, replacing TinyCC. We added native support for FFI to JavaScriptCore. The new buffer_length argument type passes a TypedArray's length alongside its pointer, so the two can't disagree. import { dlopen } from "bun:ffi"; const { symbols } = dlopen("libhash.so", { hash: { args: ["buffer", "buffer_length"], returns: "cstring" }, }); const digest = symbols.hash(data, data); typeof digest; // "string" returns: "cstring" now gives you a plain string. NULL gives you null. When a call site gets hot, the JIT compiles it into a direct call to the C function. It already knows the argument types from the signature, so it passes unboxed values in registers and skips the type checks and boxing a normal call would do. Dev tooling v1.3.2v1.4.0# --cpu-prof, --cpu-prof-md: A .cpuprofile for Chrome DevTools, or the same profile as a Markdown report for pasting into a bug or an LLM; BUN_CPU_PROFILE=1 for processes you can't pass flags to. #24112 #26327 --heap-prof, --heap-prof-md: A V8-compatible .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 await in your code. #28652 --no-orphans: Bun exits when its parent dies and SIGKILLs every descendant on exit, on Linux, macOS, and Windows. #29930 --no-env-file: Skip automatic .env loading in production and CI (env = false in bunfig.toml). #24767 HTTP/3 in Bun.serve() (experimental) v1.3.14v1.4.0# Bun.serve() supports HTTP/3. Set http3: true next to tls, and Bun listens on UDP on the same port. HTTP/1.1 keeps working over TCP, and responses advertise HTTP/3 with an Alt-Svc header so browsers upgrade on their own. On a static-route benchmark, HTTP/3 is 2.7× faster than HTTPS/1.1 on the same server. Bun.serve({ port: 443, tls: { ... }, http3: true, // also listen on UDP/443 for HTTP/3 // h1: false, // optional: serve HTTP/3 only fetch(req) { return new Response("hi"); }, }); Experimental: zero-round-trip connection resumption is disabled, server.upgrade() returns false over H3, and unix: sockets skip the H3 listener. Don't ship http3: true to production yet. #29768 HTTP/2 & HTTP/3 in fetch() (experimental) v1.3.14v1.4.0# fetch() now supports HTTP/2 and HTTP/3. Pass protocol: "http2" or protocol: "http3". const [a, b, c] = await Promise.all([ fetch("https://api.example.com/a", { protocol: "http2" }), fetch("https://api.example.com/b", { protocol: "http2" }), fetch("https://api.example.com/c", { protocol: "http2" }), ]); const res = await fetch("https://example.com", { protocol: "http3" }); Over HTTP/2, concurrent requests to the same origin share one connection. Redirects, decompression, and streaming work the same as they do over HTTP/1.1. To turn them on everywhere, set BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1 or pass --experimental-http3-fetch. With the HTTP/3 flag, Bun remembers which origins support it and uses it for later requests on its own. Serve files & folders v1.4.0# Bun.serve() routes can now serve a directory. Files stream with sendfile. Content-Type, ETag, Last-Modified, 304, and Range are handled for you, and index.html is served for directories. This replaces express.static, serve-static, and sirv. #36156 Bun.serve({ routes: { "/static/*": { dir: "./public" }, }, }); When serving files from disk, paths are normalized before lookup and on Linux files are opened with openat2 with O_RESOLVE_BENEATH, so a symlink inside the directory can't reach above it. Range and conditional requests v1.3.13v1.4.0# Bun.serve honors Range headers for file responses, so video seeking and resumable downloads work. Both static routes and Bun.file() bodies return 206 Partial Content. Static routes and Bun.file() responses also handle conditional requests. If-None-Match and If-Modified-Since get a 304, and If-Match and If-Unmodified-Since get a 412 when the precondition fails. Bun.serve({ routes: { "/video.mp4": new Response(Bun.file("./video.mp4")), "/logo.png": new Response(Bun.file("./logo.png")), }, }); curl -H 'Range: bytes=0-1023' localhost:3000/video.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 Modified HTML routes sourcemaps disabled in production v1.4.0# In production, Bun.serve no longer serves sourcemaps for HTML routes, so your original source stays on your server. Development mode still serves them. Set sourcemap under [serve.static] in bunfig.toml to pick explicitly. #36982 [serve.static] sourcemap = "linked" fetch() request compression v1.4.0# fetch() gains a compress option. It compresses the request body before sending and sets the Content-Encoding header automatically. It supports gzip, deflate, br, and zstd, with an optional compression level. Buffered bodies (string, ArrayBuffer, TypedArray, Blob) are compressed, and Content-Length reflects the compressed size. Streaming bodies pass through unchanged. #32416 await fetch(url, { method: "POST", body: largeJsonString, compress: "gzip", // or true, "deflate", "br", "zstd", { encoding, level } }); fetch() proxy headers v1.3.4# fetch()'s proxy option now also accepts an object with url and headers, letting you send custom headers (like Proxy-Authorization) directly to the proxy server, whether the destination is HTTPS or plain HTTP. #25090 await fetch(url, { proxy: { url: "http://proxy.example.com:8080", headers: { "Proxy-Authorization": "Bearer token" }, }, }); TLS session resumption v1.4.0# A second cold connection to an origin resumes at 1 RTT. A 32-entry LRU caches BoringSSL client sessions per origin, so reconnecting after the keep-alive pool evicts skips the full handshake and certificate-chain walk. Connection reuse v1.3.10v1.4.0# fetch() reuses connections through an HTTPS proxy, and reuses them for requests with custom TLS options like a client certificate or a custom CA. #28611 #37715 #27385 Also built in v1.3.3v1.4.0# Bun.JSON5: Bun.JSON5.parse()/stringify(); import .json5 files directly. Replaces json5. Bun.JSONL: parse() and streaming parseChunk() for newline-delimited JSON. Replaces ndjson. Bun.JSONC.parse(): JSON with comments and trailing commas, the parser behind tsconfig.json. Replaces jsonc-parser. Bun.XML: SIMD XML parser and serializer; import .xml files directly. Replaces fast-xml-parser and xml2js. Bun.TOML: TOML v1.1.0, 708/708 of toml-test; new stringify(). Replaces @iarna/toml. Bun.Archive: Create and extract tarballs off the main thread. Replaces tar. Bun.sliceAnsi(), Bun.wrapAnsi(), Bun.stringWidth(): Terminal-column-aware slicing, wrapping, and measurement, ANSI and grapheme aware. Replaces slice-ansi, cli-truncate, wrap-ansi, and string-width. URLPattern: The Web API, 408 WPT passing. Replaces path-to-regexp. CompressionStream / DecompressionStream: Web-standard streams for gzip, deflate, deflate-raw, plus brotli and zstd. Response.textStream(): A ReadableStream of the body decoded as UTF-8. process.on("memoryPressure"): The OS's low-memory notification on macOS, Linux, and Windows. ML-DSA and ML-KEM: NIST post-quantum signatures and key encapsulation in crypto.subtle and node:crypto. Bun.spawn({ cgroup }): Place a child in a cgroup before it starts, on Linux. bun repl: Native REPL: highlighting, history, tab completion, -e/-p. bun ./README.md: Renders Markdown to the terminal, no VM started. Replaces glow. 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: Linux x64, EPYC 9R14 · bench/install in oven-sh/bun · each package manager with its own lockfile and node_modules, state prepared per scenario before every run, every package manager at defaults · medians of 3, peak memory is the largest of the 3 runs Global virtual store: up to 7x faster installs v1.3.14v1.4.0# bun install --linker=isolated now uses a shared global virtual store. Packages are extracted once into Bun's cache and symlinked into each project's node_modules/.bun/ store, instead of being copied into node_modules on every install. #29489 On a warm isolated install, copying packages into node_modules (clonefileat() on macOS) was 95% of main-thread time, and macOS runs only one of those calls at a time. Once a package exists anywhere on the machine, later installs do one symlink() per package instead of one clonefileat(). On the common CI path (lockfile present, cache warm, node_modules wiped), a 1,400-package install is 7x faster. The global store is opt-in: it applies when you select the isolated linker, which is not the default for existing projects. # bunfig.toml [install] linker = "isolated" bun pm diff v1.4.0# bun pm diff shows you what changed between two versions of a package. It starts with a summary: which files changed, any new install scripts, and any new imports of child_process, fs, net, or vm. Then it shows the diff. Minified files are un-minified before diffing, and formatting-only changes are skipped, so you see the lines that actually changed. #39229 bun pm diff react # the version in bun.lock → 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 v1.4.0# bun audit fix upgrades vulnerable packages to a safe version and installs. If a fix needs a new major version, it tells you, and --latest lets it do that. --dry-run shows what it would change. #38333 bun audit 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 dedupe v1.4.0# bun dedupe removes duplicate versions of packages from bun.lock. If you have esbuild@0.15.10 and esbuild@0.15.11 and one version satisfies both, you end up with one. It never changes package.json, and --check fails CI if there are duplicates. #38333 bun 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 prune v1.4.0# bun prune deletes packages from node_modules that aren't in bun.lock anymore. bun prune --production also deletes devDependencies, so you can build with them and ship without them. #38333 bun prune --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 v1.4.0# bun pm licenses lists your dependencies by license. --json gives you machine-readable output, and --prod skips devDependencies. #38333 bun pm licenses --prod --json > licenses.json bun update updates transitive dependencies v1.4.0# bun update now updates the dependencies of your dependencies too, not just the ones in your package.json. bun update bun update zod bun update '@types/*' --latest bun update updates that package everywhere it appears, and bun update '@types/*' takes a pattern. #38333 bun add --filter v1.4.0# bun add, bun remove, and bun update accept --filter, so you can add a package to one workspace from the root of your monorepo. bun add zod --filter 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 --catalog v1.4.0# bun add --catalog adds the package to your root catalog and writes "catalog:" in the workspace's package.json. bun add react --catalog If the package is already in your default catalog, plain bun add uses it. Nested overrides v1.4.0# You can now override a dependency's dependency without overriding it everywhere. npm's nested form, yarn's a/b, and pnpm's a>b all work, and an override can be scoped to a version range. { "overrides": { "express": { "qs": "6.13.0" }, "lodash@<4.17.21": "4.17.21" } } Lockfile integrity for GitHub and tarball dependencies v1.3.10# bun.lock now records a SHA-512 hash for GitHub and tarball dependencies, the same way it always has for npm packages. Existing lockfiles pick up the hashes on the next install. ["pkg@github:user/repo#ref", {}, "resolved-commit"] ["pkg@github:user/repo#ref", {}, "resolved-commit", "sha512-..."] trustedDependencies only auto-trusts the npm registry v1.3.5# Bun's default trusted-dependencies list applies only to packages from the npm registry. A file:, link:, git:, or github: dependency named esbuild gets no trust from the real esbuild's entry. To run its lifecycle scripts, list it in trustedDependencies yourself. { "dependencies": { "esbuild": "github:some-fork/esbuild#main" }, "trustedDependencies": ["esbuild"] } Trusted-dependency names, .npmrc scope names, and local file: paths are compared by their full bytes rather than a hash, and registry credentials stay scoped to their configured host — never sent cross-origin, downgraded to http://, or printed in error or verbose output. nativeDependencies and ignoreScripts v1.3.2# For packages that ship prebuilt binaries as per-platform optionalDependencies (esbuild and @esbuild/darwin-arm64), Bun links the right binary directly instead of running postinstall. List them in nativeDependencies. ignoreScripts skips a package's lifecycle scripts entirely, even if it is also in trustedDependencies. #24283 Configure both in package.json, or disable native binary linking with BUN_FEATURE_FLAG_DISABLE_NATIVE_DEPENDENCY_LINKER=1 and script skipping with BUN_FEATURE_FLAG_DISABLE_IGNORE_SCRIPTS=1. { "nativeDependencies": ["esbuild", "my-custom-package"], "ignoreScripts": ["sharp", "another-package"] } bun test# bun test --parallel runs test files across worker processes. --shard splits them across CI machines. --timings balances both by how long each file takes. --changed runs only the tests your diff touches. bun test --changed=main # only what your branch 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 v1.3.13v1.4.0# bun test --parallel[=N] runs test files across N worker processes (defaulting to your CPU count). Files go to whichever worker frees up next. #29354 bun test --parallel bun test --parallel=4 --isolate Coverage and JUnit output are merged across workers. --bail stops every worker on the first failure. --parallel implies --isolate (below). --no-isolate turns that off, so each worker keeps one global and one module registry for every file it runs. Each worker exposes its 1-indexed slot as JEST_WORKER_ID / BUN_TEST_WORKER_ID, so Jest setups that key databases or ports off JEST_WORKER_ID work unchanged. Preload scripts with top-level await complete before any worker starts running tests. bun test --isolate v1.3.13v1.4.0# bun test --isolate runs each test file in a fresh JavaScript global object, in the same process. This is how Jest and Vitest behave by default. It makes "passes alone, fails in the full suite" bugs go away. #29354 bun test --isolate Between files, Bun: creates a new globalThis, so properties a file put on globalThis, 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 --preload scripts 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 --preload script with top-level await finishes 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 v1.3.13v1.4.0# bun test --shard=M/N splits your test files across multiple CI runners. Files are sorted deterministically and distributed round-robin so every machine sees the same partition, with 1-based indexing matching Jest, Vitest, and Playwright. Works alongside --changed and --randomize. An empty shard exits 0 instead of failing. #29366 # In a matrix of 3 jobs: bun test --shard=1/3 bun test --shard=2/3 bun test --shard=3/3 bun test --timings v1.4.0# --timings= 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 first With 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 v1.3.13v1.4.0# bun test --changed runs only the test files affected by your uncommitted changes, or by the diff against a branch or commit with --changed=main. The flag is vitest-compatible. #29262 bun test --changed # uncommitted (unstaged + staged + untracked) bun test --changed=HEAD~1 # diff against a commit / branch / tag bun test --changed=main bun test --changed --watch # re-filters on every restart Bun scans every test file's imports, asks git which files changed, and walks the import graph backwards to find the tests that reach them. tsconfig paths aliases like @/* work. With --watch, editing any source file re-filters on restart. bun test --retry v1.3.3v1.4.0# test() accepts { retry: n } to re-run a flaky test up to n times, and { repeats: n } to run it n times and fail if any run fails. bun test --retry sets a default for the whole suite. #23713 #26866 test( "flaky network call", async () => { await fetch("https://example.com"); }, { retry: 5 }, ); test( "stress", () => { if (Math.random() < 0.1) throw new Error("uh oh!"); }, { repeats: 20 }, ); jest.useFakeTimers() v1.3.4v1.4.0# jest.useFakeTimers() lets you control setTimeout, setInterval, and Date from your tests. @testing-library/react's waitFor detects the fake timers and advances them instead of waiting in real time. #23764 #25915 import { jest, test, expect } from "bun:test"; test("debounce", () => { jest.useFakeTimers(); let called = 0; setTimeout(() => called++, 1000); jest.advanceTimersByTime(1000); expect(called).toBe(1); jest.useRealTimers(); }); jest.setSystemTime() works with advanceTimersByTime(), and Bun.cron schedules can be driven by the fake clock. #33623 bun build# Built-in React Compiler v1.4.0# bun build --react-compiler (or reactCompiler: true in Bun.build()) runs React's auto-memoization compiler on your components and hooks with no Babel or SWC in the loop. The compiler runs inside Bun's own parser, so there is no separate parse/print round-trip. On a large React codebase (~860 components), enabling it adds 71 ms to the build (394 ms → 465 ms), about 20× faster than the Babel plugin's 9.15 s on the same input. A full --compile build finishes in 3.62 s vs 13.04 s (3.6×). #32504 await Bun.build({ entrypoints: ["./src/index.tsx"], outdir: "./dist", reactCompiler: true, }); Barrel import optimization v1.3.10v1.4.0# When you write import { Button } from "antd", Bun skips the hundreds of files behind the names you didn't import. Packages that declare "sideEffects": false get this automatically. For everything else, opt in with optimizeImports. #26892 await Bun.build({ entrypoints: ["./src/index.tsx"], optimizeImports: ["antd", "@mui/material"], }); Compile-time feature flags with bun:bundle v1.3.5# feature("FLAG") from bun:bundle becomes true or false at build time, and the dead branch is removed. Set flags with --feature=FLAG or features: [...] in Bun.build(). They work in bun build, bun run, and bun test. #25462 import { feature } from "bun:bundle"; if (feature("SUPER_SECRET")) { console.log("Secret feature enabled!"); } // bun build --feature=SUPER_SECRET index.ts In-memory files in Bun.build() v1.3.6# Bun.build() accepts a files option: a map of paths to strings, 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 + "!"; }`, }, }); Single-file HTML with --compile --target=browser v1.3.10# bun build --compile --target=browser produces one HTML file with every script, stylesheet, and asset inlined. You can double-click it and open it from file://, with no web server. #27056 bun build ./index.html --compile --target=browser --outdir=dist # → dist/index.html (everything inlined, zero external requests) metafile: true v1.3.6v1.4.0# Bun.build() supports metafile: true, returning build metadata in esbuild's metafile format: a full map of inputs, outputs, imports, exports, and byte sizes. result.metafile works as-is with https://esbuild.github.io/analyze/ and anything else that reads esbuild's format. #25842 const result = await Bun.build({ entrypoints: ["./index.js"], metafile: true, }); console.log(result.metafile.inputs); console.log(result.metafile.outputs); --metafile-md v1.3.8v1.4.0# bun build --metafile-md writes the module graph as a Markdown report: a quick summary, the largest input files, per-entry-point breakdowns, dependency chains, and a grep-friendly raw section. The report is plain Markdown, so you can paste it into an LLM to ask why a bundle is large. #26441 bun build entry.js --metafile-md --outdir=dist bun build entry.js --metafile-md=analysis.md --outdir=dist bun build entry.js --metafile=meta.json --metafile-md=meta.md --outdir=dist Standard TC39 decorators v1.3.10v1.4.0# You can now use standard TC39 decorators in Bun. function logged(value, { kind, name }) { if (kind === "method") { return function (...args) { console.log(`calling ${name}`); return value.call(this, ...args); }; } } class C { @logged greet() {} } These are the decorators you get when experimentalDecorators is off in tsconfig.json. They work on classes, methods, fields, accessors, and private members. Bun passes the esbuild decorator test suite. --asset v1.4.0# bun build --compile --asset 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 compilation for ES modules v1.3.9v1.4.0# --bytecode now supports ES modules. --bytecode --format=esm requires --compile, and enables top-level await, import.meta, dynamic imports, and code splitting in bytecode-compiled binaries; previously --bytecode forced CommonJS output. #26402 Code splitting on 20,000-module graphs is 14× faster v1.4.0# The code-splitting reachability walk is now BFS and O(V+E). A 20,000-module diamond-shaped DAG links in 320 ms, from 4.65 s. The tree-shaking liveness, TLA validation, CSS-order, and part-visitor passes run on explicit stacks. So linear import chains of thousands of modules link without stack growth. #35310 #34554 Faster# Between Bun 1.3 and 1.4 we bumped our WebKit pin 39 times, pulling in roughly eight months of upstream JavaScriptCore work; the regex engine, Promises, and most String/Array builtins moved from self-hosted JavaScript to C++, and Bun swapped in zlib-ng and SIMD kernels for its own hot paths. new URL() is up to 4.6× faster v1.4.0# Bun's URL parser was rewritten. WebKit's new parser does the parsing. On Bun's side, href reuses the input string, the last base URL is cached, and hosts that are already ASCII punycode skip ICU. #39273 #39368 #39468 Faster RegExp v1.4.0# The RegExp performance gap between JavaScriptCore and V8 has been fixed. marked.parse() gets 138× faster. On an 80 KB Markdown fixture, it runs in ~6 ms, from 912 ms. isbot gets 200× faster. One call on a typical user agent takes 1.07 µs, from 218 µs in Bun 1.3. Node.js 26 takes 1.47 µs. 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); node:zlib uses zlib-ng v1.3.13v1.4.0# Bun now uses zlib-ng, the same library Node.js 24 and Chromium use, for node:zlib, gzipped fetch() responses, and everything else that compresses. It picks the fastest code path for your CPU at runtime. #29433 Time per call on 1 MB of JSON, default level: Peak memory, same runs: 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. // node:zlib benchmark: compress/decompress a JSON-like text buffer, one scenario per process. // Usage: zlib-bench.mjs [level] [--bytes=N] [--iters=N] // e.g. bun zlib-bench.mjs gzip sync compress --bytes=1048576 --iters=50 // node zlib-bench.mjs brotli async compress // deno run -A zlib-bench.mjs zstd sync decompress 3 // --bytes: input size (default 64 MiB). --iters: timed iterations (default 1); with >1, warms up // 5 iterations and reports the median. Prints one JSON line: {encoding, api, op, level, ms, iters, // inputBytes, compressedBytes}. Wrap with `/usr/bin/time -v` to get peak RSS. import * as zlib from "node:zlib"; const flags = Object.fromEntries( process.argv .slice(2) .filter((a) => a.startsWith("--")) .map((a) => a.slice(2).split("=")), ); const [encoding, api, op, levelArg] = process.argv .slice(2) .filter((a) => !a.startsWith("--")); const level = levelArg === undefined ? undefined : Number(levelArg); const TARGET = Number(flags.bytes ?? 64 * 1024 * 1024); const ITERS = Number(flags.iters ?? 1); // Deterministic JSON-lines text; a few fields vary per record so it is not trivially repetitive. function makeInput() { let seed = 0x9e3779b9; const rnd = () => (seed = (seed * 1103515245 + 12345) >>> 0) / 2 ** 32; const cities = [ "Berlin", "Tokyo", "Austin", "Lagos", "Lima", "Oslo", "Pune", "Quito", ]; const words = [ "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", ]; const parts = []; let size = 0; for (let i = 0; size < TARGET; i++) { const tags = Array.from( { length: 3 }, () => words[(rnd() * words.length) | 0], ); const rec = { id: i, uuid: `${((rnd() * 2 ** 32) >>> 0) .toString(16) .padStart(8, "0")}-4c1e-8a2b-${i.toString(16).padStart(12, "0")}`, user: `user_${(rnd() * 50000) | 0}`, email: `person${(rnd() * 1e6) | 0}@example.com`, city: cities[(rnd() * cities.length) | 0], score: Math.round(rnd() * 10000) / 100, active: rnd() > 0.5, tags, ts: 1700000000000 + ((rnd() * 1e9) | 0), note: "lorem ipsum dolor sit amet, consectetur adipiscing elit " + words[i % words.length], }; const line = JSON.stringify(rec) + "\n"; parts.push(line); size += line.length; } return Buffer.from(parts.join(""), "latin1"); } const fns = { gzip: [zlib.gzipSync, zlib.gzip, zlib.gunzipSync, zlib.gunzip], deflate: [zlib.deflateSync, zlib.deflate, zlib.inflateSync, zlib.inflate], brotli: [ zlib.brotliCompressSync, zlib.brotliCompress, zlib.brotliDecompressSync, zlib.brotliDecompress, ], zstd: [ zlib.zstdCompressSync, zlib.zstdCompress, zlib.zstdDecompressSync, zlib.zstdDecompress, ], }; const [cSync, cAsync, dSync, dAsync] = fns[encoding]; if (!cSync) { console.log(JSON.stringify({ encoding, api, op, error: "unsupported" })); process.exit(0); } const opts = level === undefined ? {} : encoding === "brotli" ? { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } } : { level }; const input = makeInput(); const compressed = op === "decompress" ? cSync(input, opts) : null; const [syncFn, asyncFn, data] = op === "decompress" ? [dSync, dAsync, compressed] : [cSync, cAsync, input]; const once = () => new Promise((resolve, reject) => { const t0 = performance.now(); if (api === "sync") return resolve([syncFn(data, opts), performance.now() - t0]); asyncFn(data, opts, (err, out) => err ? reject(err) : resolve([out, performance.now() - t0]), ); }); let out, times = []; if (ITERS > 1) for (let i = 0; i < 5; i++) await once(); for (let i = 0; i < ITERS; i++) { const [o, ms] = await once(); out = o; times.push(ms); } times.sort((a, b) => a - b); const ms = times[times.length >> 1]; console.log( JSON.stringify({ encoding, api, op, level: level ?? "default", ms: Math.round(ms * 1000) / 1000, iters: ITERS, inputBytes: input.length, compressedBytes: op === "decompress" ? compressed.length : out.length, }), ); Buffer.from(str, "hex") is 8× faster and "base64url" 46× faster v1.4.0# Buffer.from(str, "hex") and Buffer.from(str, "base64url") decode with SIMD. Decoding 1 MiB: Decoding 128 KiB: import { Buffer } from "node:buffer"; const sizes = [1024, 128 * 1024, 1024 * 1024]; const raw = (n) => { const b = Buffer.alloc(n); for (let i = 0; i < n; i++) b[i] = (i * 2654435761) >>> 24; return b; }; for (const enc of ["hex", "base64", "base64url"]) { for (const n of sizes) { const str = raw(n).toString(enc); let sink = 0; for (let i = 0; i < 200; i++) sink += Buffer.from(str, enc).length; // warm up const iters = n >= 1024 * 1024 ? 300 : n >= 128 * 1024 ? 2000 : 100000; const times = []; for (let rep = 0; rep < 5; rep++) { const t0 = performance.now(); for (let i = 0; i < iters; i++) sink += Buffer.from(str, enc).length; times.push(((performance.now() - t0) * 1000) / iters); } times.sort((a, b) => a - b); console.log(enc, n / 1024, "KiB", times[2].toFixed(2), "µs/op"); } } Source map decoding is 3.1× faster v1.4.0# Source map decoding uses SIMD. new SourceMap(json) on a 9.5 MB map takes 12 ms, 3.1× faster than before and 24× faster than Node.js. #32556 Promises are 1.5–2.4× faster v1.4.0# JavaScriptCore rewrote their Promise implementation to reduce overhead. Time per operation, 2 million iterations: Memory: 1,000,000 pending promises resolved at once. // Promise microbenchmarks. Prints ns/op per operation as JSON. // Usage: bun promise-bench.mjs | node promise-bench.mjs | deno run -A promise-bench.mjs const WARMUP = 1e5; const ITERS = 2e6; const p1 = Promise.resolve(1), p2 = Promise.resolve(2), p3 = Promise.resolve(3), p4 = Promise.resolve(4); const arr = [p1, p2, p3, p4]; async function noAwait(x) { return x; } const benches = { "Promise.race (4 resolved)": () => Promise.race(arr), "Promise.all (4 resolved)": () => Promise.all(arr), "Promise.allSettled (4 resolved)": () => Promise.allSettled(arr), "await resolved promise": async () => { await p1; }, ".then() chain of 4": () => p1 .then((x) => x) .then((x) => x) .then((x) => x) .then((x) => x), "async fn, no await": () => noAwait(1), }; async function time(fn, n) { const t0 = performance.now(); for (let i = 0; i < n; i++) await fn(); return ((performance.now() - t0) * 1e6) / n; } const results = {}; for (const [name, fn] of Object.entries(benches)) { await time(fn, WARMUP); results[name] = Math.round((await time(fn, ITERS)) * 10) / 10; } console.log(JSON.stringify(results)); // promise-rss.mjs: peak memory of 1,000,000 pending promises resolved at once const N = 1_000_000; const resolvers = []; const promises = Array.from( { length: N }, () => new Promise((r) => resolvers.push(r)), ); const all = Promise.all(promises); for (const r of resolvers) r(1); const t0 = performance.now(); await all; console.log( `${N} promises settled in ${(performance.now() - t0).toFixed(1)} ms`, ); Security# Bun 1.4 includes a lot of security fixes. We recommend everyone update. Most of them change nothing you'd notice. They are listed under Security hardening in the changelog. The handful below tighten a default, in most cases a TLS certificate check. That can turn a connection that worked on 1.3 into a verification error. Advisories will go up on GitHub once people have had time to upgrade. checkServerIdentity runs before fetch() sends the request v1.4.0# When you pass tls: { checkServerIdentity } to fetch(), the callback runs after the TLS handshake and before any of the request is written, and again on each redirect hop. If it returns an Error, fetch() rejects with that error and nothing is sent. await fetch("https://api.example.com/upload", { method: "POST", body: secretPayload, tls: { checkServerIdentity(hostname, cert) { if (cert.fingerprint256 !== PINNED) return new Error("pin mismatch"); }, }, }); // nothing is sent until checkServerIdentity returns 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 now uses host as the default servername v1.3.13# tls.connect({ host, port }) without a servername now uses host for both SNI and the certificate identity check. This matches Node.js. Connecting by IP address or to localhost now fails with ERR_TLS_CERT_ALTNAME_INVALID when the certificate was issued for another name. This applies whether you call tls.connect() yourself or a driver like pg or ioredis does. Pass the certificate's name as servername. Or pass checkServerIdentity: () => undefined if you deliberately trust the server by its CA alone. tls.connect({ host: "10.0.0.12", port: 5432, ca, servername: "db.internal", }); Bun.connect and Bun.listen enforce rejectUnauthorized v1.4.0# Bun.connect({ tls }), socket.upgradeTLS(), and Bun.listen() with requestCert: true now default to rejectUnauthorized: true, as node:tls and fetch() do. The usual case is a Bun.connect() to a dev or staging server with a self-signed or private-CA certificate and no ca. It does not throw. The handshake handler runs with socket.authorized set to false. Writes return -1. The socket closes without delivering data. Pass the CA in tls, or pass rejectUnauthorized: false (NODE_TLS_REJECT_UNAUTHORIZED=0 is honored here too). RedisClient enforces TLS hostname verification v1.3.14# A rediss:// RedisClient checks the server certificate against the host in the URL, as the Postgres and MySQL clients do, and rejects the first command with ERR_TLS_CERT_ALTNAME_INVALID on a mismatch. If you reach Redis by IP or through a port-forward to localhost, connect by the name on the certificate instead, or pass tls: { rejectUnauthorized: false }. HTTP request parsing hardening in Bun.serve v1.3.4# Bun.serve() answers 400 and closes the connection for more kinds of malformed Content-Length and Transfer-Encoding headers and chunked bodies. Browsers, curl, fetch() and reverse proxies send none of them. If a hand-written client starts getting 400 responses, look at its framing headers first; Bun does not call your fetch handler or log anything for most of these. Tarball extraction hardening v1.3.6# Tarball extraction for github: and URL dependencies and bun create templates skips entries that would land outside the package directory. If one of those is missing a file after the upgrade and you get Cannot find module for it, look in its repo for a symlink that points outside the package, and replace it with the real file or a relative link. Platforms# Native FreeBSD builds v1.3.14v1.4.0# Bun now ships official FreeBSD binaries for x86_64 and aarch64. On FreeBSD 14.3+ the full runtime (Bun.serve(), fetch(), node:fs, node:os, Bun.spawn) works on a stock install with no extra system packages. This is a native port built against FreeBSD's own kernel APIs, not a Linux compatibility layer. #29676 curl -fsSL https://bun.sh/install | bash uname -sm FreeBSD amd64 bun --version 1.4.0 Windows on ARM64 v1.3.7v1.4.0# Bun now builds natively for Windows on ARM64. Surface, Snapdragon X, and Ampere-based Windows machines run Bun natively. #26215 PS> powershell -c "irm bun.sh/install.ps1|iex" PS> $env:PROCESSOR_ARCHITECTURE ARM64 PS> bun --version 1.4.0 Experimental Android support v1.4.0# Bun ships experimental Android builds for aarch64 and x64 with every release. Linux glibc minimum drops to 2.17 v1.3.13# Bun's minimum glibc requirement on Linux drops from 2.26 to 2.17. Bun now runs on RHEL/CentOS 7, Amazon Linux 1, and ARM64 Linux distributions without needing a separate compatibility build. #29461 ldd --version ldd (GNU libc) 2.17 bun --version 1.4.0 Fallback for in-memory files on older Linux kernels v1.3.13# On Linux kernels older than 3.17, like RHEL 7, Bun detects that memfd_create is missing once and falls back. The documented minimum kernel is now 3.10. #29465 BUN_FEATURE_FLAG_DISABLE_MEMFD=1 bun run server.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"] } } Sub-15 ms timers on Windows v1.4.0# On Windows, setTimeout(fn, 1) fires in about 1.4 ms instead of 15.5 ms. Timers no longer round to the 15.6 ms system tick. #34834 Bun runs inside an AppContainer v1.4.0# Bun runs inside a Windows AppContainer, so embedders can sandbox it with a lowbox token. bun install, bun run, Bun.spawn, child_process.fork, and Bun.Terminal all work inside the container. Bun also now works on read-only directories like Program Files and read-only network shares, and no longer fails when an ancestor directory isn't readable. Upgrading to 1.4# Most code is unaffected. Five changes are the most likely to need a line in your project: Node.js 26: process.versions.modules is now 147. Packages that pick a prebuilt native addon by NODE_MODULE_VERSION need 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.lock records 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 node symlink) does not load .env files. This matches Node. Pass --env-file to keep them. #36610 Bun.YAML follows YAML 1.2: yes/no/on/off are strings. on: in a GitHub Actions workflow parses as "on". #25537 Bun.TOML and bunfig.toml are strict: unquoted strings, missing newlines between pairs, and integers past Number.MAX_SAFE_INTEGER are SyntaxErrors. #32953 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.modules is 147. Packages that pick a prebuilt native addon by NODE_MODULE_VERSION need a build for 147. res.writeHeader() in node:http is 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 returns null. #31991 res.writeHeader(200, { "Content-Type": "text/plain" }); res.writeHead(200, { "Content-Type": "text/plain" }); x64 builds are now baseline-only v1.4.0# x64 releases now ship only the baseline build. The separate build compiled with -march=haswell is gone. The -baseline download URLs and npm packages still exist and contain the same binary. Existing install scripts and bun upgrade keep working. The CPU lacks AVX support startup warning is removed. #34782 Temporal is now defined by default, and toEqual() compares Temporal objects by value v1.4.0# Temporal and Date.prototype.toTemporalInstant are now defined. Set BUN_JSC_useTemporal=0 to turn them off. Bun.deepEquals(), toEqual(), toStrictEqual(), and util.isDeepStrictEqual() now compare Temporal objects by value. Before, any two instances of the same class were equal. #32978 #37024 Bun.deepEquals( Temporal.PlainDate.from("2020-01-01"), Temporal.PlainDate.from("1999-12-31"), ); // false bun:ffi: cstring values are plain strings and CString no longer has .ptr v1.4.0# bun:ffi is now engine-native. This changes four things: A returns: "cstring" value or a cstring callback argument is a plain string. A NULL pointer is null. new CString(ptr) returns a string with no .ptr, .byteLength, or .arrayBuffer. Keep the original pointer if you need to free it. napi_env and napi_value argument types throw TypeError outside cc(). dlopen() and the other entry points throw TypeError when the JIT is disabled. #35246 const str = new CString(ptr); my_library_free(str.ptr); my_library_free(ptr); bun build --compile no longer auto-loads tsconfig.json or package.json at runtime v1.3.4# Standalone executables built with bun build --compile no longer auto-load tsconfig.json or package.json from the runtime working directory. Before, a compiled binary could pick up unrelated config files from the directory it ran in. To opt back in, pass --compile-autoload-tsconfig / --compile-autoload-package-json (or compile.autoloadTsconfig / compile.autoloadPackageJson in Bun.build()). .env and bunfig.toml still auto-load by default. They keep their existing --compile-autoload-dotenv / --compile-autoload-bunfig flags. #25340 bun install defaults to the isolated linker for new monorepos v1.3.2# New monorepos (projects with workspaces) now use linker: "isolated". This is a symlinked node_modules layout that prevents phantom dependencies. bun.lock records a configVersion. Existing lockfiles (config version 0) keep the hoisted linker they were created with. Your node_modules layout does not change on upgrade. #24236 # bunfig.toml: pin the old behavior if you need it [install] linker = "hoisted" bun.lock is now lockfileVersion: 2 v1.4.0# New lockfiles use version 2. Version 2 adds two stricter parse-time checks: 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": { ... }, Bun invoked as node no longer loads .env files v1.4.0# When Bun runs as node (under bun --bun, bunx --bun, or a node symlink to Bun), it no longer loads .env, .env.local, or .env.{development,production,test}. This matches Node.js. bun file.js still loads them. A package.json script that calls node under bun --bun run now sees those variables as undefined. To keep them, pass --env-file to node. #36610 "scripts": { "check": "node ./check.js" "check": "node --env-file=.env ./check.js" Bun.YAML now parses yes/no/on/off as strings, not booleans v1.3.5# Bun.YAML now parses booleans per the YAML 1.2 spec. yes/no/on/off/y/Y are plain strings, not booleans. These are YAML 1.1 legacy values that the 1.2 spec dropped. An on: key in a GitHub Actions workflow file now parses as the string "on", not true. Only true/True/TRUE and false/False/FALSE resolve to booleans. #25537 Bun.YAML.parse("on: push"); // { on: "push" } Bun.TOML.parse() and bunfig.toml are stricter and throw SyntaxError v1.4.0# The rewritten Bun.TOML parser throws SyntaxError instead of BuildMessage. It rejects TOML that the old parser let through: 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" .xml imports now return the parsed document instead of the file path v1.4.0# import or require() of a .xml file now returns the same object as Bun.XML.parse(). This applies at runtime and in bun build. Before, it returned the file's path. A file that does not parse throws at runtime and fails the build. To keep getting the path, pass --loader .xml:file. #37048 import "." and import ".." now resolve as directories v1.4.0# "." and ".." in import and require() now resolve to the directory's index file or package.json main. This matches Node.js. Before, they resolved to a sibling file with the directory's name. So "." inside lib/run.ts loaded lib.ts; it now loads lib/index.ts. To keep the sibling, name it. #36969 import { e } from "."; import { e } from "../lib"; .css imports at runtime now export {} instead of the file path v1.4.0# At runtime, the default export of a .css import is now {}. This applies to import, require(), dynamic import(), and Workers. Before, it was the file's absolute path as a string. bun build already emitted {}. .module.css still differs from bun build, which emits a class-name map. #35163 "jsx": "react-jsx" in tsconfig.json now emits jsx instead of jsxDEV v1.4.0# With "jsx": "react-jsx", bun run and bun build now import jsx and jsxs from /jsx-runtime. Before, both imported jsxDEV from /jsx-dev-runtime unless NODE_ENV=production or --production was set. An explicit NODE_ENV still wins. To keep the development runtime, set "jsx": "react-jsxdev". #34422 { "compilerOptions": { "jsx": "react-jsx" "jsx": "react-jsxdev" } } useDefineForClassFields: false in tsconfig.json is now honored v1.4.0# With useDefineForClassFields: false, Bun now does what tsc does: 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 Bun.Socket#setKeepAlive() now treats initialDelay as milliseconds v1.4.0# setKeepAlive(true, delay) on a Bun.Socket now divides delay by 1000 before setting TCP_KEEPIDLE, as documented. Before, the raw value was used as seconds, so 4000 meant 4000 seconds. A value under 1000 now divides to 0 and leaves TCP_KEEPIDLE unchanged. Code that passed seconds should pass milliseconds. setKeepAlive(true) now returns true instead of false. net.Socket#setKeepAlive() still sets the same kernel value as before. #34269 socket.setKeepAlive(true, 60); socket.setKeepAlive(true, 60_000); Bun.mmap({ offset }) now starts the view at offset v1.4.0# Bun.mmap(path, { offset }) now returns a view whose index 0 is the byte at offset. Before, offset was rounded down to a page boundary. The view started at that boundary, for reads and for writes through { shared: true }. Remove any offset % pageSize adjustment you added to compensate. #34120 const m = Bun.mmap("data.bin", { offset: 100 }); m[0]; // byte 0 of the file m[0]; // byte 100 of the file Bun.cron.parse() and in-process Bun.cron() now use local time v1.4.0# Bun.cron.parse() and the in-process Bun.cron(schedule, handler) overload now read schedules in the process's local time zone. Before, they used UTC. This matches the OS-registered overload. "0 9 * * *" under TZ=America/Los_Angeles now means 9:00 Pacific. To keep the old times, pass { tz: "UTC" }. Both accept it as a new final argument. #35122 Bun.cron("0 9 * * *", handler); Bun.cron("0 9 * * *", handler, { tz: "UTC" }); Bun.$ now globs only patterns written in the template itself v1.4.0# Glob characters that arrive through ${...}, a shell variable, command substitution, or quoted text are now literal. Only *, **, and braces written directly in the template expand. ?, [...], and a leading ! are literal everywhere. Before, $`echo ${"**/"}*` matched recursively. It now fails with no matches found. Write the pattern in the template instead. #31220 await $`echo ${"**/"}*`; await $`echo **/*`; fs.rmdir no longer accepts { recursive: true } v1.4.0# Passing recursive: true to fs.rmdir now throws ERR_INVALID_ARG_VALUE. This matches Node.js, which removed the option after a long deprecation. Use fs.rm instead. #31830 await fs.rmdir("build", { recursive: true }); await fs.rm("build", { recursive: true, force: true }); X509Certificate serial and modulus are now uppercase hex v1.4.0# X509Certificate#serialNumber, .toLegacyObject().modulus, and tls.TLSSocket#getPeerCertificate() now return uppercase hex. This matches Node.js and openssl x509 -serial. If you pin certificates against a lowercase serial string, normalize the case first. #31519 const { serialNumber } = new X509Certificate(pem); // "3b8e2a..." // "3B8E2A..." tls.createServer({ requestCert: true }) now rejects unverified client certificates v1.4.0# A node:tls server with requestCert: true and no explicit rejectUnauthorized now applies the default of true. A connection whose client certificate does not verify is destroyed, and the server emits tlsClientError. Before, it reached your handler with authorized: false. To keep admitting those clients, pass rejectUnauthorized: false. #31322 tls.createServer({ ca, requestCert: true, rejectUnauthorized: false, }); dgram.Socket now throws synchronously on a second bind() and after close() v1.4.0# Two node:dgram changes, both matching Node.js: bind() on a socket that is already bound throws ERR_SOCKET_ALREADY_BOUND. Before, it emitted an error event. 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 error event). Code that handled a second bind() in an error listener needs a try/catch. #33037 #33024 dns.lookup() now uses the system resolver on Linux v1.4.0# On Linux, dns.lookup(), dns.promises.lookup(), and hostname resolution in net.connect() now go through getaddrinfo(), as in Node.js. Before, they used c-ares. Names that only systemd-resolved or a split-DNS VPN knows now resolve. Before, they failed with getaddrinfo EREFUSED. dns.setServers() no longer affects these calls. dns.resolve*() and Bun.dns.lookup() still use c-ares. If you need the old behavior for a lookup, pass { backend: "c-ares" } to Bun.dns.lookup(). #37383 Exceptions thrown in node:fs, node:dns, and crypto.pbkdf2 callbacks are now uncaughtException v1.4.0# An exception thrown inside a node:fs, node:dns, or crypto.pbkdf2() callback now reaches process.on("uncaughtException"), as in Node.js. Before, it surfaced as an unhandledRejection. A handler registered there no longer sees it. Move the handler. #34660 fs.readFile("config.json", () => { throw new Error("bad config"); }); process.on("unhandledRejection", onError); process.on("uncaughtException", onError); net.Server and tls.Server no longer auto-resume accepted sockets; tls.Server checks requestCert and rejectUnauthorized literally v1.4.0# Sockets accepted by net.Server or tls.Server are no longer resumed automatically. Bytes that arrive before a 'data' listener is attached are buffered, as in Node.js. Only a literal rejectUnauthorized: false disables verification. This applies to tls.connect() and tls.Server. Before, null did too. requestCert must be literally true. A tls.Server no longer reads NODE_TLS_REJECT_UNAUTHORIZED for its default. handshakeTimeout now also emits the socket's 'timeout' event (after 'tlsClientError'). It leaves the socket open instead of destroying it. An exception thrown in an onread callback or 'secureConnection' listener is now an uncaught exception. #32630 #34598 #35006 tls.createServer({ key, cert, ca, requestCert: 1, rejectUnauthorized: null, requestCert: true, rejectUnauthorized: false, }); fetch() responses and Bun.serve requests now combine duplicate headers with , v1.4.0# Duplicate headers on a fetch() response or a Bun.serve request are now joined with , , per the Fetch spec. Before, only the last value was kept. Common headers were already combined. This change affects the rest, including every custom header. fetch() responses also keep empty values now. A header sent with no value reads "" instead of null. Set-Cookie still comes back as separate values from getSetCookie(). #31734 // X-Dup: first // X-Dup: second res.headers.get("x-dup"); // "second" // "first, second" Request#clone() and Response#clone() now throw once the body has been read v1.4.0# clone() on a Request or Response whose body has been read, or whose stream is locked, now throws TypeError: Body is disturbed or locked (ERR_BODY_ALREADY_USED). This is per the Fetch spec. It includes the request passed to Bun.serve route handlers. Before, clone() succeeded and the problem showed up later, as an empty body or an error when the clone was read. Call clone() before reading the body. #33129 const text = await req.text(); const copy = req.clone(); const copy = req.clone(); const text = await req.text(); fetch() network errors are now TypeError, and a failed body read sets bodyUsed v1.4.0# fetch() and response body reads now reject a network error with a TypeError. Before, it was a plain Error. .code (for example ECONNRESET) is still set. After a body read fails, bodyUsed is true. A second read rejects with ERR_BODY_ALREADY_USED instead of the socket error. Issue a new fetch() to retry. fetch(request) with a request whose stream body was already used now rejects with the same TypeError before connecting. #35855 #36499 const res = await fetch(url); // connection drops mid-body await res.text(); // rejects with Error, code "ECONNRESET" await res.text(); // rejects with TypeError, code "ECONNRESET" Bun.serve({ inspector }) has been removed v1.3.14# The undocumented inspector: true option is now silently ignored. It mounted a /bun:inspect debugger WebSocket on your HTTP port. It predated bun --inspect and was never in the public types. Use the --inspect flag to attach a debugger. #29613 Bun.serve({ inspector: true, fetch }); Bun.serve({ fetch }); bun --inspect server.ts server.publish() and ws.publish() now return 0 or -1 under backpressure v1.4.0# server.publish(), ws.publish(), ws.publishText(), and ws.publishBinary() now return: 0 if the message was dropped for any subscriber, or the topic had no subscribers -1 if 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 connections and waits for in-flight requests v1.4.0# server.stop() now closes idle keep-alive connections immediately. It closes busy ones once their response is sent. It resolves when the last connection has closed. Before, it closed only the listener and resolved while requests were still being served. It now stays pending on a connection that has sent part of a request and stopped. server.stop(true) closes such connections. It now works after a graceful stop() too. #35130 #37074 WebSocket (global) no longer accepts an agent option v1.3.6# The non-standard agent option on the Web-standard WebSocket constructor is removed. Node.js's global WebSocket uses an undici dispatcher, not an http.Agent. The ws package's WebSocket, which Bun polyfills natively, now accepts agent instead. This matches its documented API. #25935 const ws = new WebSocket(url, { agent }); // global import WebSocket from "ws"; const ws = new WebSocket(url, { agent }); // ws module WebSocket#close(), ping(), and pong() now validate their arguments v1.4.0# close() now throws InvalidAccessError for a code other than 1000 to 1003, 1007 to 1014, or 3000 to 4999. It throws SyntaxError for a reason longer than 123 UTF-8 bytes. Before, an invalid code went out unchecked. With the default code, an over-long reason was silently sent as empty. ping() and pong() on the WebSocket client, ServerWebSocket, and the ws package now throw RangeError for a payload over 125 bytes. Before, they sent it. Shorten the reason or payload. #32820 #35030 WebSocket now fails the handshake if a requested subprotocol is not negotiated v1.4.0# new WebSocket(url, protocols) now closes with code 1002 when the server's 101 response omits Sec-WebSocket-Protocol. This is per RFC 6455 and matches browsers. Before, it opened with ws.protocol === "". Fix the server to echo a protocol, or stop passing protocols. Connections that request no subprotocol are unaffected. #33072 WebSocket#close() no longer fires close before it returns v1.4.0# ws.close() and ws.terminate() on a WebSocket client now queue the close event, as in Node.js and browsers. When the call returns, readyState is CLOSING and onclose has not run yet. Code that read CLOSED on the next line, or relied on onclose having run, should await the close event instead. #27259 ws.close(); ws.readyState; // 3, CLOSED ws.readyState; // 2, CLOSING jest.resetAllMocks() now drops mock implementations v1.4.0# jest.resetAllMocks() and vi.resetAllMocks() now reset every mock's implementation as well as its call history. This matches Jest. Before, they behaved like clearAllMocks(). After the reset, a jest.fn(() => 42) returns undefined. A spyOn() spy returns undefined until mockRestore(). If you only want the call history cleared, call clearAllMocks(). #33374 afterEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); }); expect().toContain() now compares with === instead of Object.is v1.4.0# toContain() in bun:test now compares array and iterable elements with === instead of Object.is. This matches Jest. expect([-0]).toContain(0) passes and expect([NaN]).toContain(NaN) fails. toBe() still uses Object.is. toContainEqual() still uses deep equality. #32950 expect(values).toContain(NaN); expect([...values].some(Number.isNaN)).toBe(true); Bun.sql now decodes MySQL DATETIME and TIMESTAMP as UTC v1.4.0# MySQL DATETIME and TIMESTAMP columns are now decoded as UTC. This matches how Bun.sql encodes them, so a Date round-trips unchanged. Before, it came back shifted by the machine's UTC offset on any host not running in UTC. Postgres timestamp read through .simple() is decoded as UTC too. timestamptz is unaffected. Remove any offset correction you added. #31212 await sql`INSERT INTO t (dt) VALUES (${new Date("2024-06-15T12:00:00Z")})`; const [{ dt }] = await sql`SELECT dt FROM t`; dt.toISOString(); // "2024-06-15T16:00:00.000Z" under TZ=America/New_York dt.toISOString(); // "2024-06-15T12:00:00.000Z" Bun.sql now parses MariaDB 10.5+ JSON columns instead of returning strings v1.4.0# On MariaDB 10.5 and later, Bun.sql now parses JSON columns and JSON function results such as JSON_OBJECT() and JSON_EXTRACT(). Before, it returned the JSON text as a string. A json column holding {"b": 1} now reads as the object { b: 1 }. Remove the JSON.parse(). #37130 const [row] = await sql`SELECT a FROM t`; const a = JSON.parse(row.a); const a = row.a; // { b: 1 } Other behavior changes v1.4.0# The bun feedback command is removed. #38444 The bun feedback command is removed. #38444 Bun.password.hash() with argon2 now requires memoryCost of at least 8. Hashes made by Bun 1.3 with a lower memoryCost still verify. #39596 Bun.password.hash() with argon2 now requires memoryCost of at least 8. Hashes made by Bun 1.3 with a lower memoryCost still verify. #39596 bun update now moves transitive packages. bun update errors (exit 1) on a name nothing depends on. Before, it added the package. --production/--prod on update means "only update dependencies and optionalDependencies". -i updates only the selection. #38333 bun update now moves transitive packages. bun update errors (exit 1) on a name nothing depends on. Before, it added the package. --production/--prod on update means "only update dependencies and optionalDependencies". -i updates only the selection. #38333 A project's bunfig.toml now overrides any .npmrc for the same key. #38333 A project's bunfig.toml now overrides any .npmrc for the same key. #38333 bun install --filter x now edits x, not the root. bun add y --filter x no longer installs a package named x. add/remove --filter '*' no longer includes the root. #38333 bun install --filter x now edits x, not the root. bun add y --filter x no longer installs a package named x. add/remove --filter '*' no longer includes the root. #38333 A plain bun add x in a workspace whose default catalog lists x now writes catalog:. audit fix may rewrite exact pins. --frozen-lockfile --lockfile-only writes nothing. Overrides/catalog changes fail frozen installs. #38333 A plain bun add x in a workspace whose default catalog lists x now writes catalog:. audit fix may rewrite exact pins. --frozen-lockfile --lockfile-only writes nothing. Overrides/catalog changes fail frozen installs. #38333 Projects with catalog: peers or dead pkg@range override 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 Projects with catalog: peers or dead pkg@range override 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 init now writes typescript ^7. Before, it wrote ^5, or nothing in the React templates. A fresh project installs TypeScript 7. #33265 #39341 bun init now writes typescript ^7. Before, it wrote ^5, or nothing in the React templates. A fresh project installs TypeScript 7. #33265 #39341 bun init with a non-TTY stdin (CI, a piped spawn) now behaves as bun init -y. Before, it opened the template picker. #35165 bun init with a non-TTY stdin (CI, a piped spawn) now behaves as bun init -y. Before, it opened the template picker. #35165 bun update -i with a non-TTY stdin now exits with code 1 and an error. Before, it opened the picker. Use bun update or bun outdated. #35165 bun update -i with a non-TTY stdin now exits with code 1 and an error. Before, it opened the picker. Use bun update or bun outdated. #35165 bun update with no package names now rewrites the root catalog and catalogs entries (to the newest version with --latest). It leaves catalog: references in workspace package.json files in place. Before, it replaced them with ^ . With --recursive or --filter, it rewrites each selected workspace's package.json. It touches the root catalog only when the root is selected. #36304 #36360 #36379 bun update with no package names now rewrites the root catalog and catalogs entries (to the newest version with --latest). It leaves catalog: references in workspace package.json files in place. Before, it replaced them with ^ . With --recursive or --filter, it rewrites each selected workspace's package.json. It touches the root catalog only when the root is selected. #36304 #36360 #36379 bun install and bun remove now drop a package from bun.lock when 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 bun install and bun remove now drop a package from bun.lock when 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 trustedDependencies and --trust entries 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.lockb still match by hash. #31218 trustedDependencies and --trust entries 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.lockb still match by hash. #31218 bun install --registry no longer sends the configured registry's credentials to when it is a different host, or when it downgrades from https:// to http://. #36165 bun install --registry no longer sends the configured registry's credentials to 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.json files. Inside a downloaded package, they fail to resolve like any other unknown range. Before, they created a workspace package. #37669 workspace: ranges are now honored only in the root and workspace package.json files. 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 SyntaxError on invalid input. Before, it threw a BuildMessage. Bun.JSONC.parse("") also throws SyntaxError. Before, it returned {}. #35066 Bun.JSONC.parse() now throws SyntaxError on invalid input. Before, it threw a BuildMessage. Bun.JSONC.parse("") also throws SyntaxError. Before, it returned {}. #35066 Wildcard exports and imports targets in package.json that do not name an existing file are now retried with each known extension, or with .ts in place of .js. A subpath such as @modelcontextprotocol/sdk/server/stdio now resolves. Before, it failed with Cannot find module. #36299 Wildcard exports and imports targets in package.json that do not name an existing file are now retried with each known extension, or with .ts in place of .js. A subpath such as @modelcontextprotocol/sdk/server/stdio now resolves. Before, it failed with Cannot find module. #36299 bun build now bundles an unresolvable require(), require.resolve(), or await import() inside catch as a runtime throw. Before, it failed with Could not resolve. #35659 bun build now bundles an unresolvable require(), require.resolve(), or await import() inside catch as 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 TypeError when reached. bun build still reports it as an error. #36046 Assigning to an imported binding is no longer a parse error at runtime. The module loads, and the assignment throws TypeError when reached. bun build still reports it as an error. #36046 bun build --target browser now honors a package's browser field entry for a Node builtin ("crypto": false or a remap). Before, it bundled the polyfill. It also resolves require() of a package that has jsnext:main but no module field to its main, as it already did for module. #35447 #36597 bun build --target browser now honors a package's browser field entry for a Node builtin ("crypto": false or a remap). Before, it bundled the polyfill. It also resolves require() of a package that has jsnext:main but no module field to its main, as it already did for module. #35447 #36597 A bundled import * as ns namespace 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 A bundled import * as ns namespace 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 --minify no longer generates a bare $ identifier. That identifier shadowed jQuery's $ when a bundle was loaded as a classic script. #35668 bun build --minify no 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.redis with an invalid REDIS_URL) now throws at the binding that uses it. Before, it failed the whole module. #37525 #37714 #37726 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.redis with an invalid REDIS_URL) now throws at the binding that uses it. Before, it failed the whole module. #37525 #37714 #37726 bun build --metafile now sets a bundled import's path to the imported file's inputs key (src/b/shared.js). Before, it was the raw specifier or an absolute path, so metafile.inputs[path] never matched. #34534 bun build --metafile now sets a bundled import's path to the imported file's inputs key (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 RangeError for a timestamp of 2**48 or more. Before, values up to 2**53 - 1 were truncated to 48 bits. It also throws for a NaN timestamp, an invalid Date, or a Date before 1970. Before, these were encoded as 0. #34021 Bun.randomUUIDv7() now throws RangeError for a timestamp of 2**48 or more. Before, values up to 2**53 - 1 were truncated to 48 bits. It also throws for a NaN timestamp, an invalid Date, or a Date before 1970. Before, these were encoded as 0. #34021 Bun.udpSocket({ connect: { port } }) now throws for a port outside 1 to 65535. Before, it connected to port 0 and dropped every datagram. #34029 Bun.udpSocket({ connect: { port } }) now throws for a port outside 1 to 65535. Before, it connected to port 0 and dropped every datagram. #34029 Bun.YAML.parse() now throws SyntaxError on a NUL byte. Before, it silently stopped there. If you pad a buffer with zeros, pad with newlines instead. Bun.YAML.parse() now throws SyntaxError on 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 0xff0000 is now opaque. Before, it had alpha 0. #33328 #33046 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 0xff0000 is now opaque. Before, it had alpha 0. #33328 #33046 Bun.Cookie now serializes Expires like Date#toUTCString(). Before, the weekday was one day off, the day was unpadded, and the zone was -0000 instead of GMT. Update tests that assert the old string. #32926 Bun.Cookie now serializes Expires like Date#toUTCString(). Before, the weekday was one day off, the day was unpadded, and the zone was -0000 instead of GMT. Update tests that assert the old string. #32926 structuredClone(), self.postMessage() inside a worker, and new Worker(path, { transferList }) now throw TypeError for a transfer entry that is not an object, such as null. Before, they skipped it. #32809 structuredClone(), self.postMessage() inside a worker, and new Worker(path, { transferList }) now throw TypeError for 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 JSCallback returned an instance whose ptr was undefined. #34396 bun:ffi viewSource() and new JSCallback() now throw on invalid arguments. Before, viewSource() returned the error, and JSCallback returned an instance whose ptr was undefined. #34396 Bun.FileSystemRouter.match() now returns null for a non-empty path string that does not start with /. Before, "Xtop" matched /top. Full URLs are unaffected. #34028 Bun.FileSystemRouter.match() now returns null for 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. drain now fires on POSIX. #34289 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. drain now 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 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_VALUE for a NUL byte in argv0 or cwd. Before, argv0 was silently cut at the NUL. Bun.spawn() and Bun.spawnSync() now throw ERR_INVALID_ARG_VALUE for a NUL byte in argv0 or cwd. Before, argv0 was silently cut at the NUL. Bun.$ now fails with ambiguous redirect when a redirect target such as > *.txt expands to more than one word. Before, the words were joined into one path. #34324 Bun.$ now fails with ambiguous redirect when a redirect target such as > *.txt expands 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. 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_RANGE for timeout: NaN and ERR_UNKNOWN_SIGNAL for killSignal: 0. Before, timeout: NaN meant no timeout, and killSignal: 0 sent a no-op signal. The child kept running either way. #35348 Bun.spawn() and Bun.spawnSync() now throw ERR_OUT_OF_RANGE for timeout: NaN and ERR_UNKNOWN_SIGNAL for killSignal: 0. Before, timeout: NaN meant no timeout, and killSignal: 0 sent a no-op signal. The child kept running either way. #35348 Bun.spawn() and Bun.spawnSync() now throw AbortError (with cause set to signal.reason) for a signal that 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.spawn() and Bun.spawnSync() now throw AbortError (with cause set to signal.reason) for a signal that 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: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:sqlite row objects and stmt.columnNames now keep a column aliased AS "". Before, it was dropped, and a trailing one made .all() return a number. columnNames now throws after finalize(). #34925 bun:sqlite row objects and stmt.columnNames now keep a column aliased AS "". Before, it was dropped, and a trailing one made .all() return a number. columnNames now throws after finalize(). #34925 Two robustness passes changed several edge cases. Bun.spawn({ stdout: typedArray }) throws instead of aborting. FileSystemRouter no longer matches a URL shorter than the route pattern. CSS serialization escapes identifiers consistently. Workers read process.env at runtime instead of at transpile time. The PR bodies list the rest. Two robustness passes changed several edge cases. Bun.spawn({ stdout: typedArray }) throws instead of aborting. FileSystemRouter no longer matches a URL shorter than the route pattern. CSS serialization escapes identifiers consistently. Workers read process.env at runtime instead of at transpile time. The PR bodies list the rest. S3Client.list() entries now expose checksumAlgorithm. The misspelled checksumAlgorithme still works but is non-enumerable. It no longer appears in Object.keys() or JSON.stringify() output. #36502 S3Client.list() entries now expose checksumAlgorithm. The misspelled checksumAlgorithme still 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 options argument to TextDecoder#decode() invalid arguments to crypto.createDiffieHellman() (before, returned as an error object) NaN or undefined seconds in RedisClient#expire() (before, sent EXPIRE key 0) fractional or beyond-32-bit ports for Bun.udpSocket(), and cost, timeCost, or memoryCost values for Bun.password (before, wrapped or truncated into range) Bun.openInEditor() with no editor found (before, returned silently) an fs.write() offset past the end of the buffer when length is omitted (before, wrote 0 bytes) #35188 #35189 #36508 #36835 #36999 #37210 #37632 More inputs that were silently accepted now throw: odd-length hex passed to Bun.CryptoHasher#update() a primitive options argument to TextDecoder#decode() invalid arguments to crypto.createDiffieHellman() (before, returned as an error object) NaN or undefined seconds in RedisClient#expire() (before, sent EXPIRE key 0) fractional or beyond-32-bit ports for Bun.udpSocket(), and cost, timeCost, or memoryCost values for Bun.password (before, wrapped or truncated into range) Bun.openInEditor() with no editor found (before, returned silently) an fs.write() offset past the end of the buffer when length is omitted (before, wrote 0 bytes) #35188 #35189 #36508 #36835 #36999 #37210 #37632 new URL(bad) now throws Node's TypeError: Invalid URL with code and input set. It rejects an invalid punycode xn-- host for special schemes. #34660 new URL(bad) now throws Node's TypeError: Invalid URL with code and input set. 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 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. stdout and stderr always emit Buffer chunks. Call child.stdout.setEncoding() to get strings. #36050 child_process.spawn() now ignores options.encoding, as Node does. stdout and stderr always emit Buffer chunks. 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 0 once 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 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 0 once 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_VALUE when an object is passed as flags. Before, {} opened the file read-only. #34505 fs.open() now throws ERR_INVALID_ARG_VALUE when an object is passed as flags. Before, {} opened the file read-only. #34505 fs.rm() and fs.rmSync() now reject recursive, force, retryDelay, or maxRetries explicitly set to undefined, as Node does. Omit the key instead. #34505 fs.rm() and fs.rmSync() now reject recursive, force, retryDelay, or maxRetries explicitly set to undefined, as Node does. Omit the key instead. #34505 On Windows, process.binding("uv") and every node:fs error now use libuv's error numbers (-4058 for ENOENT). Before, the binding and some fs calls such as fs.access() reported CRT values like -2. POSIX is unchanged. #34505 On Windows, process.binding("uv") and every node:fs error now use libuv's error numbers (-4058 for 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 position is 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.write(), fs.writev(), and fs.readv() now operate at the current file offset when position is 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.appendFile() and fs.appendFileSync() with { flag: "w" } now truncate the file, as the flag says. Before, they appended. #36553 fs.watch() with recursive: true on 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 fs.watch() with recursive: true on 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.remoteSettings in node:http2 is now {} while the session is connecting or destroyed, as in Node. Before, it was null. Reading a setting right after connect() now returns undefined instead of throwing. session.localSettings is {} at that point too. Before the peer's ACK, it shows only the defaults plus your customSettings. #34358 session.remoteSettings in node:http2 is now {} while the session is connecting or destroyed, as in Node. Before, it was null. Reading a setting right after connect() now returns undefined instead of throwing. session.localSettings is {} 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_STREAM on the DATA frame carrying chunk, as Node does. Before, it sent an empty frame after it. #34432 node:http2 stream.end(chunk) now sets END_STREAM on the DATA frame 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:http2 pushStream() now reports invalid headers only through its callback, as Node does. The pushed stream no longer also emits 'error'. #36551 node:test suites marked skip no 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 node:test suites marked skip no 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 path when the exec fails. This matches Node 26. Before, it printed an error and aborted. process.execve() now throws an error carrying code, syscall, errno, and path when the exec fails. This matches Node 26. Before, it printed an error and aborted. process.title now defaults to argv[0] as invoked. Before, it was "bun". #31831 process.title now 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.builtinModules no longer lists bun:wrap. #31831 require(), (await import()).default, and process.getBuiltinModule() now return the same object for a natively implemented builtin such as node:buffer. module.builtinModules no 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 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.stdout by default) is not a TTY. Pass { validateStream: false } to always get escape codes. util.inspect() now brackets ArrayBuffer internals ([byteLength]: 4). util.format("%s", date) prints the ISO form. vm module namespaces have a null prototype. #34434 util.styleText() now follows the Node 26 API. It returns plain text when the target stream (process.stdout by default) is not a TTY. Pass { validateStream: false } to always get escape codes. util.inspect() now brackets ArrayBuffer internals ([byteLength]: 4). util.format("%s", date) prints the ISO form. vm module namespaces have a null prototype. #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 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.subtle is now a getter on Crypto.prototype. It throws ERR_INVALID_THIS when 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 crypto.subtle is now a getter on Crypto.prototype. It throws ERR_INVALID_THIS when 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/catch around an unawaited call no longer catches it. #33649 fetch() now returns a rejected promise when reading its options throws. Before, it threw synchronously. A synchronous try/catch around an unawaited call no longer catches it. #33649 Response.redirect(url) now parses and re-serializes an absolute url before writing Location. http://example.com becomes http://example.com/. A relative url is written as-is. A relative url containing a code point above U+00FF now throws TypeError. #33126 Response.redirect(url) now parses and re-serializes an absolute url before writing Location. http://example.com becomes http://example.com/. A relative url is written as-is. A relative url containing a code point above U+00FF now throws TypeError. #33126 fetch() now rejects the body read when a compressed response with neither Content-Length nor Transfer-Encoding is cut off early. Before, it resolved with partial data. #34922 fetch() now rejects the body read when a compressed response with neither Content-Length nor Transfer-Encoding is cut off early. Before, it resolved with partial data. #34922 fetch() now errors the response body when its signal aborts, 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 errors the response body when its signal aborts, 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 Upgrade as token lists. Any close token disables connection reuse. Transfer-Encoding: gzip, chunked is framed as chunked instead of rejected. identity codings 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 parses Connection, Transfer-Encoding, Content-Encoding, and Upgrade as token lists. Any close token disables connection reuse. Transfer-Encoding: gzip, chunked is framed as chunked instead of rejected. identity codings 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() 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 3xx such as 304 now resolve. Before, they rejected with UnexpectedRedirect. #36539 fetch() with redirect: "error" now rejects only on 301, 302, 303, 307, and 308, per the Fetch spec. Other 3xx such as 304 now 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 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 RangeError for non-integer, negative, or out-of-range port values. Before, it silently clamped: port: 65536 started a server on port 65535, and port: -1 bound a random port. Numeric strings and null/undefined still work. #34957 Bun.serve({ port }) now throws a RangeError for non-integer, negative, or out-of-range port values. Before, it silently clamped: port: 65536 started a server on port 65535, and port: -1 bound a random port. Numeric strings and null/undefined still work. #34957 Bun.serve now treats a returned Response with a status outside 100 to 999, such as Response.error(), like a thrown error. It goes to error() and answers 500 by default. Before, it wrote an invalid status line. #33400 Bun.serve now treats a returned Response with a status outside 100 to 999, such as Response.error(), like a thrown error. It goes to error() and answers 500 by default. Before, it wrote an invalid status line. #33400 Bun.serve per-method route objects ({ GET: handler }) now answer HEAD with the GET handler when no HEAD key is set. Before, the request fell through to the next route or 404. #32822 Bun.serve per-method route objects ({ GET: handler }) now answer HEAD with the GET handler when no HEAD key is set. Before, the request fell through to the next route or 404. #32822 Bun.serve WebSocket connections now close with code 1006 and reason Received an incorrectly masked frame when a client sends an unmasked frame, per RFC 6455. Before, the frame was parsed as if it were masked. #32820 Bun.serve WebSocket connections now close with code 1006 and reason Received an incorrectly masked frame when a client sends an unmasked frame, per RFC 6455. Before, the frame was parsed as if it were masked. #32820 Bun.serve now answers 413 and 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.serve now answers 413 and 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.serve HTML routes with development: false no longer emit sourceMappingURL or debugId comments. .map URLs answer 404. [serve.static] sourcemap = "linked" in bunfig.toml restores them. #36982 Bun.serve HTML routes with development: false no longer emit sourceMappingURL or debugId comments. .map URLs answer 404. [serve.static] sourcemap = "linked" in bunfig.toml restores them. #36982 Bun.serve({ tls: [...] }) now enforces requestCert and rejectUnauthorized set on a per-serverName entry. 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.serve({ tls: [...] }) now enforces requestCert and rejectUnauthorized set on a per-serverName entry. 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.serve now answers 400 to a request whose Transfer-Encoding names anything besides a single final chunked (gzip, chunked, chunked, chunked). Before, such requests got 200 with the body still encoded. node:http still accepts gzip, chunked but now rejects chunked, chunked. #35295 Bun.serve now answers 400 to a request whose Transfer-Encoding names anything besides a single final chunked (gzip, chunked, chunked, chunked). Before, such requests got 200 with the body still encoded. node:http still accepts gzip, chunked but now rejects chunked, chunked. #35295 server.upgrade() now returns false unless the request has Upgrade: websocket and a well-formed Sec-WebSocket-Key. It answers 426 when Sec-WebSocket-Version is not 13. Before, any GET with a 24-byte key was upgraded. #35298 server.upgrade() now returns false unless the request has Upgrade: websocket and a well-formed Sec-WebSocket-Key. It answers 426 when Sec-WebSocket-Version is not 13. Before, any GET with a 24-byte key was upgraded. #35298 ws.subscribe() and ws.unsubscribe() now return false on a closed ServerWebSocket (and are typed boolean). #35236 ws.subscribe() and ws.unsubscribe() now return false on a closed ServerWebSocket (and are typed boolean). #35236 ws.send() and publish() of an in-memory Blob now send its bytes as a binary frame. Before, they sent the text [object Blob]. A Bun.file() blob throws; read it first. #36032 ws.send() and publish() of an in-memory Blob now send its bytes as a binary frame. Before, they sent the text [object Blob]. A Bun.file() blob throws; read it first. #36032 Bun.serve static and file routes now evaluate If-Match and If-Unmodified-Since on GET and HEAD. They answer 412 when the precondition fails. Before, both headers were ignored. #35169 Bun.serve static and file routes now evaluate If-Match and If-Unmodified-Since on GET and HEAD. They answer 412 when the precondition fails. Before, both headers were ignored. #35169 new WebSocket(url, { proxy }) now throws SyntaxError at construction for a proxy scheme other than http or https. Before, it failed later with Connection ended. #35147 new WebSocket(url, { proxy }) now throws SyntaxError at construction for a proxy scheme other than http or 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.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 connectionTimeout now 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.sql's connectionTimeout now 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.sql now honors PGSSLMODE from the environment. A URL ?sslmode= still wins. PGSSLMODE=require against 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.sql now honors PGSSLMODE from the environment. A URL ?sslmode= still wins. PGSSLMODE=require against 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.sql now decodes a Postgres date, timestamp, or timestamptz of infinity or -infinity as the number Infinity or -Infinity. Before, it was an invalid Date. Check for it before calling Date methods on the value. #35121 Bun.sql now decodes a Postgres date, timestamp, or timestamptz of infinity or -infinity as the number Infinity or -Infinity. Before, it was an invalid Date. Check for it before calling Date methods 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 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 v1.3.10v1.4.0 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/.editor commands 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