Bun v1.3.11: Cron Jobs, ANSI Slicing, Markdown Rendering, and What Changed Under the Hood

A production deep dive into Bun v1.3.11 — covering Bun.cron for OS-level scheduling, Bun.sliceAnsi for terminal-aware string slicing, enriched Markdown rendering callbacks, path-ignore patterns for test discovery, and the dgram UDP fixes that unblocked real-world libraries on macOS.

Bun v1.3.11 shipped on March 18, 2026, and it is not a release you skip. The headline items are Bun.cron — a built-in OS-level cron scheduler that works the same way on Linux, macOS, and Windows — and Bun.sliceAnsi, a native replacement for two of the most depended-upon terminal string packages in the npm ecosystem. Below those is a deep list of Node.js compatibility fixes, dgram UDP correctness fixes on macOS that unblocked DHT libraries, and richer metadata for Bun.markdown.render() callbacks.

This article walks through every significant change with production-grade code and a full technical explanation of what each piece does and why it matters.

Bun.cron: OS-Level Cron Scheduling

Before this release, scheduling recurring background work in a Bun application meant one of three things: reaching for a Node.js library like node-cron or croner, shelling out to crontab yourself, or running a separate process. Bun.cron eliminates all three. It registers jobs directly with the operating system scheduler — crontab on Linux, launchd on macOS, Task Scheduler on Windows — using a single, cross-platform API.

Registering a Job

import path from "path";

const WORKER_PATH = path.resolve(import.meta.dir, "workers", "report.ts");

async function registerScheduledJobs(): Promise<void> {
  await Bun.cron(WORKER_PATH, "30 2 * * MON", "weekly-report");
  await Bun.cron(WORKER_PATH, "0 * * * *", "hourly-health-check");
  await Bun.cron(WORKER_PATH, "@daily", "daily-cleanup");
}

registerScheduledJobs().catch((err) => {
  console.error("Failed to register cron jobs:", err);
  process.exit(1);
});

Bun.cron takes three arguments: the path to the script to execute, the cron expression defining the schedule, and a unique job title. The title is the job's identity in the OS scheduler. Calling Bun.cron a second time with the same title overwrites the existing job in place, which means you can safely re-run your startup script after a deploy without creating duplicate entries.

The path must be absolute or resolvable from the current working directory. Using import.meta.dir anchors the path to the source file's location, which is the right pattern in production where the process may run from a different working directory than your source tree.

The function is async and returns a Promise that resolves when the OS scheduler confirms registration. On Linux this means crontab has been updated. On macOS it means a launchd plist has been written and loaded. On Windows it means a Task Scheduler entry exists. Errors surface as rejections — if crontab is unavailable or the user lacks scheduling permissions, the Promise rejects with an actionable error message rather than silently failing.

The Worker Script

import { Pool } from "pg";
import { createLogger } from "winston";

const logger = createLogger({ level: "info" });

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  idleTimeoutMillis: 10_000,
});

interface ScheduledController {
  cron: string;
  scheduledTime: number;
}

async function runWeeklyReport(scheduledTime: number): Promise<void> {
  const week = new Date(scheduledTime);

  const result = await pool.query<{ count: number; revenue: number }>(
    `SELECT
       COUNT(*)::int AS count,
       COALESCE(SUM(amount), 0)::numeric AS revenue
     FROM orders
     WHERE created_at >= date_trunc('week', $1::timestamptz) - INTERVAL '7 days'
       AND created_at <  date_trunc('week', $1::timestamptz)
       AND status = 'completed'`,
    [week],
  );

  logger.info({
    week: week.toISOString(),
    orders: result.rows[0].count,
    revenue: result.rows[0].revenue,
  }, "Weekly report generated");
}

async function runHourlyHealthCheck(_scheduledTime: number): Promise<void> {
  const client = await pool.connect();
  try {
    await client.query("SELECT 1");
    logger.info("Health check passed");
  } finally {
    client.release();
  }
}

async function runDailyCleanup(_scheduledTime: number): Promise<void> {
  await pool.query(
    `DELETE FROM sessions
     WHERE expires_at < NOW() - INTERVAL '1 day'`,
  );
  logger.info("Expired sessions cleaned");
}

const handlers: Record<string, (scheduledTime: number) => Promise<void>> = {
  "30 2 * * 1": runWeeklyReport,
  "0 * * * *": runHourlyHealthCheck,
  "@daily": runDailyCleanup,
};

