| | |

Bun 10 ⚡ File System Operations and Streams with Bun’s Native APIs

⏱️ Estimated read: 10 min | |

Bun’s file system layer is not a wrapper around Node’s fs module. It is a set of native APIs—Bun.file() and Bun.write()—implemented in Zig and optimized for the runtime’s event loop. The BunFile object is a lazy reference: creating it does not read the file from disk. The data is loaded only when a method is called, and the method determines the format: text, JSON, bytes, or a stream . For operations that the native APIs do not cover—mkdir, readdir, stat—Bun provides a nearly complete implementation of node:fs, so existing code continues to work without modification .

Key point: Bun.file() creates a lazy reference. Bun.write() writes data to disk and returns the number of bytes written. The BunFile.stream() method returns a ReadableStream for processing large files without loading them into memory. The native APIs are the recommended path; node:fs is the fallback for operations not yet covered.


Why native file APIs exist

The lazy reference problem. Reading a file into memory is unnecessary when only the size or MIME type is needed. Bun.file() creates a BunFile object that represents the file but does not read it. The size and type properties are available immediately. The contents are loaded only when text(), json(), bytes(), or arrayBuffer() is called . This is the difference between asking “how large is this file?” and “give me the entire contents.”

The format problem. A file is just bytes. The application needs text, or JSON, or a Uint8Array. BunFile exposes a method for each format. The conversion happens at the point of use, not at the point of reference. This is the same pattern as the Fetch API’s Response object, and BunFile conforms to the Blob interface for compatibility .

The write problem. Bun.write() accepts a destination and a data source. The source can be a string, a Blob, an ArrayBuffer, a TypedArray, or even a Response object. Writing a fetched response to disk is a single call. Writing a file to another file is a single call. The method returns the number of bytes written, which is useful for verification .

The large-file problem. Reading a multi-gigabyte file into memory is not viable. The stream() method returns a ReadableStream that yields chunks. The chunks can be processed one at a time with for await, piped to a WritableStream, or passed to Bun.write() as the source. The memory footprint is bounded by the chunk size, not the file size .

The trade-off. The native APIs are optimized for Bun, but they are not a complete replacement for node:fs. Directory operations, file watching, and symbolic link handling still require node:fs. The native APIs cover the common cases—reading, writing, streaming, deleting—and the Node compatibility layer covers the rest .


a. Reading files with Bun.file()

The Bun.file() function accepts a path, a file descriptor, or a URL. The returned BunFile object has properties and methods for inspecting and reading the file.

// Create a lazy reference
const file = Bun.file("./data.json");

// Inspect without reading
console.log(file.size); // bytes, 0 if missing
console.log(file.type); // "application/json;charset=utf-8"

// Read in the desired format
const text = await file.text();
const json = await file.json();
const bytes = await file.bytes();
const buffer = await file.arrayBuffer();

The exists() method checks whether the file is present. A BunFile can reference a path that does not exist; no error is thrown until a read method is called .

const missing = Bun.file("./notreal.txt");
console.log(await missing.exists()); // false

The default MIME type is text/plain;charset=utf-8. It can be overridden with the second argument:

const config = Bun.file("./config", { type: "application/json" });
console.log(config.type); // "application/json;charset=utf-8"

The slice() method creates a view into the file without reading it. This is useful for reading headers or specific byte ranges from large files .

const header = Bun.file("./large.bin").slice(0, 1024);
const headerBytes = await header.arrayBuffer();

b. Writing files with Bun.write() and FileSink

Bun.write() writes data to a destination. The destination can be a path string, a BunFile, or a file descriptor. The data can be a string, a Blob, a TypedArray, an ArrayBuffer, or a Response .

// Write a string
const bytes = await Bun.write("./output.txt", "Hello, Bun");
console.log(bytes); // 10

// Write a fetched response
const response = await fetch("https://example.com");
await Bun.write("./page.html", response);

// Copy a file
await Bun.write("./copy.txt", Bun.file("./source.txt"));

