10LOC
Bunintermediate

Streaming a file response with Bun.file(), no extra deps

Published September 11, 2026

import { serve, file } from "bun";

const server = serve({
  async fetch(req) {
    const pathname = new URL(req.url).pathname;
    const asset = file(`./public${pathname}`);

    if (!(await asset.exists())) return new Response("Not found", { status: 404 });

    return new Response(asset);
  },
});

console.log(`Serving ./public at ${server.url}`);

What

Bun.file(path) returns a BunFile — a lazy, Blob-shaped reference to a file on disk. Passing it straight into new Response(asset) streams the file to the client as it's read, instead of loading it into memory first.

Why it matters

The obvious approach in Node — fs.readFile then res.end(buffer) — reads the entire file into memory before sending a single byte, which is wasteful for anything large and adds latency proportional to file size before the response even starts. Bun's BunFile is intentionally lazy: constructing it does no I/O at all, and new Response(file) hands the file straight to the platform's fastest available syscall for the job (sendfile/copy_file_range on Linux, fcopyfile/clonefile on macOS) instead of routing bytes through JavaScript. No streaming library, no manual ReadableStream plumbing.

How it works

  • file(...) builds a BunFile reference — no disk access happens yet, so building the path string is cheap even for a request that turns out to be a 404.
  • asset.exists() is the one async check we do need: BunFile can point at a path that doesn't exist, and .size/.type on a missing file just read as 0 and a default MIME type instead of throwing.
  • new Response(asset) is where the actual streaming happens. Bun sets Content-Type from the file's detected type and reads the file directly into the response body.

Gotchas

  • Bun.file() never throws for a missing path by itself — only .exists(), .text(), etc. surface the absence. Skipping the .exists() check means the client sees a confusing 200 with an empty body instead of a clean 404.
  • The MIME type is guessed from the file extension. For extensionless files or unusual types, pass a second argument — Bun.file(path, { type: "application/octet-stream" }) — to override it.
  • This snippet does no path sanitization; a request path is concatenated directly onto a disk path. Reaching into .. segments needs to be blocked before this is exposed to untrusted input — see the static-file-router post for a fallback pattern that guards against it.

Related

  • Bun.write(destination, data) is the inverse — write a string, Blob, or another BunFile to disk with the same fast-path syscalls.
  • Serving a static file as a routes value ("/logo.png": Bun.file("./logo.png")) skips the fetch handler entirely for paths known at server-start time.