export default {
  async scheduled(controller: ScheduledController): Promise<void> {
    const handler = handlers[controller.cron];

    if (!handler) {
      logger.error({ cron: controller.cron }, "No handler registered for cron expression");
      return;
    }

    try {
      await handler(controller.scheduledTime);
    } catch (err) {
      logger.error({ cron: controller.cron, err }, "Scheduled job failed");
      throw err;
    }
  },
};

When the OS fires a scheduled job, Bun imports the script and calls default.scheduled(). This follows the Cloudflare Workers Cron Triggers API — the same interface used in edge workers — so scripts written for Cloudflare Workers work without modification. controller.cron contains the normalized cron expression that triggered the invocation (note that named weekdays are normalized to numbers: MON becomes 1). controller.scheduledTime is the Unix timestamp in milliseconds for the scheduled fire time, not the wall-clock time the script actually started. These differ when the scheduler fires late.

The handlers map routes each expression to the right function. Throwing inside scheduled propagates the error to the OS scheduler, which records it in the platform's log — journalctl on Linux, the log files in /tmp on macOS, Event Viewer on Windows. Swallowing errors with an empty catch makes production debugging very difficult. Re-throwing after logging is the right pattern.

The worker holds its own database pool rather than sharing one with the main server process, because the OS spawns the worker as a separate Bun process. Each job invocation is an independent process. This is not a design flaw — it means a hung job cannot block the scheduler from firing the next run, and a crashing job does not take down the API server.

Parsing Cron Expressions

interface CronSchedule {
  expression: string;
  label: string;
  nextRuns: Date[];
}

function previewCronSchedule(
  expression: string,
  label: string,
  count = 5,
): CronSchedule {
  const nextRuns: Date[] = [];
  let cursor: Date | null = new Date();

  for (let i = 0; i < count; i++) {
    const next = Bun.cron.parse(expression, cursor);
    if (next === null) break;
    nextRuns.push(next);
    cursor = new Date(next.getTime() + 1000);
  }

  return { expression, label, nextRuns };
}

function validateCronExpression(expression: string): boolean {
  return Bun.cron.parse(expression) !== null;
}

const schedules = [
  previewCronSchedule("30 2 * * MON", "weekly-report"),
  previewCronSchedule("0 9 * * MON-FRI", "weekday-digest"),
  previewCronSchedule("@yearly", "annual-audit"),
];

for (const schedule of schedules) {
  console.log(`\n${schedule.label} (${schedule.expression})`);
  for (const date of schedule.nextRuns) {
    console.log(" ", date.toLocaleString("en-US", { timeZone: "UTC" }));
  }
}

Bun.cron.parse takes a cron expression and an optional Date representing the start of the search window. It returns the next Date after that window when the expression would fire, or null if no match exists within approximately four years. A null return catches logically impossible expressions like 0 0 30 2 * (February 30th) before they ever reach the OS scheduler.

Chaining calls by advancing cursor by one second after each result produces a sequence of upcoming fire times. This is useful for displaying a preview to operators before enabling a job, or for writing unit tests that assert your cron expression matches the human-readable description in your documentation.

Bun.cron.parse is a pure function — it makes no system calls and involves no I/O. Running it at application startup to validate all configured expressions costs microseconds and catches misconfigured schedules before they silently never fire.

Removing a Job

async function deregisterJob(title: string): Promise<void> {
  try {
    await Bun.cron.remove(title);
    console.log(`Removed job: ${title}`);
  } catch (err) {
    console.error(`Failed to remove job "${title}":`, err);
    throw err;
  }
}

async function rotateCronJob(
  scriptPath: string,
  newExpression: string,
  title: string,
): Promise<void> {
  await Bun.cron(scriptPath, newExpression, title);
}

Bun.cron.remove deletes the scheduled job from the OS by title. On Linux it removes the matching line from crontab. On macOS it unloads and deletes the launchd plist. On Windows it removes the Task Scheduler entry.

rotateCronJob shows the correct way to update an expression for an existing job: re-register with the same title. Because the OS scheduler overwrites in place, there is no window between the remove and the re-add where the job is absent. Calling Bun.cron.remove followed by Bun.cron would introduce a gap; calling Bun.cron alone does not.

Bun.sliceAnsi: Terminal-Aware String Slicing

Terminal applications and CLI tools routinely need to truncate strings to fit a column width, or extract a slice from a larger string, while keeping ANSI color codes and emoji intact. Two npm packages — slice-ansi and cli-truncate — handle this use case and together account for tens of millions of weekly downloads. Bun.sliceAnsi replaces both with a single native built-in.