// Write with options
await Bun.write("./secret.txt", data, {
  mode: 0o600,
  createPath: true,
});

The FileSink provides a writer for incremental writes. It is created with file.writer() and used with write() and end(). The highWaterMark option controls the buffer size before flushing to disk .

const writer = Bun.file("./log.txt").writer();
writer.write("Line 1\n");
writer.write("Line 2\n");
await writer.end();

The pipeTo() method connects a ReadableStream to a FileSink, which is the correct pattern for streaming a large file from one location to another without loading it into memory .

await Bun.file("./input.txt").stream().pipeTo(Bun.file("./output.txt").writer());

c. Streams: ReadableStream and the direct mode

The BunFile.stream() method returns a ReadableStream. The stream can be consumed with for await, a reader, or pipeTo(). Bun implements the standard Web Streams API, and also provides a “direct” mode that avoids the internal queue .

// Standard stream: chunks are enqueued
const stream = Bun.file("./large.txt").stream();
for await (const chunk of stream) {
  console.log(chunk); // Uint8Array
}

The direct ReadableStream is created with type: "direct". The controller uses write() instead of enqueue(). The destination handles the queuing, and backpressure is managed through the return value of write(): a number for success, a Promise<number> when the buffer is full, or 0 when the destination has disconnected .

const direct = new ReadableStream({
  type: "direct",
  async pull(controller) {
    for (const chunk of chunks) {
      await controller.write(chunk);
    }
    controller.close();
  },
});

The direct stream is useful for Bun.serve() responses, Bun.write(), and fetch() bodies. In these cases, the destination consumes the entire body, and the queueing that a standard ReadableStream performs is unnecessary overhead .

The highWaterMark option controls how many bytes are buffered before backpressure is applied. The default is 64 KiB. It is passed as the second argument to the ReadableStream constructor .


Complete Example Session

# ============================================
# PART 1: LAZY REFERENCE
# ============================================

const file = Bun.file("./data.json");
console.log(file.size); // does not read the file
console.log(file.type); // "application/json;charset=utf-8"
# ============================================
# PART 2: READ AS TEXT
# ============================================

const text = await file.text();
console.log(text);
# ============================================
# PART 3: READ AS JSON
# ============================================

const json = await file.json();
console.log(json.key);
# ============================================
# PART 4: WRITE A STRING
# ============================================

const bytes = await Bun.write("./output.txt", "Hello");
console.log(bytes); // 5
# ============================================
# PART 5: WRITE A RESPONSE
# ============================================

const response = await fetch("https://bun.sh");
await Bun.write("./bun.html", response);
# ============================================
# PART 6: COPY A FILE
# ============================================

await Bun.write("./copy.txt", Bun.file("./source.txt"));
# ============================================
# PART 7: STREAM A FILE
# ============================================

const stream = Bun.file("./large.log").stream();
for await (const chunk of stream) {
  console.log(chunk.length);
}
# ============================================
# PART 8: FILE SINK
# ============================================

const writer = Bun.file("./log.txt").writer();
writer.write("Entry 1\n");
writer.write("Entry 2\n");
await writer.end();
# ============================================
# PART 9: PIPE STREAM TO FILE
# ============================================

await Bun.file("./input.txt").stream().pipeTo(Bun.file("./output.txt").writer());

The nine parts trace the file I/O lifecycle: lazy reference, text read, JSON read, string write, response write, file copy, stream read, file sink, and stream piping.


Quick Reference

Bun.file() Methods

MethodReturns
.text()Promise<string>
.json()Promise<any>
.bytes()Promise<Uint8Array>
.arrayBuffer()Promise<ArrayBuffer>
.stream()ReadableStream
.exists()Promise<boolean>
.delete()Promise<void>
.writer()FileSink
.slice(start, end)Blob (lazy view)

Bun.write() Options

OptionPurpose
modeFile permissions (octal)
createPathCreate parent directories

Stream Types

TypeConstructorController
Standardnew ReadableStream({ start })enqueue()
Directnew ReadableStream({ type: "direct", pull })write()

Best Practices

