10LOC
Bunadvanced

Piped-stdio subprocesses with Bun.spawn

Published January 15, 2027

const proc = Bun.spawn(["bun", "-e", "console.log((await Bun.stdin.text()).toUpperCase())"], {
  stdin: "pipe",
});

proc.stdin.write("hello from the parent\n");
proc.stdin.end();

const output = await new Response(proc.stdout).text();
console.log(output.trim()); // "HELLO FROM THE PARENT"

const exitCode = await proc.exited;
if (exitCode !== 0) throw new Error(`child exited with code ${exitCode}`);

export {};

What

Bun.spawn(cmd, { stdin: "pipe" }) returns a Subprocess whose .stdin is a writable FileSink and whose .stdout is a readable stream by default — you write to the child incrementally and read its output back, both without touching a temp file.

Why it matters

Node's child_process.spawn gives you the same basic shape (writable stdin, readable stdout), but Bun's version is built on posix_spawn and is measurably faster to launch — Bun's own benchmark shows spawnSync running roughly 60% faster than Node's equivalent. More practically, .stdin being a FileSink means the same buffered-write-then-flush model as writing to a file (write(), flush(), end()), so streaming input into a long-running child process (a formatter, a compiler, a filter command) doesn't require juggling Readable/Writable stream types the way Node's API does.

How it works

  • Passing stdin: "pipe" is what makes proc.stdin a FileSink instead of undefined — the default is no input stream at all.
  • proc.stdin.write(...) buffers each write; proc.stdin.end() flushes and closes the stream, which signals EOF to the child — the child here is waiting for exactly that to stop reading.
  • proc.stdout is a web ReadableStream, which has no .text() of its own — wrapping it in new Response(proc.stdout) gets you .text() for free. That read resolves once the child closes stdout, which for this child happens right after it prints its answer and exits.
  • await proc.exited resolves with the exit code once the process actually terminates — worth checking explicitly rather than assuming a clean run just because stdout produced output.

Gotchas

  • Bun.spawn runs the executable directly via posix_spawn — no shell is involved, so shell builtins (cd, echo as a shell feature) and glob expansion don't work unless the target binary implements them itself. That's Bun.$'s job, not Bun.spawn's.
  • Forgetting proc.stdin.end() leaves the child waiting for more input forever if it reads until EOF — the parent process then hangs on await new Response(proc.stdout).text(), which never resolves because the child never exits.
  • Bun.spawn is asynchronous and best for servers and long-running apps; Bun.spawnSync is the blocking equivalent, better suited to a one-shot CLI tool where you can afford to block.

Related

  • Bun.$ — a shell-syntax wrapper around subprocesses for one-off commands; reach for Bun.spawn instead when you need fine-grained control over stdin/stdout as streams.
  • proc.kill() / the signal and timeout options — for terminating a subprocess that runs too long instead of waiting on .exited indefinitely.