CLI Output Formatting

import { createServer, IncomingMessage, ServerResponse } from "http";

interface LogEntry {
  timestamp: Date;
  level: "INFO" | "WARN" | "ERROR";
  requestId: string;
  message: string;
  durationMs: number;
}

const LEVEL_COLORS: Record<LogEntry["level"], string> = {
  INFO: "\x1b[32m",
  WARN: "\x1b[33m",
  ERROR: "\x1b[31m",
};

const RESET = "\x1b[0m";
const DIM = "\x1b[2m";
const BOLD = "\x1b[1m";

function formatLogLine(entry: LogEntry, terminalWidth: number): string {
  const levelColor = LEVEL_COLORS[entry.level];
  const coloredLevel = `${levelColor}${BOLD}${entry.level.padEnd(5)}${RESET}`;

  const ts = entry.timestamp.toISOString().slice(11, 23);
  const coloredTs = `${DIM}${ts}${RESET}`;

  const reqId = `${DIM}[${entry.requestId.slice(0, 8)}]${RESET}`;
  const duration = `${DIM}${entry.durationMs}ms${RESET}`;

  const prefix = `${coloredTs} ${coloredLevel} ${reqId} `;
  const suffix = ` ${duration}`;

  const prefixWidth = ts.length + 1 + entry.level.length + 1 + 1 + 8 + 1 + 1;
  const suffixWidth = entry.durationMs.toString().length + 2 + 1;
  const messageWidth = Math.max(terminalWidth - prefixWidth - suffixWidth, 10);

  const truncatedMessage = Bun.sliceAnsi(entry.message, 0, messageWidth, "…");

  return `${prefix}${truncatedMessage}${suffix}`;
}

function formatTable(
  rows: Array<{ label: string; value: string }>,
  maxLabelWidth: number,
  maxValueWidth: number,
): string {
  return rows
    .map(({ label, value }) => {
      const truncatedLabel = Bun.sliceAnsi(label, 0, maxLabelWidth, "…");
      const truncatedValue = Bun.sliceAnsi(value, 0, maxValueWidth, "…");
      return `  ${truncatedLabel.padEnd(maxLabelWidth)}  ${truncatedValue}`;
    })
    .join("\n");
}

function createRequestLogger(terminalWidth: number) {
  return function logRequest(entry: LogEntry): void {
    const line = formatLogLine(entry, terminalWidth);
    process.stdout.write(line + "\n");
  };
}

const logger = createRequestLogger(process.stdout.columns ?? 120);

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  const start = performance.now();
  const requestId = crypto.randomUUID();

  res.on("finish", () => {
    const level = res.statusCode >= 500 ? "ERROR" : res.statusCode >= 400 ? "WARN" : "INFO";
    logger({
      timestamp: new Date(),
      level,
      requestId,
      message: `${req.method} ${req.url} ${res.statusCode}`,
      durationMs: Math.round(performance.now() - start),
    });
  });

  res.writeHead(200);
  res.end("ok");
});

server.listen(3000);

Bun.sliceAnsi(str, start, end, ellipsis) slices str by terminal column width from start to end. When an ellipsis argument is provided, Bun.sliceAnsi operates in truncation mode: if the string exceeds end columns, it truncates to end - ellipsis_width columns and appends the ellipsis. Without the ellipsis argument it operates in plain slice mode, returning exactly the columns between start and end.

The function measures width in terminal columns, not character code units. A single emoji like 🎉 occupies two terminal columns. A zero-width joiner sequence like 👨‍👩‍👧‍👦 is a single grapheme cluster that also occupies two columns. ASCII characters occupy one column each. Bun.sliceAnsi handles all of these correctly, whereas a naïve str.slice(0, n) splits multi-byte sequences mid-character and produces garbled output or broken ANSI codes.

The terminal width calculation in formatLogLine separates the fixed-width structural elements (timestamp, level, request ID, duration) from the variable-width message. The message width is what remains after subtracting the structural widths. This pattern keeps log lines within the terminal viewport regardless of message length, while preserving ANSI color codes in both the truncated message and the surrounding elements.

The ellipsis is emitted inside active SGR styles. If the message was colored red before truncation, the ellipsis inherits that red color. After the ellipsis, styles are reset correctly. This means a truncated error message does not leave a dangling escape sequence that colors everything that follows it on the terminal.

