Running commands with Node.js child processes

By

Learn how to run commands from Node.js with spawn, exec, execFile, and fork while handling streams, errors, timeouts, signals, and shell injection.

~~~

Node.js can start other programs through the built-in node:child_process module.

You can run Git, ImageMagick, a shell script, or another Node.js program. The child runs in a separate process with its own memory and process ID.

The module gives us four main asynchronous functions:

My default is spawn(). It handles large or long-running output without keeping everything in memory.

Start a process with spawn()

Import spawn() from the core module:

import { spawn } from 'node:child_process'

Pass the executable as the first argument and its arguments as an array:

const child = spawn('git', ['status', '--short'])

Node does not run this through a shell by default. It looks up git using PATH, then passes the two arguments directly to the executable.

That separation matters. Do not write this:

spawn('git status --short')

Node looks for an executable literally named git status --short and emits an ENOENT error.

Read stdout and stderr

By default, Node creates pipes for the child’s standard input, output, and error streams.

Listen for data on stdout:

const child = spawn('git', ['status', '--short'])

child.stdout.on('data', data => {
  process.stdout.write(data)
})

Errors printed by the program arrive on stderr:

child.stderr.on('data', data => {
  process.stderr.write(data)
})

These chunks are Buffer objects. Convert them to strings only if the program emits text:

child.stdout.setEncoding('utf8')

child.stdout.on('data', text => {
  console.log(text)
})

A chunk is not guaranteed to contain a complete line. If lines matter, use readline or keep incomplete text between chunks.

You can also inherit the parent’s terminal:

const child = spawn('npm', ['test'], {
  stdio: 'inherit'
})

This is ideal for an interactive command or a build whose output should appear immediately. Inherited streams are not available as child.stdout and child.stderr because the child writes directly to the parent’s streams.

If you do not need output, ignore it:

spawn('node', ['cleanup.js'], {
  stdio: 'ignore'
})

Never leave piped output unread. Operating-system pipes have limited capacity. A child that fills a pipe can block until the parent consumes data.

Send data to stdin

The child’s stdin is a writable stream.

This example sends text to the wc command and closes the input:

const child = spawn('wc', ['-w'])

child.stdout.setEncoding('utf8')
child.stdout.on('data', data => console.log(data.trim()))

child.stdin.write('one two three\n')
child.stdin.end()

Call end() when there is no more input. Many programs wait for end-of-file before producing their final result.

Streams also let you connect processes:

const find = spawn('find', ['src', '-name', '*.js'])
const count = spawn('wc', ['-l'])

find.stdout.pipe(count.stdin)
count.stdout.pipe(process.stdout)

This is the Node equivalent of a small shell pipeline, without asking a shell to parse a command string.

Handle errors and exit results

Two events describe different failures.

The error event means Node could not start or control the process. A missing executable commonly produces ENOENT:

child.on('error', error => {
  console.error('Could not start the command:', error.message)
})

The close event means the process ended and its standard streams closed:

child.on('close', (code, signal) => {
  if (signal) {
    console.error(`Stopped by ${signal}`)
    return
  }

  if (code !== 0) {
    console.error(`Exited with code ${code}`)
  }
})

An exit code of zero conventionally means success. Other codes are program-specific.

Do not listen only for close. If spawning fails, you need the error handler too.

Here is a Promise helper that covers both paths:

import { spawn } from 'node:child_process'

function run(command, args, options = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, options)

    child.once('error', reject)

    child.once('close', (code, signal) => {
      if (code === 0) {
        resolve()
        return
      }

      reject(new Error(
        signal
          ? `${command} stopped by ${signal}`
          : `${command} exited with code ${code}`
      ))
    })
  })
}

await run('npm', ['test'], { stdio: 'inherit' })

For production code, include the command context in the error without exposing secret arguments.

Set cwd and environment variables

Use cwd to choose the child’s working directory:

spawn('npm', ['test'], {
  cwd: '/Users/flavio/projects/notes-api',
  stdio: 'inherit'
})

Use env to control its environment:

spawn('node', ['server.js'], {
  env: {
    ...process.env,
    NODE_ENV: 'production'
  }
})

Notice that I spread process.env. If you replace the environment with only NODE_ENV, the child might lose PATH and fail to find commands.

Environment variables are inherited by child processes. Do not pass every secret to a tool that does not need them. Build a smaller environment for untrusted or third-party commands.

Stop a process and set a timeout

Call kill() to send a signal:

child.kill('SIGTERM')

SIGTERM asks a Unix process to terminate. The process can handle or ignore it. SIGKILL cannot be handled, but it does not allow cleanup.

Signals behave differently on Windows, so test lifecycle code on every supported platform.

Modern Node.js APIs can use an AbortSignal:

const controller = new AbortController()

const child = spawn('node', ['slow-report.js'], {
  signal: controller.signal
})

setTimeout(() => controller.abort(), 5000)

Aborting produces an AbortError through the child’s error event. Keep the error handler installed.

You can also use the timeout option:

spawn('node', ['slow-report.js'], {
  timeout: 5000,
  killSignal: 'SIGTERM'
})

A timeout sends the configured signal. It does not guarantee instant termination if the process handles that signal and stays alive.

Use exec() for small shell commands

exec() runs a command string through a shell and buffers stdout and stderr before calling you back:

