To install Bun

curl

curl -fsSL https://bun.sh/install | bash

powershell

powershell -c "irm bun.sh/install.ps1|iex"

docker

docker run --rm --init --ulimit memlock=-1:-1 oven/bun

To upgrade Bun

Bun.Image — Built-in Image Processing

Bun now ships a built-in image processing API that handles JPEG, PNG, WebP, GIF, and BMP — plus HEIC, AVIF, and TIFF on macOS and Windows — with zero native module installs.

Bun.Image provides a chainable pipeline for decoding, transforming, and encoding images, designed as a drop-in alternative to sharp for common server-side image operations.

// Resize and convert a photo to WebP
await Bun.file("photo.jpg")
  .image()
  .resize(1024, 1024, { fit: "inside" })
  .rotate(90)
  .webp({ quality: 85 })
  .write("thumb.webp");

// Generate a thumbnail from an upload in a single expression
return new Response(new Bun.Image(upload).resize(200).jpeg());

Input sources

Bun.Image accepts path strings, ArrayBuffer/TypedArray (zero-copy), Blob/BunFile/S3File, and data: URLs. You can also use Bun.file("photo.jpg").image() or blob.image() to start a pipeline.

Chainable transforms

The pipeline supports .resize(w, h?, {filter, fit, withoutEnlargement}), .rotate(90|180|270), .flip(), .flop(), and .modulate({brightness, saturation}). Output format is set with .jpeg(), .png(), .webp(), .heic(), or .avif() — each with format-specific quality/compression options.

Resize filters

All sharp filters are supported: nearest, box, bilinear, cubic, mitchell, lanczos2, lanczos3, plus mks2013 and mks2021.

Terminal methods

All processing runs off the main thread (except metadata()). Output via .bytes(), .buffer(), .blob(), .toBase64(), .dataurl(), .placeholder() (thumbhash), .metadata(), or .write(dest).

const meta = await new Bun.Image(buf).metadata();
// { width: 1920, height: 1080, format: "jpeg", ... }

const placeholder = await Bun.file("hero.jpg").image().placeholder(); // thumbhash data URL for blur-up

Body integration

Bun.Image instances work directly as response/request bodies with automatic Content-Type:

return new Response(new Bun.Image(upload).resize(200).jpeg());

Platform-specific formats

FormatmacOSWindowsLinux
JPEG
PNG
WebP
GIF
BMP (simple)
TIFF✅ decode✅ decode
HEIC✅ decode + encode✅ decode + encode
AVIF✅ decode (+ encode on Apple Silicon)✅ decode + encode

JPEG, PNG, WebP, GIF, and BMP use statically linked codecs and produce identical output across all platforms. HEIC, AVIF, and TIFF use OS system backends (ImageIO + vImage on macOS, WIC on Windows) with lazy symbol resolution for zero startup cost.

Performance vs sharp 0.34.5

Benchmarked on linux/x64 with 50 iterations and sharp.concurrency(1):

OperationBun.ImagesharpSpeedup
metadata()0.004 ms0.28 ms70×
1080p PNG → 400×400 → JPEG28.6 ms39.5 ms1.38×
1080p PNG → 800×600 → WebP82.7 ms110.1 ms1.33×
4K JPEG → 800×450 → JPEG35.8 ms45.5 ms1.27×
4K JPEG → 1920×1080 → JPEG57.2 ms69.9 ms1.22×
12MP JPEG → 1024×768 → WebP138 ms165 ms1.20×

The performance comes from i16 fixed-point SIMD resize kernels, JPEG IDCT scaling to the smallest sufficient size, zero-copy ArrayBuffer borrowing, and a single pre-allocated arena for resize scratch memory.

Global Virtual Store

bun install --linker=isolated now supports a shared global virtual store via the install.globalStore = true option in bunfig.toml. Instead of cloning every package from the cache into each project's node_modules on every install, packages are materialized once into a global <cache>/links/ directory, and each project's node_modules/.bun/<pkg>@<ver> becomes a symlink into it.

Warm installs — lockfile present, cache warm, node_modules wiped (the common CI path) — now perform ~1 symlink() per package instead of ~1 clonefileat() per file copy. On macOS APFS, clonefileat() holds a volume-wide kernel lock that made parallelization ineffective. The global store eliminates those calls entirely on the warm path.