Slicing ANSI Strings Safely

interface ProgressBar {
  label: string;
  current: number;
  total: number;
  width: number;
}

function renderProgressBar(bar: ProgressBar): string {
  const GREEN = "\x1b[32m";
  const GRAY = "\x1b[90m";
  const RESET = "\x1b[0m";
  const BOLD = "\x1b[1m";

  const ratio = Math.min(bar.current / bar.total, 1);
  const filledCount = Math.round(bar.width * ratio);
  const emptyCount = bar.width - filledCount;

  const filled = `${GREEN}${"█".repeat(filledCount)}${RESET}`;
  const empty = `${GRAY}${"░".repeat(emptyCount)}${RESET}`;
  const pct = `${BOLD}${Math.round(ratio * 100)}%${RESET}`;

  const fullBar = `${filled}${empty} ${pct}`;

  const labelWidth = 20;
  const truncatedLabel = Bun.sliceAnsi(bar.label, 0, labelWidth, "…");

  return `${truncatedLabel.padEnd(labelWidth)} ${fullBar}`;
}

function renderProgressTable(bars: ProgressBar[]): string {
  return bars.map(renderProgressBar).join("\n");
}

const jobs: ProgressBar[] = [
  { label: "Uploading assets to CDN", current: 450, total: 600, width: 30 },
  { label: "Running database migrations with very long description", current: 12, total: 15, width: 30 },
  { label: "Compiling TypeScript — 🎉 almost done", current: 599, total: 600, width: 30 },
  { label: "Sending notifications", current: 1000, total: 1000, width: 30 },
];

process.stdout.write(renderProgressTable(jobs) + "\n");

Bun.sliceAnsi handles the label truncation here, capping it at 20 columns regardless of what it contains — plain ASCII, multibyte UTF-8, ANSI hyperlinks, or any combination. The padEnd call then pads the visible width to exactly 20 columns so the progress bars align vertically. Without ANSI-aware slicing, padEnd would measure the string's byte length rather than its visible width, producing misaligned output whenever a label contains non-ASCII characters or escape sequences.

The negative-index variant works as well: Bun.sliceAnsi(str, -n, undefined, "…") extracts the last n columns, replacing the beginning with an ellipsis. This is useful for path truncation in file explorers and log viewers where the filename at the end of the path is more informative than the leading directories.

Bun.markdown.render(): Richer List Metadata

Bun.markdown is Bun's built-in CommonMark parser. Its render() method accepts a Markdown string and a renderer object whose callbacks are invoked for each node type. In v1.3.11, the listItem callback now receives index, depth, ordered, and start in its metadata, and the list callback receives depth. Previously, listItem only passed checked (and only for task list items), making it impossible to build custom list renderers without post-processing.

Documentation Generator with Custom List Rendering

interface ListItemMeta {
  index: number;
  depth: number;
  ordered: boolean;
  start?: number;
  checked?: boolean;
}

interface ListMeta {
  depth: number;
}

type Renderer = Parameters<typeof Bun.markdown.render>[1];

function buildNumberingMarker(
  depth: number,
  n: number,
  ordered: boolean,
): string {
  if (!ordered) {
    const bullets = ["•", "◦", "▸"];
    return bullets[Math.min(depth, bullets.length - 1)];
  }

  switch (depth) {
    case 0:
      return `${n}.`;
    case 1:
      return `${String.fromCharCode(96 + n)}.`;
    case 2: {
      const romanNumerals = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"];
      return `${romanNumerals[Math.min(n - 1, romanNumerals.length - 1)]}.`;
    }
    default:
      return `${n}.`;
  }
}

function buildHtmlRenderer(options: { wrapCodeBlocks?: boolean } = {}): Renderer {
  return {
    listItem(children: string, meta: ListItemMeta): string {
      const n = (meta.start ?? 1) + meta.index;
      const marker = buildNumberingMarker(meta.depth, n, meta.ordered);
      const indent = "  ".repeat(meta.depth);

      if (meta.checked !== undefined) {
        const checkbox = meta.checked
          ? '<input type="checkbox" checked disabled>'
          : '<input type="checkbox" disabled>';
        return `${indent}<li class="task-item">${checkbox} ${children.trim()}</li>\n`;
      }

      return `${indent}<li data-marker="${marker}" data-depth="${meta.depth}">${children.trim()}</li>\n`;
    },

    list(children: string, meta: ListMeta): string {
      const depthClass = `list-depth-${meta.depth}`;
      return `<ul class="${depthClass}">\n${children}</ul>\n`;
    },

    code(source: string, language?: string): string {
      if (!options.wrapCodeBlocks) {
        return `<pre><code class="language-${language ?? "text"}">${source}</code></pre>\n`;
      }
      return (
        `<div class="code-block" data-language="${language ?? "text"}">\n` +
        `  <pre><code>${source}</code></pre>\n` +
        `</div>\n`
      );
    },

    heading(children: string, level: number): string {
      const id = children
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .trim()
        .replace(/\s+/g, "-");
      return `<h${level} id="${id}">${children}</h${level}>\n`;
    },
  };
}