✅ Do This:

const file = Bun.file("./data.json");
const json = await file.json();
<!-- Use the specific format method -->                 # ✅
await Bun.file("./input.txt").stream().pipeTo(Bun.file("./output.txt").writer());
<!-- Pipe streams for large files -->                  # ✅
const writer = Bun.file("./log.txt").writer();
await writer.end();
<!-- Always end the FileSink -->                      # ✅

❌ Don’t Do This:

# Read a file into memory just to check its size
<!-- Use file.size instead -->                         # ❌
# Use readFileSync for large files
<!-- Use stream() for bounded memory -->              # ❌
# Forget to await Bun.write()
<!-- The write is asynchronous -->                     # ❌

Common Pitfalls

PitfallWhy It HappensFix
size is 0 for missing fileLazy referenceawait file.exists()
Bun.write returns 0Invalid destinationVerify path and permissions
Stream not closedMissing end() or close()Call writer.end() or controller.close()
Memory grows with large fileUsing .text() instead of .stream()Use stream() and iterate
MIME type incorrectDefault is text/plainPass { type: "..." }

Real-World Examples

1. Read JSON

const config = await Bun.file("./config.json").json();

2. Write String

await Bun.write("./output.txt", "Hello");

3. Copy File

await Bun.write("./copy.txt", Bun.file("./source.txt"));

4. Write Response

await Bun.write("./page.html", await fetch("https://example.com"));

5. Stream Large File

for await (const chunk of Bun.file("./large.log").stream()) { ... }

6. File Sink

const writer = Bun.file("./log.txt").writer();
writer.write("Line\n");
await writer.end();

7. Pipe Stream

await Bun.file("./in.txt").stream().pipeTo(Bun.file("./out.txt").writer());

8. Check Existence

if (await Bun.file("./data.json").exists()) { ... }

9. Delete File

await Bun.file("./old.txt").delete();

10. Custom MIME Type

Bun.file("./data", { type: "application/json" });

Visual

BunFile Lifecycle

┌──────────────────────────────────────────────┐
│  Bun.file("./data.json")                     │
│  → Lazy reference created                    │
│  → No disk read                              │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  .size / .type                               │
│  → Metadata read (minimal I/O)               │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  .text() / .json() / .bytes()                │
│  → Full file read into memory                │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  .stream()                                   │
│  → Chunked read (bounded memory)             │
└──────────────────────────────────────────────┘

Stream Piping

┌──────────────────────────────────────────────┐
│  Bun.file("./input.txt").stream()            │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  ReadableStream                              │
│  → Chunks yielded one at a time              │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  .pipeTo(Bun.file("./output.txt").writer())  │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
┌──────────────────────────────────────────────┐
│  FileSink writes chunks to disk              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Lazy referenceBun.file(path)
Text read.text()
JSON read.json()
Bytes read.bytes()
Stream read.stream()
WriteBun.write(dest, data)
Incremental writefile.writer() → FileSink
Delete.delete()
Stream pipingstream().pipeTo(writer())

Key takeaways:

  • Bun.file() is lazy. Creating a BunFile does not read the file. The size and type properties are available immediately; the contents are loaded only when a read method is called .
  • Bun.write() is the single write API. It accepts strings, Blobs, ArrayBuffers, TypedArrays, and Response objects. The return value is the number of bytes written .
  • stream() is for large files. It returns a ReadableStream that yields chunks. The memory footprint is bounded by the chunk size, not the file size .
  • FileSink is for incremental writes. The writer() method returns a sink that buffers writes and flushes to disk on end(). The highWaterMark option controls the buffer size .
  • node:fs is the fallback. Operations not covered by the native APIs—mkdir, readdir, stat—are available through Bun’s near-complete implementation of the Node module .

Remember: Bun’s file APIs are built for the runtime. Bun.file() is a reference, not a read. Bun.write() is the write. stream() is the escape hatch for files too large to fit in memory. The native APIs cover the common cases; node:fs covers the rest. The reference is lazy, the write is explicit, and the stream is bounded.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!