Benchmarks — warm install of a ~1,400-package fixture on Apple Silicon macOS (hyperfine --warmup 3 --runs 10):

Wall timeSystem timeclonefileat calls
--linker hoisted823 ms478 ms1,387
--linker isolated (before)841 ms1,256 ms1,387
--linker isolated (after)115 ms94 ms0

This is still experimental, so the global store is off by default with the isolated linker.

To enable:

# bunfig.toml
[install]
globalStore = true

Or via environment variable:

BUN_INSTALL_GLOBAL_STORE=1 bun install

A package is eligible for the global store only when it comes from an immutable cache source (npm registry, git, tarball — unpatched, no trusted lifecycle scripts) and all of its transitive dependencies are also eligible. Ineligible packages fall back to per-project copies automatically.

The entry hash encodes the package's resolved dependency closure, so two projects that resolve a package to the same transitive versions share one on-disk entry, while a project with different resolutions gets its own.

This release also fixes a pre-existing issue: Bun now synthesizes an implicit "*" optional peer dependency for entries that appear in peerDependenciesMeta but not in peerDependencies (matching pnpm/yarn behavior). This fixes compatibility with packages like webpack-cli.

HTTP/3 (QUIC) support in Bun.serve

⚠️ Highly experimental. HTTP/3 support is new and likely has bugs. Do not deploy http3: true to production yet.

Bun.serve now supports HTTP/3 over QUIC. Enable it with a single flag:

Bun.serve({
  port: 443,
  tls: { cert, key },
  http3: true, // also listen on UDP/443 for HTTP/3
  fetch(req) {
    return new Response("hi");
  },
});

When http3: true is set alongside tls, Bun binds TCP for HTTP/1.1+2 and UDP for HTTP/3 on the same port. Your existing fetch handler and routes work identically across all three protocols — no code changes needed. HTTP/1.1 and HTTP/2 responses automatically include Alt-Svc: h3=":<port>"; ma=86400 so browsers discover the QUIC endpoint.

You can also serve HTTP/3 only:

Bun.serve({
  port: 443,
  tls: { cert, key },
  http3: true,
  http1: false, // disable HTTP/1.1
  fetch(req) {
    return new Response("h3 only");
  },
});

Everything you'd expect works over HTTP/3: new Response(readableStream) for streaming, new Response(Bun.file("large.bin")), new Response(req.body) passthrough, req.url/req.headers/req.method across await boundaries, requestIP(), server.reload(), and graceful server.stop().

Performance

On Linux x64 (single process, loopback), HTTP/3 is significantly faster than HTTPS/1.1 from the same server instance:

BenchmarkHTTP/3HTTPS/1.1HTTP/1.1
Static route (routes)509,135 req/s189,130 req/s239,476 req/s
Dynamic fetch handler283,485 req/s142,323 req/s171,696 req/s

~50% of HTTP/3 CPU time is inside lsquic; further optimizations may come in a future releas.e

Limitations

  • WebSocket over HTTP/3 is not supported yet (server.upgrade() returns false). WebTransport is a separate project.
  • 0-RTT is disabled.
  • unix: socket addresses skip the H3 listener (QUIC over Unix sockets is non-standard).
  • No trailer support, no Expect: 100-continue (matching HTTP/1.1 behavior).

Powered by lsquic v4.6.2.

Experimental HTTP/2 Client for fetch()

fetch() now supports HTTP/2 as an experimental feature. When enabled, Bun negotiates h2 via TLS ALPN — multiple concurrent fetches to the same origin share a single multiplexed TCP+TLS connection instead of opening separate HTTP/1.1 connections.

Enable it globally with an environment variable or CLI flag, or opt in per-request:

// Opt in globally:
//   BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT=1 bun run app.js
//   bun run --experimental-http2-fetch app.js

// Or per-request (works without the env flag):
const res = await fetch("https://example.com", { protocol: "http2" });

Multiplexing & connection coalescing

Parallel fetches to the same origin share one TLS handshake and one connection. The first request opens the socket; subsequent requests attach to the same HTTP/2 session up to the server's MAX_CONCURRENT_STREAMS limit, with overflow queued automatically.