import { exec } from 'node:child_process'

exec('git status --short', (error, stdout, stderr) => {
  if (error) {
    console.error(error.message)
    return
  }

  console.log(stdout)
})

This is convenient when you deliberately need shell syntax such as pipes, redirects, or variable expansion.

It has two costs.

First, output is buffered and limited by maxBuffer. exec() is a poor choice for a command that can print a lot.

Second, a shell parses the string. Never concatenate untrusted input:

exec(`convert ${filename} output.jpg`)

A malicious filename could add another shell command.

Prefer spawn() or execFile() with an argument array:

spawn('convert', [filename, 'output.jpg'])

The argument is passed as data rather than shell syntax.

Passing an argument array prevents the shell from interpreting characters such as ;, &, and $(). It does not make the called program harmless.

Some programs interpret arguments as instructions. A filename beginning with - might become an option. When the executable supports it, place -- before untrusted positional values:

spawn('grep', ['--', searchText, filename])

Validate inputs according to the command too. If the application only permits an image filename, accept a server-generated identifier and look up its path instead of forwarding an arbitrary path.

Use execFile() when you want buffered output

execFile() runs an executable directly and buffers its output:

import { execFile } from 'node:child_process'

execFile('git', ['rev-parse', '--short', 'HEAD'], (error, stdout) => {
  if (error) {
    console.error(error.message)
    return
  }

  console.log(stdout.trim())
})

It avoids a shell by default, so it is safer and often more efficient than exec().

Use it when the output is known to be small and you want one completed string or buffer. Use spawn() when output can be large or should appear while the command runs.

Promisify execFile()

Node’s child-process functions use callbacks and events. You can use promisify() for the buffered APIs:

import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

const execFileAsync = promisify(execFile)

const { stdout } = await execFileAsync(
  'git',
  ['rev-parse', '--short', 'HEAD']
)

console.log(stdout.trim())

When the executable exits unsuccessfully, the Promise rejects with an error that also carries captured stdout and stderr.

The output is still buffered. Promises do not change the memory behavior of execFile().

Use fork() for another Node.js process

fork() is a special form of spawn() for Node.js modules. It creates an IPC channel so parent and child can exchange messages.

Parent:

import { fork } from 'node:child_process'

const worker = fork('./report-worker.js')

worker.send({ type: 'build-report', month: 'august' })

worker.on('message', message => {
  console.log(message)
})

Child:

process.on('message', message => {
  if (message.type === 'build-report') {
    process.send({ type: 'done', file: 'august.pdf' })
  }
})

The child is a separate process, not a worker thread. It has its own V8 instance and memory.

Use fork() when process isolation and message passing fit the job. For CPU work that needs shared memory, worker threads can be a better fit.

Detached processes

A detached child can continue independently of its parent, but it needs careful setup.

On Unix, detached: true starts it as the leader of a new process group and session. To let the parent exit, avoid inherited pipes and call unref():

const child = spawn('node', ['background-job.js'], {
  detached: true,
  stdio: 'ignore'
})

child.unref()

Do not use this as a substitute for a real process manager. Production services need restart policy, logs, health checks, and controlled shutdown.

Think about child trees

Stopping the direct child does not always stop every process it started.

A build tool can create workers of its own. A shell command can start a pipeline. Killing the first process might leave descendants running.

The exact solution is platform-specific. Unix process groups and Windows job objects have different behavior. If you launch a complex tool tree, test cancellation on every supported operating system and verify that no descendant remains.

For server jobs, keep a process registry with the child PID, start time, owner, and current state. Limit how many commands can run at once. A route that spawns unlimited children is an easy denial-of-service target even when every command is trusted.

Queue expensive work and define what happens after the parent restarts. An in-memory child reference disappears with the process, but the operating-system process might not.

Synchronous methods

The module also provides spawnSync(), execSync(), and execFileSync().

They block the Node.js event loop until the command exits. That can be acceptable in a short build script or one-time CLI startup step.

Do not use synchronous child-process methods inside a web request handler. Every other request waits while the command runs.

The security checklist

Starting a process crosses an important boundary. Before doing it, check:

Node’s official child process documentation includes every option and platform detail.

Node’s permission model can also restrict process creation. When an application runs with --permission, spawning requires the child-process permission. Treat that as defense-in-depth, not a replacement for input validation.

Cross-platform commands

Commands available on macOS or Linux might not exist on Windows. Shell built-ins and quoting rules differ too.

Prefer a cross-platform Node.js API when one exists. Use node:fs to copy files instead of spawning cp. Use node:path instead of concatenating platform separators.

When an external executable is the point of the feature, document it as a dependency and test command discovery. On Windows, some npm-installed tools use .cmd wrappers and need platform-aware handling.

Do not assume a developer’s interactive shell configuration is available. A deployed process can have a smaller PATH, no aliases, and a different current directory.

How I choose the API

I use spawn() for build tools, media processing, and commands with live output.

I use execFile() when I need a small result, such as a Git commit ID.

I use exec() only when the shell itself is part of the job and every piece of the command is trusted.

I use fork() when I control both Node.js programs and want process isolation with a message channel.

That decision avoids most child-process problems before the first line runs.

Tagged: Node.js · All topics
~~~

Related posts about node: