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 makesproc.stdinaFileSinkinstead ofundefined— 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.stdoutis a webReadableStream, which has no.text()of its own — wrapping it innew 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.exitedresolves with the exit code once the process actually terminates — worth checking explicitly rather than assuming a clean run just becausestdoutproduced output.
Gotchas
Bun.spawnruns the executable directly viaposix_spawn— no shell is involved, so shell builtins (cd,echoas a shell feature) and glob expansion don't work unless the target binary implements them itself. That'sBun.$'s job, notBun.spawn's.- Forgetting
proc.stdin.end()leaves the child waiting for more input forever if it reads until EOF — the parent process then hangs onawait 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.spawnSyncis 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 forBun.spawninstead when you need fine-grained control over stdin/stdout as streams.proc.kill()/ thesignalandtimeoutoptions — for terminating a subprocess that runs too long instead of waiting on.exitedindefinitely.