Per-request protocol control

The new protocol option in RequestInit lets you pin the HTTP version:

// Force HTTP/2 — fails with HTTP2Unsupported if the server doesn't support it
await fetch("https://example.com", { protocol: "http2" });

// Force HTTP/1.1 — ignores the experimental flag
await fetch("https://example.com", { protocol: "http1.1" });

Accepted values: "http2", "h2", "http1.1", "h1".

What works

  • Keep-alive pooling — idle HTTP/2 sessions (with HPACK state) are reused by subsequent requests
  • Streaming request bodiesReadableStream bodies are sent as DATA frames with proper flow control
  • REFUSED_STREAM and graceful GOAWAY — transparently retried (up to 5 attempts) for replayable bodies
  • Content-Length enforcement per RFC 9113 §8.1.1
  • Expect: 100-continue support

Hardening

The HTTP/2 client also includes RFC 9113 conformance and denial-of-service protections:

  • CONTINUATION flood / HPACK bomb mitigation: 256 KiB cap on both header-block accumulation and decoded header lists, advertised via SETTINGS_MAX_HEADER_LIST_SIZE.
  • PING reflection attack mitigation: 1 MiB cap on queued PING/SETTINGS-ACK control frames prevents unbounded memory growth from malicious servers.
  • The first server frame must be SETTINGS per RFC 9113 — connections that violate this are immediately terminated.
  • RST_STREAM(NO_ERROR) mid-body now correctly fails the request instead of silently truncating the response.
  • REFUSED_STREAM retries only when no data has been delivered to the caller.
  • Content-Length mismatches with actual DATA frame bytes are now detected and rejected.
  • Trailers without END_STREAM are now rejected per spec.
  • GOAWAY no longer drops already-completed streams.

Not yet supported

HTTP proxies/CONNECT tunneling, Unix sockets, server push, and cleartext HTTP/2 (h2c) are not yet supported. The HTTP/1.1 path is completely unchanged when the flag is off and protocol is not set.

Experimental HTTP/3 Client for fetch()

fetch() now supports an experimental HTTP/3 client using the protocol option. This uses an lsquic-backed QUIC transport that runs alongside the existing HTTP/1.1 and HTTP/2 paths.

⚠️ Highly experimental. This is an early preview — the API may change in future releases.

const res = await fetch("https://example.com/", { protocol: "http3" });
console.log(await res.text());

Both "http3" and "h3" are accepted as protocol values. The HTTP/3 client shares the same redirect, decompression, and response handling pipeline as HTTP/1.1 and HTTP/2, so existing fetch() behavior is preserved.

What's supported:

  • All standard HTTP methods (GET, POST, PUT, DELETE, HEAD)
  • Request and response headers, JSON bodies, gzip compression
  • Redirects
  • Large request/response bodies (1 MB+ round-trips)
  • Concurrent multiplexed requests over a single QUIC connection
  • Connection pooling and sequential reuse
  • ReadableStream request body uploads
  • Full-duplex bidirectional streaming (server can respond while upload is still in progress)
  • rejectUnauthorized TLS option (defaults to true)
  • AbortSignal support

Alt-Svc HTTP/3 upgrades

The HTTP/3 client can also automatically upgrade fetch() requests to HTTP/3 via the Alt-Svc header (RFC 7838). When a server advertises Alt-Svc: h3 in an HTTPS response, subsequent requests to that origin are routed over QUIC instead of TCP.

This is opt-in while the HTTP/3 client matures. Enable it with the CLI flag or environment variable:

# CLI flag
bun --experimental-http3-fetch app.ts

# Environment variable
BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP3_CLIENT=1 bun app.ts
// First request goes over TCP/TLS as usual
const res1 = await fetch("https://example.com/api");
// If the response includes `Alt-Svc: h3=":443"`,
// the next request to the same origin uses QUIC/HTTP-3
const res2 = await fetch("https://example.com/api");

The upgrade is transparent and per-origin — cross-origin redirects re-evaluate from HTTP/1.1, and requests that aren't eligible (proxied, unix socket, sendfile, or pinned to a specific protocol) gracefully fall back to TCP.

Rewritten fs.watch() backend on Linux, macOS, and FreeBSD

Bun's fs.watch() implementation on POSIX platforms has been completely rewritten to talk directly to the OS file-watching APIs (inotify, FSEvents, kqueue) instead of routing through Bun's internal bundler watcher. This fixes several long-standing bugs and reduces complexity significantly.

Recursive watching now tracks new directories (Linux)

Previously, fs.watch("dir", { recursive: true }) only registered the directory tree that existed at the time watch() was called. Directories created after the watch started were never tracked, so files inside them were invisible to the watcher.

import fs from "node:fs";

// Now correctly detects changes inside directories created after watch() starts
fs.watch("./src", { recursive: true }, (event, filename) => {
  console.log(event, filename);
});

// mkdir src/newDir && touch src/newDir/file.txt
// Previously: only "rename newDir" — file.txt was missed
// Now: "rename newDir", "rename newDir/file.txt", "change newDir/file.txt"

Deleted-and-recreated files emit change events again (Linux)

When a watched file was deleted and recreated, subsequent modifications to the recreated file would silently stop emitting change events. This is now fixed — the new inotify watch descriptor is correctly registered on recreation.

macOS no longer spins up two watcher threads

Previously, fs.watch() on a directory on macOS would start both a kqueue watcher (via the bundler watcher) and an FSEvents CFRunLoop thread. The new implementation uses FSEvents exclusively for both files and directories, matching libuv's behavior and halving the thread overhead.

--no-orphans — exit when the parent process dies

Bun now supports an opt-in mode that automatically exits when its parent process dies — even if the parent was SIGKILLed and never had a chance to forward a signal. On exit, Bun also recursively SIGKILLs every descendant process it spawned.

This is useful when Bun is launched by a supervisor (Electron, a CI runner, a thin shim) that may be force-killed. Without this option, Bun would be silently reparented to launchd/init and keep running forever, along with anything it spawned.

There are three equivalent ways to enable it:

# CLI flag (new)
bun --no-orphans run my-script

# bunfig.toml
[run]
noOrphans = true

# Environment variable
BUN_FEATURE_FLAG_NO_ORPHANS=1 bun run my-script

The flag is automatically inherited by nested Bun processes, so enabling it once at the top level is sufficient.

How it works:

  • Linux: Uses prctl(PR_SET_PDEATHSIG, SIGKILL) — kernel-delivered, no polling, no thread. Children spawned from the main thread also inherit PDEATHSIG by default so non-Bun descendants are covered.
  • macOS: Registers a EVFILT_PROC/NOTE_EXIT watch for the original parent pid on the existing event loop's kqueue — the same mechanism Bun already uses to watch child process exits. No dedicated thread, no extra file descriptor.

On clean exit, Bun walks its process tree and uses a stop-verify-kill strategy for pid-reuse safety: each descendant is SIGSTOPped, its ppid is re-verified, and only then is it SIGKILLed. This prevents accidentally killing an unrelated process that recycled a stale pid.

macOS coverage is now comprehensive. Previously, bun run <script> and bunx on macOS had no parent-death watching — if the parent was killed, spawned scripts could be left orphaned. Bun now uses a dedicated kqueue watcher for these paths, monitoring both the parent process and child stdio. bun run --filter and bun run --parallel on macOS are also now covered.

LinuxmacOS
bun <file>prctl✅ Event loop watcher
bun run / bunxprctlkqueue watcher (new)
--filter / --parallelprctl✅ MiniEventLoop watcher (new)

Linux and macOS only (no-op on Windows).

process.execve() support

Bun now implements process.execve(execPath, args, env), matching the API added in Node.js v24. This POSIX syscall replaces the current process image in-place — it never returns on success.

// Replace the current process with a new one
process.execve("/usr/bin/echo", ["echo", "hello from execve"], {
  PATH: process.env.PATH,
});

// ^ If successful, this line is never reached

Key details:

  • stdio is inherited — file descriptors 0/1/2 are preserved across the exec boundary, while all other descriptors are marked close-on-exec to prevent leaks.
  • Signal mask is reset before calling execve(2).
  • Throws ERR_WORKER_UNSUPPORTED_OPERATION when called from a worker thread.
  • Throws ERR_FEATURE_UNAVAILABLE_ON_PLATFORM on Windows.
  • Emits an ExperimentalWarning once per process, matching Node.js behavior.
  • If execve fails, the process prints the error to stderr and aborts (consistent with Node.js behavior, since process state has already been mutated).