const input = `
# API Reference

## Endpoints

1. Authentication
   1. POST /auth/login
      1. Returns JWT
      2. Accepts email and password
   2. POST /auth/refresh
2. Users
   a. GET /users
   b. POST /users

## Features

- Open source
  - MIT licensed
    - Free for commercial use
  - Community maintained
- Fast

## Checklist

- [x] TypeScript support
- [ ] GraphQL adapter
- [x] REST API
`;

const renderer = buildHtmlRenderer({ wrapCodeBlocks: true });
const html = Bun.markdown.render(input, renderer);
console.log(html);

Each listItem callback invocation now receives its full structural context. meta.index is zero-based within the parent list, so the first item in any list is always 0 regardless of nesting. meta.start holds the user-specified start number from the Markdown source (1. First has start: 1; 3. Third has start: 3). The actual display number is therefore (meta.start ?? 1) + meta.index.

meta.depth is the nesting level of the parent list, with 0 meaning top-level. This drives the buildNumberingMarker function, which applies Arabic numerals at depth 0, lowercase letters at depth 1, and Roman numerals at depth 2. Previously this information was unavailable without parsing the rendered HTML or tracking state externally.

meta.ordered distinguishes ordered lists (1. Item) from unordered lists (- Item), letting the same callback render both with different marker styles and CSS classes.

The checked property is only present for task list items (- [x] or - [ ]). For all other items it is undefined. As of this release, the meta object is always passed to listItem — in prior versions it was only passed for task list items. This makes the callback signature consistent and allows type-safe handling without checking whether meta exists.

The meta objects use cached JSC (JavaScriptCore) structures internally, adding approximately 0.7 nanoseconds per object creation. For a document with 500 list items this is 350 nanoseconds of overhead — negligible for any real workload.

Generating Structured Documentation from Markdown

import { writeFile } from "fs/promises";
import path from "path";
import { glob } from "glob";

interface TableOfContentsEntry {
  title: string;
  id: string;
  level: number;
}

interface RenderResult {
  html: string;
  toc: TableOfContentsEntry[];
}

function renderWithToc(markdown: string): RenderResult {
  const toc: TableOfContentsEntry[] = [];

  const html = Bun.markdown.render(markdown, {
    heading(children: string, level: number): string {
      const id = children
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .trim()
        .replace(/\s+/g, "-");

      if (level <= 3) {
        toc.push({ title: children, id, level });
      }

      return `<h${level} id="${id}">${children}</h${level}>\n`;
    },

    listItem(children: string, meta: ListItemMeta): string {
      const indent = "  ".repeat(meta.depth);
      const n = (meta.start ?? 1) + meta.index;

      if (meta.checked !== undefined) {
        const cls = meta.checked ? "done" : "todo";
        return `${indent}<li class="task ${cls}">${children.trim()}</li>\n`;
      }

      if (meta.ordered) {
        return `${indent}<li value="${n}">${children.trim()}</li>\n`;
      }

      return `${indent}<li>${children.trim()}</li>\n`;
    },

    list(children: string, meta: ListMeta): string {
      return `<ul class="depth-${meta.depth}">\n${children}</ul>\n`;
    },
  });

  return { html, toc };
}

function buildTocHtml(entries: TableOfContentsEntry[]): string {
  if (entries.length === 0) return "";

  const items = entries
    .map(({ title, id, level }) => {
      const indent = "  ".repeat(level - 1);
      return `${indent}<li><a href="#${id}">${title}</a></li>`;
    })
    .join("\n");

  return `<nav class="toc">\n<ul>\n${items}\n</ul>\n</nav>\n`;
}