Bun.Terminal on Windows via ConPTY

Bun.Terminal and Bun.spawn({ terminal }) now work on Windows, powered by the Windows ConPTY API (CreatePseudoConsole). Previously, Bun.Terminal was only available on macOS and Linux.

const terminal = new Bun.Terminal({
  cols: 80,
  rows: 24,
  onData(data) {
    process.stdout.write(data);
  },
});

const proc = Bun.spawn({
  cmd: ["cmd.exe", "/c", "echo", "hello from ConPTY"],
  terminal,
});

await proc.exited;
terminal.close();

Platform differences

The core behavior — child sees a TTY, write() reaches the child's stdin, child output reaches the data callback, resize() updates the child's window size — is the same on every platform. A few details differ on Windows:

  • No termios on Windows. inputFlags, outputFlags, localFlags, and controlFlags always read as 0 and setting them is a no-op.
  • No echo without a child process. On POSIX, the kernel line discipline echoes write() input back to the data callback even with no process attached. ConPTY has no line discipline, so input is buffered for the next reader.
  • ConPTY re-encodes output. The data callback receives semantically equivalent — but not byte-identical — escape sequences compared to what the child emitted. Colors and text are preserved; cursor-positioning sequences may be reordered or coalesced.

Thanks to @dylan-conway for the contribution!

using / await using no longer lowered when targeting Bun

Bun's underlying JavaScript engine (JavaScriptCore) natively supports the Explicit Resource Management proposal (using and await using). Starting in this release, Bun no longer transpiles these declarations into __using / __callDispose helper calls wrapped in try/catch/finally when the target is Bun.

This applies to:

  • bun run / bun <file>
  • Bun.Transpiler({ target: "bun" })
  • bun build --target=bun (including --compile and --bytecode)

Other targets (browser, node) continue to lower using as before.

Before:

// bun build --target=bun entry.js
var __using = (stack, value, async) => {
  /* ... */
};
var __callDispose = (stack, error, hasError) => {
  /* ... */
};
{
  let __stack = [];
  try {
    const x = __using(
      __stack,
      {
        [Symbol.dispose]() {
          /* ... */
        },
      },
      0,
    );
    console.log("hi");
  } catch (_catch) {
    var _err = _catch,
      _hasErr = 1;
  } finally {
    __callDispose(__stack, _err, _hasErr);
  }
}

After:

// bun build --target=bun entry.js
{
  using x = {
    [Symbol.dispose]() {
      /* ... */
    },
  };
  console.log("hi");
}

This also fixes a bug where using inside a CommonJS module (.cjs) would inject an ESM import … from "bun:wrap" inside the CommonJS function wrapper, causing an Expected CommonJS module to have a function wrapper error instead of the expected TypeError for non-disposable values.

SIGHUP and SIGBREAK signal handling on Windows

process.on('SIGHUP', …) and process.on('SIGBREAK', …) now correctly receive Windows console-control events, matching Node.js behavior:

Console eventSignal
CTRL_CLOSE_EVENT (closing the console window)SIGHUP
CTRL_BREAK_EVENT (Ctrl+Break)SIGBREAK

Previously, these signal names were missing from Bun's Windows signal map, so registering a listener was treated as a plain EventEmitter event — no uv_signal_t was created, and the default handler would terminate the process immediately.

// Gracefully handle console window close on Windows
process.on("SIGHUP", () => {
  cleanup();
  process.exit();
});

// Handle Ctrl+Break
process.on("SIGBREAK", () => {
  console.log("Ctrl+Break received");
  process.exit();
});

Thanks to @ig-ant for the contribution!

WebSocket perMessageDeflate: false now respected in upgrade requests

Previously, setting perMessageDeflate: false when creating a WebSocket connection was silently ignored — Bun always sent the Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits header in the upgrade request. This broke deployments where gateways or proxies reject upgrade requests that advertise unwanted extensions.

Now, passing perMessageDeflate: false correctly suppresses the extension header, matching the behavior of Node.js and the ws package.

const WebSocket = require("ws");

// Extension header is now correctly omitted
const ws = new WebSocket("ws://localhost:3000", {
  perMessageDeflate: false,
});

// Also works with globalThis.WebSocket
const ws2 = new WebSocket("ws://localhost:3000", {
  perMessageDeflate: false,
});

Additionally, if the server responds with a Sec-WebSocket-Extensions header when the client did not offer any extensions, the handshake is now correctly failed per RFC 6455 §9.1 — matching upstream ws behavior.

FreeBSD and Android Support

Bun now has 1st-party native builds for FreeBSD and Android.

Reduced memory usage for MongoDB & Mongoose

All TLS-using APIs in Bun — Bun.connect, Bun.SQL (Postgres & MySQL), Valkey, upgradeTLS, new WebSocket(), and node:tls — now share a single native SSL_CTX cache per VM. Connections with identical TLS configurations reuse the same SSL_CTX instead of allocating a fresh one (~50 KB of BoringSSL state + cert/key parsing) per connection.

This is especially impactful for database connection pools: a Postgres or MySQL pool with sslmode=require and N connections previously created N separate SSL_CTX objects. Now it creates one.

import { SQL } from "bun";

// All connections in this pool now share a single SSL_CTX
const db = new SQL("postgres://user:pass@host/db?sslmode=require");

// These also share the same cached SSL_CTX since the config is identical
const conn1 = await Bun.connect({
  hostname: "example.com",
  port: 443,
  tls: true,
  socket: {
    /* ... */
  },
});
const conn2 = await Bun.connect({
  hostname: "example.com",
  port: 443,
  tls: true,
  socket: {
    /* ... */
  },
});

The cache is keyed by a SHA-256 digest of the TLS configuration fields. servername and ALPNProtocols are excluded from the digest (they're per-connection, not per-context), so Bun.connect({ tls: { servername: "x" } }) correctly shares the default SSL_CTX with tls: true.

This was the root cause behind long-standing memory leak reports when using tls.connect(), Bun.connect({tls}), socket.upgradeTLS(), and any library built on top of them (MongoDB, Mongoose, mysql2, etc.). Under connection churn — Postgres pools, Redis, fetch keepalive expiry, MongoDB heartbeats — RSS would grow rapidly until the garbage collector eventually frees the context. Now it avoids allocating unnecessary duplicate contexts.

// Before: each iteration allocated a fresh SSL_CTX (~50 KB+)
for (let i = 0; i < 1000; i++) {
  const sock = tls.connect({
    host: "localhost",
    port: 5432,
    rejectUnauthorized: false,
  });
  sock.on("secureConnect", () => sock.destroy());
}
// RSS after: ~1 GB

// After: one shared SSL_CTX, RSS stays flat
// RSS after: ~168 MB

Upgraded JavaScriptCore engine

Bun's underlying JavaScript engine (JavaScriptCore) has been upgraded with 565 upstream commits, bringing numerous performance improvements, bug fixes, and new capabilities.

JavaScript performance & correctness

  • Faster async functions — When an async function returns a value without any await, the returned promise is now optimized to avoid unnecessary overhead.
  • Faster Array.prototype.shift — New fastShift implementation for arrays.
  • Faster JSON.parse for short stringsJSString cells are now cached for short string values returned by JSON.parse.
  • Faster String.prototype.startsWith/endsWith — Single-character checks now avoid resolving rope strings.
  • Faster Intl.NumberFormat creation — Optimized construction and improved external memory reporting for Intl.NumberFormat and Intl.PluralRules.
  • Faster Array.prototype.indexOf on NodeList — New fast path added.

Bug fixes from upstream

  • Fixed Promise.prototype.finally throwing in SpeciesConstructor before calling then, matching spec behavior.
  • Fixed Object.defineProperties Proxy trap ordering to match the spec.
  • Fixed megamorphic inline cache property ownership check.
  • Fixed TypedArray toSorted/toReversed/with to correctly snapshot the span.
  • Fixed Intl.Segmenter isWordLike off-by-one error.
  • Fixed Intl.Locale to canonicalize before overriding language.
  • Fixed Intl.DateTimeFormat to preserve original legacy [[TimeZone]].
  • Fixed several RegExp JIT issues
  • Fixed JIT compiler issues with hole-handling when rematerializing sunk double arrays and escaping MultiGetByOffset constants not convertible to double.

WebAssembly

  • Relaxed SIMD support — Implements the relaxed SIMD proposal, adding instructions like f32x4.relaxed_madd, i8x16.relaxed_swizzle, and more.
  • Memory64 improvements — Atomics, bulk memory operations, and grow/size in the OMG tier now support 64-bit memory.
  • Fixed integer division/remainder with INT_MIN/-1 in BBQ JIT.
  • Fixed floating-point min/max negative-zero handling in BBQ JIT.
  • Fixed crash on wide-arithmetic instructions.

Thanks to @sosukesuzuki for the upgrade!

Previously, bun publish included README.md in the published tarball but didn't populate the readme or readmeFilename fields in the JSON body sent to the npm registry. This meant packages published with Bun showed an empty README when queried via the registry API (e.g. npm view <pkg> readme), even though the tarball contained one.

Now, bun publish matches npm publish behavior by automatically finding the first README or README.* file (case-insensitive) in your workspace and including its contents in the version metadata sent to the registry. This works for both workspace publishes (bun publish) and tarball publishes (bun publish path.tgz). A readme field already present in package.json takes precedence.

# Before: registry API returned empty readme
npm view @my-scope/my-package readme  # ""

# After: readme contents are properly sent
npm view @my-scope/my-package readme  # "# my-package\n\nA great package..."

Updated SQLite to 3.53.0

Bun's built-in SQLite has been updated from 3.51.2 to 3.53.0.

Notable changes in SQLite 3.53.0 include:

  • New SQLITE_DBCONFIG_FP_DIGITS option for controlling floating-point precision when converting doubles to text
  • New SQLITE_LIMIT_PARSER_DEPTH limit for controlling the maximum depth of the SQL parser stack
  • New SQLITE_PREPARE_FROM_DDL flag for enforcing schema-level security constraints during statement preparation

Cross-language LTO for Zig ↔ C++ on Linux

Bun's binary is now built with full link-time optimization (LTO) across the Zig and C++ boundaries on Linux. Previously, the Zig-compiled object file was a native ELF object that the linker could link but not optimize across — meaning hundreds of small cross-language function calls (Zig → C++, C → Zig, allocator calls) were never inlined.

By emitting the Zig object as LLVM bitcode and participating in the same LTO link pass as the C/C++ side, LLVM can now inline and optimize across language boundaries:

BoundaryFunctions declaredFunctions eliminated by inlining%
Zig export fn → C++33614242%
C us_* (µSockets) ← Zig1157969%
C++ uws_* (µWebSockets) ← Zig1087670%
mi_free (mimalloc)all100%

Measured impact (linux-x64):

BenchmarkBeforeAfterImprovement
Bun.escapeHTML183.2 ns171.3 ns6.5% faster
TextDecoder.decode106.8 ns104.0 ns2.6% faster
HTTP throughput (oha -n 1M -c 50)~193,800 req/s~200,600 req/s3.5% faster

This is a broad improvement — any hot path that crosses the Zig/C++ boundary benefits, including HTTP serving, text encoding/decoding, and HTML escaping.

Faster ESM module loading

Fixed an internal parser oversight where an ~8KB struct was being copied by value on every AST node allocation, causing unnecessary memcpy overhead during transpilation. Passing it by pointer instead eliminates the redundant copies, reducing _platform_memmove overhead from 7.5% to 2.9% of self time in profiling.

On a benchmark loading 500 ESM files, this results in approximately 12% faster module loading (~140ms → ~123ms).

Thanks to @sosukesuzuki for the contribution!

Reduced GC overhead for built-in objects

Bun's incremental garbage collector previously re-scanned ~63 types of built-in objects (Request, Response, Subprocess, Stats, Dirent, Timeout, and more) after every mutator yield during incremental GC — even though these objects already use write barriers that guarantee correctness without the extra pass.

This redundant work has been removed. Only visitChildren is called now for codegen'd classes, eliminating the overhead of re-walking every live instance of these common types during incremental GC cycles. Hand-written types that genuinely require output constraints (like EventTarget, AbortSignal, MessagePort, etc.) are unchanged.

This should reduce GC pause times, especially in applications with many live built-in objects.

Smaller binary size

Bun gets smaller on Windows and Linux. macOS binary size hasn't changed much.

Target
Linux aarch64-9.07 MB
Linux aarch64-musl-7.63 MB
Linux x64-8.58 MB
Linux x64-baseline-8.64 MB
Linux x64-musl-6.61 MB
Linux x64-musl-baseline-6.75 MB
Windows aarch64-18.42 MB
Windows x64-17.66 MB
Windows x64-baseline-17.67 MB

tls.getCACertificates('system') now works without --use-system-ca

Previously, tls.getCACertificates('system') returned an empty array [] unless --use-system-ca or NODE_USE_SYSTEM_CA=1 was explicitly set. Node.js returns the OS trust store unconditionally for 'system' — the flag only affects 'default'. Bun now matches this behavior.

System certificates are lazy-loaded on first demand, so there's no startup cost unless 'system' is actually queried or --use-system-ca is set.

import tls from "node:tls";

// Previously returned [] without --use-system-ca, now returns system CA certs
const systemCerts = tls.getCACertificates("system");
console.log(systemCerts.length); // > 0 on Linux/Windows

This also fixes a data race that could cause segfaults or truncated certificate lists when multiple threads (e.g. Workers) accessed root certificates concurrently during initialization.

Thanks to @cirospaciari for the contribution!

tls.getCACertificates('system') no longer stalls on managed Macs

On macOS, tls.getCACertificates('system') previously evaluated every keychain certificate using SecTrustEvaluateWithError with an SSL policy, causing trustd to attempt OCSP/CRL/AIA network fetches for each cert. On managed Macs running a NetworkExtension content filter, this turned a local lookup into ~10 seconds of wall-clock time as hundreds of outbound flows were individually signed and policy-denied by the filter.

This release rewrites the macOS keychain enumeration to match how Node.js and Chromium handle it:

  • Removed kSecMatchTrustedOnly from the keychain query — this flag forced a redundant network-revocation-enabled evaluation of every cert before per-cert filtering even started.
  • Replaced the trust-settings stub with a full parser — the previous implementation always returned UNSPECIFIED, causing every cert to fall through to the expensive SecTrustEvaluateWithError path. The new parser (ported from Node's IsTrustSettingsTrustedForPolicy) resolves certs via cheap local XPC lookups.
  • Deferred SecTrustEvaluateWithError as a last resort — only certs with no decisive trust settings in any domain reach it, and when they do, it now uses SecPolicyCreateBasicX509 + SecTrustSetNetworkFetchAllowed(false) to avoid network access entirely.

The result is functionally equivalent — OpenSSL still enforces EKU and basic-constraint checks at handshake time — but enumeration no longer triggers any network I/O.

When using --use-system-ca or NODE_USE_SYSTEM_CA=1 on Windows, Bun now reads from the same certificate stores as Node.js, fixing unable to get local issuer certificate errors commonly seen in enterprise and intranet environments.

Previously, Bun only enumerated the ROOT store for CURRENT_USER and LOCAL_MACHINE. This meant that when a server only sent a leaf certificate without its intermediates (very common on intranets with corporate proxies or self-signed certificates), Bun couldn't build the certificate chain — even though Windows had the intermediates cached in its CA store.

Bun now mirrors Node.js's ReadWindowsCertificates behavior:

BeforeAfter
Store namesROOTROOT, CA, TrustedPeople
LocationsLOCAL_MACHINE, CURRENT_USER+ GROUP_POLICY, ENTERPRISE variants
CERT_STORE_OPEN_EXISTING_FLAG✓ (don't create missing stores)
EKU server-auth filter✓ (skip certs restricted to e.g. code-signing only)

This brings --use-system-ca on Windows to parity with Node.js, making it significantly more reliable for enterprise environments with custom certificate authorities and proxy servers.

Event loop refactor

Large parts of the event loop have been refactored to improve reliability and simplify memory management.

Along the way, this fixed:

  • DuplexUpgradeContext was never freed (full leak per tls.connect({socket: duplex}))
  • UpgradedDuplex.onEndCallback was in