async function processDocumentationFiles(
  docsDir: string,
  outputDir: string,
): Promise<void> {
  const files = await glob("**/*.md", { cwd: docsDir });

  await Promise.all(
    files.map(async (file) => {
      const inputPath = path.join(docsDir, file);
      const outputPath = path.join(
        outputDir,
        file.replace(/\.md$/, ".html"),
      );

      const markdown = await Bun.file(inputPath).text();
      const { html, toc } = renderWithToc(markdown);
      const tocHtml = buildTocHtml(toc);

      const page = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Docs</title></head>
<body>
${tocHtml}
<main>
${html}
</main>
</body>
</html>`;

      await Bun.write(outputPath, page);
    }),
  );
}

await processDocumentationFiles("./docs", "./dist/docs");

renderWithToc uses the heading callback to intercept heading nodes as they render, extracting the text and computed ID into a table of contents array. Because callbacks fire in document order, toc is populated in the order headings appear, which is exactly the order they should appear in the navigation.

Bun.file(inputPath).text() is Bun's native async file read — faster than fs/promises.readFile and with less boilerplate. Bun.write(outputPath, page) writes the output with the same zero-dependency style. Promise.all processes all files concurrently, which on a 100-file documentation site reduces total processing time from the sum of all file I/O operations to roughly the time of the single slowest file.

The listItem callback's value="${n}" on ordered list items tells the browser the correct display number for items with a non-default start. Without value, a list starting at 3 would render as 1, 2, 3 in the browser even though the Markdown source says 3. Item.

--path-ignore-patterns for bun test

Large monorepos and projects with vendored code frequently have *.test.ts files in directories that should not participate in the main test suite. Previously bun test would discover and attempt to run these files. --path-ignore-patterns fixes this with glob-based exclusion baked directly into the test runner's file scanner.

Configuration via bunfig.toml

[test]
pathIgnorePatterns = [
  "vendor/**",
  "node_modules/**",
  "submodules/**",
  "fixtures/**",
  "**/test-data/**",
  "**/e2e/**",
  "scripts/**"
]
import { describe, test, expect, mock } from "bun:test";
import path from "path";

interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
  ssl: boolean;
}

function parseConnectionString(url: string): DatabaseConfig {
  const parsed = new URL(url);
  return {
    host: parsed.hostname,
    port: parseInt(parsed.port, 10) || 5432,
    database: parsed.pathname.slice(1),
    ssl: parsed.searchParams.get("ssl") === "true",
  };
}

describe("parseConnectionString", () => {
  test("parses a standard postgres URL", () => {
    const config = parseConnectionString(
      "postgres://localhost:5432/mydb?ssl=false",
    );
    expect(config).toEqual({
      host: "localhost",
      port: 5432,
      database: "mydb",
      ssl: false,
    });
  });

  test("defaults port to 5432 when omitted", () => {
    const config = parseConnectionString("postgres://db.example.com/mydb");
    expect(config.port).toBe(5432);
  });

  test("handles SSL flag", () => {
    const config = parseConnectionString(
      "postgres://localhost/mydb?ssl=true",
    );
    expect(config.ssl).toBe(true);
  });
});

The pathIgnorePatterns option in bunfig.toml accepts an array of glob patterns. When bun test scans for test files, it prunes any directory whose path matches one of these patterns before traversing into it. This is important: pruning happens at the directory level, not after discovering files inside it. A pattern like vendor/** prevents Bun from descending into vendor/ at all, not just from running files found inside it. For a vendor/ directory with 10,000 files, this avoids the entire filesystem traversal.

Patterns passed via --path-ignore-patterns on the command line take precedence over and completely replace the bunfig.toml value. They do not merge. If you have three patterns in bunfig.toml and pass one via the CLI, only the CLI pattern applies. This design keeps CI configuration explicit: a CI script that passes --path-ignore-patterns always produces the same file set regardless of what bunfig.toml contains.

dgram UDP Socket Fixes on macOS

Three bugs in Bun's dgram implementation caused UDP sockets to fail silently on macOS. These bugs affected real libraries in production: k-rpc, which is used by bittorrent-dht and by any application implementing the BitTorrent DHT protocol, depends on implicit bind-on-send behavior that was broken.

UDP Peer Discovery with reusePort and Implicit Binding

import dgram from "dgram";
import { EventEmitter } from "events";

interface PeerAddress {
  address: string;
  port: number;
}

interface DHTMessage {
  type: "ping" | "pong" | "find_node" | "announce";
  nodeId: string;
  payload?: unknown;
}

class UDPPeerDiscovery extends EventEmitter {
  private readonly socket: dgram.Socket;
  private bound = false;
  private readonly nodeId: string;

  constructor() {
    super();
    this.nodeId = crypto.randomUUID();

    this.socket = dgram.createSocket({
      type: "udp4",
      reusePort: true,
    });

    this.socket.on("message", (msg, rinfo) => {
      this.handleMessage(msg, { address: rinfo.address, port: rinfo.port });
    });

    this.socket.on("error", (err) => {
      this.emit("error", err);
    });
  }

  private handleMessage(msg: Buffer, sender: PeerAddress): void {
    let parsed: DHTMessage;

    try {
      parsed = JSON.parse(msg.toString("utf8")) as DHTMessage;
    } catch {
      return;
    }

    this.emit("message", parsed, sender);

    if (parsed.type === "ping") {
      const pong: DHTMessage = { type: "pong", nodeId: this.nodeId };
      this.send(pong, sender.port, sender.address);
    }
  }

  send(message: DHTMessage, port: number, address: string): void {
    const payload = Buffer.from(JSON.stringify(message));

    this.socket.send(payload, 0, payload.length, port, address, (err) => {
      if (err) {
        this.emit("error", err);
      }
    });
  }

  bind(port: number): Promise<void> {
    return new Promise((resolve, reject) => {
      this.socket.once("error", reject);
      this.socket.bind(port, () => {
        this.bound = true;
        this.socket.removeListener("error", reject);
        resolve();
      });
    });
  }

  ping(target: PeerAddress): void {
    const message: DHTMessage = { type: "ping", nodeId: this.nodeId };
    this.send(message, target.port, target.address);
  }

  getAddress(): dgram.AddressInfo {
    return this.socket.address();
  }

  close(): Promise<void> {
    return new Promise((resolve) => {
      this.socket.close(resolve);
    });
  }
}

const discovery = new UDPPeerDiscovery();

discovery.on("message", (msg: DHTMessage, sender: PeerAddress) => {
  console.log(`Received ${msg.type} from ${sender.address}:${sender.port}`);
});

discovery.on("error", (err: Error) => {
  console.error("UDP error:", err);
});

await discovery.bind(6881);

const loopbackTarget: PeerAddress = { address: "127.0.0.1", port: 6882 };
discovery.ping(loopbackTarget);

console.log("Listening on:", discovery.getAddress());

Three fixes land in v1.3.11 that make this code work correctly on macOS.

reusePort: true in dgram.createSocket options sets SO_REUSEPORT on the socket, allowing multiple processes to bind to the same UDP port. DHT implementations use this to run multiple node instances on the same machine. Before this fix, Bun's implementation of reusePort was guarded by a Linux-only code path — the option was silently ignored on macOS. Now SO_REUSEPORT is applied on any platform that supports it, matching Node.js behavior.

Implicit bind-on-send is the behavior where calling send() on an unbound socket automatically binds it to 0.0.0.0 on an ephemeral port before sending. The DHT library k-rpc relies on this — it calls send before explicitly calling bind. On macOS this failed because Bun's error handling used hardcoded Linux errno values (92 for ENOPROTOOPT) rather than the C macro ENOPROTOOPT, whose value on macOS is 42. The mismatch caused IPv4 fallback paths inside the bind logic to never trigger on macOS. The fix uses the macro rather than the raw number.

The socket file descriptor leak occurred when setsockopt for reusePort failed. The socket was opened, the option set failed, but the file descriptor was neither closed nor returned to the caller with a useful error. Production systems hit this when they tried to create many sockets and eventually exhausted their file descriptor limit. The fix closes the socket and propagates the errno correctly, producing a specific error message instead of a generic Failed to bind socket.

Notable Bug Fixes with Production Impact

TLS and Custom DNS Lookups (axios / got)

A bug in Bun's HTTP client broke TLS certificate verification when a custom lookup function was provided — this is the mechanism axios, got, and other HTTP libraries use to implement custom DNS resolvers. When the custom lookup resolved a hostname to an IP address, Bun used the resolved IP for the TLS Server Name Indication header and certificate validation. The server's certificate is issued to the hostname, not the IP address, so verification failed with ERR_TLS_CERT_ALTNAME_INVALID. The fix retains the original hostname for TLS and only uses the resolved address for the TCP connection.

PostgreSQL Batch Insert Limits

bun:sql previously panicked with an internal type error when a query exceeded PostgreSQL's 65,535 parameter limit — a limit you hit when batch-inserting, for example, 7,000 rows with 10 columns each (70,000 parameters). The panic produced no useful information. The fix converts this to a typed PostgresError with code ERR_POSTGRES_TOO_MANY_PARAMETERS and a hint to reduce batch size. Production code can now catch this error and split the batch.

import { sql } from "bun:sql";

interface Row {
  userId: number;
  action: string;
  metadata: Record<string, unknown>;
  createdAt: Date;
}

async function batchInsertAuditLog(rows: Row[]): Promise<void> {
  const COLUMNS = 4;
  const MAX_PG_PARAMS = 65_535;
  const MAX_BATCH = Math.floor(MAX_PG_PARAMS / COLUMNS);

  for (let i = 0; i < rows.length; i += MAX_BATCH) {
    const batch = rows.slice(i, i + MAX_BATCH);

    await sql`
      INSERT INTO audit_log (user_id, action, metadata, created_at)
      SELECT * FROM json_populate_recordset(
        null::audit_log,
        ${JSON.stringify(batch.map((r) => ({
          user_id: r.userId,
          action: r.action,
          metadata: r.metadata,
          created_at: r.createdAt,
        })))}
      )
    `;
  }
}

The MAX_BATCH calculation divides 65,535 by the number of columns, giving the maximum number of rows per insert. Inserting as a JSON array and using json_populate_recordset sidesteps the parameter limit entirely — the entire batch is one parameter regardless of row count. Both patterns are correct; the per-column calculation is the simpler migration when changing existing code.

HTTP/2 Window Stalling

node:http2 client streams stalled after receiving 65,535 bytes when setLocalWindowSize() was in use. The method updated Bun's internal window size counter but never sent a WINDOW_UPDATE frame to the server, so the server believed the client's receive window was exhausted and stopped sending data. This is the RFC 7540 flow control mechanism working as designed — the server is not buggy, the client was. The fix sends the WINDOW_UPDATE frame when setLocalWindowSize is called.

spawnSync Draining Microtask Queue

spawnSync is supposed to be a blocking call that runs a subprocess and returns when it exits. A bug caused it to drain the global microtask queue during the wait, which meant Promise callbacks — user JavaScript — could execute inside what callers expected to be a synchronous, non-interruptible block. Code that depended on nothing running between the spawnSync call and its return was silently broken. The fix prevents microtask queue drainage during synchronous subprocess execution.

process.off Signal Handler Bug

Calling process.off("SIGTERM", handler) when multiple listeners were registered for the same signal incorrectly uninstalled the OS-level signal handler, not just the JavaScript listener. Remaining JavaScript listeners registered with process.on("SIGTERM", ...) would stop receiving the signal. In production, this meant a graceful shutdown handler registered before a third-party library's handler could be silently deregistered when the library removed its own listener. The fix correctly tracks listener counts and only uninstalls the OS handler when the last listener is removed.

CRLF Injection in writeEarlyHints

ServerResponse.prototype.writeEarlyHints wrote header names and values to the raw socket without sanitization. A header value containing \r\n followed by a new header line would be written verbatim, allowing HTTP response splitting — an attacker who controlled a header value could inject arbitrary response headers into early hint responses. The fix validates header names and values before writing them, stripping carriage returns and line feeds.

4 MB Smaller on Linux x64

Bun's Linux x64 binary is 4 MB smaller in this release because the Bun team deleted CMake from the build dependencies and eliminated the compiled CMake artifacts from the distributed binary. CMake is a build system generator — it is needed to build Bun's C++ dependencies, but its output is not needed at runtime. Moving the build system configuration to Zig's native build system eliminates the need to vendor CMake binaries.

For production deployments where Bun is packaged in Docker images, 4 MB less in the binary directly reduces layer sizes. A typical Alpine-based image that previously included Bun at ~95 MB now includes it at ~91 MB. For base images that are pulled millions of times across a fleet, this reduction multiplies significantly in total network transfer. It also reduces cold start times in serverless environments where the binary must be loaded from storage before execution begins.

The Cnclusion

Bun v1.3.11 is one of the more substantial maintenance releases in recent history. The surface-level additions — Bun.cron and Bun.sliceAnsi — replace entire categories of npm dependencies with zero-configuration native implementations. The bug fixes reach into dgram, TLS, http2, crypto, the bundler's barrel optimization, and the test runner. Any production Bun application benefits from the upgrade, and several of the fixes address correctness issues rather than performance: the process.off signal handler bug and the spawnSync microtask drainage bug both affect correctness under conditions that are easy to hit in real services.