Working with shell commands in Node.js doesn’t have to be a headache. Execa brings sanity to process execution with a promise-based API, clean output handling, and rich error reporting—all wrapped in a developer-friendly package.
It’s cross-platform, reliable, and significantly improved over the built-in child_process
module. Execa gives you precise control over command execution, with clean handling of stdout
, detailed error messages, and consistent behavior across all major operating systems.
This article discusses practical Execa implementations, demonstrating how this lightweight library solves common process execution challenges.
Prerequisites
To follow along with this article, you'll need Node.js version 18.0.0 or higher installed on your system.
Getting started with Execa
Let's create a new project to explore Execa's capabilities. Open your terminal and run the following commands:
mkdir execa-demo && cd execa-demo
npm init -y
Configure the project to use ES modules by adding the following to your package.json
file:
npm pkg set type="module"
Now install Execa:
npm install execa
Create an index.js
file with this simple example:
import { execa } from 'execa';
async function main() {
try {
const { stdout } = await execa('echo', ['Hello, world!']);
console.log(stdout);
} catch (error) {
console.error('Error:', error.message);
}
}
main();
This minimal example shows off Execa's Promise-based approach to running commands. While Node’s native child_process
module requires extra setup to capture output and handle errors, Execa handles it out of the box.
It runs the command, waits for it to finish, and returns a Promise with the result, making your code cleaner and easier to work with.
The diagram below illustrates the key difference between Node's child_process
and Execa's approach:
Run the script with the following command:
node index.js
Hello, world!
As you can see, Execa executed the echo
command and captured its output. This is just one of the many conveniences that Execa provides out of the box.
Understanding Execa's return value
Node's child_process
makes you juggle separate handlers for stdout
and stderr
. Execa simplifies everything by returning a single, rich Promise result object that includes all the output and metadata in one place. Let’s take a closer look at what this response object contains:
import { execa } from 'execa';
async function main() {
try {
const result = await execa('ls', ['-la']);
// Examine the comprehensive result object
console.log('Command:', result.command);
console.log('Exit code:', result.exitCode);
console.log('Output:', result.stdout);
console.log('Error output:', result.stderr);
} catch (error) {
console.error('Error:', error.message);
}
}
main();
This example shows Execa gives you all process details in a single object. Beyond capturing output, Execa tracks the exit code, process ID, and exact executed command.
When you run this script, you'll get detailed information about the execution:
node index.js
Command: ls -la
Exit code: 0
Output: total 40
drwxr-xr-x@ 6 stanley group 192 Apr 8 13:29 .
drwxr-xr-x@ 4 stanley group 128 Apr 8 13:28 ..
-rw-r--r--@ 1 stanley group 423 Apr 8 13:32 index.js
drwxr-xr-x@ 26 stanley group 832 Apr 8 13:29 node_modules
-rw-r--r--@ 1 stanley group 9983 Apr 8 13:29 package-lock.json
-rw-r--r--@ 1 stanley group 291 Apr 8 13:29 package.json
Error output:
This rich result object lets you handle command results with minimal code, from simple directory listings to complex operations.
Handling command errors
Execa transforms error handling from a fragmented mess of event listeners to a clean, Promise-based approach.
When a process fails, Execa gives you intelligent error objects with complete context about what went wrong.
import { execa } from 'execa';
async function main() {
try {
// Try to run a command that doesn't exist
await execa('nonexistentcommand');
} catch (error) {
console.error('Error message:', error.message);
console.error('Command:', error.command);
console.error('Exit code:', error.exitCode);
console.error('Error output:', error.stderr);
}
}
main();
This code demonstrates Execa's unified error handling approach. Whether a command doesn't exist or exits with an error code, Execa provides consistent error objects with all the details you need.
When executed, you'll see detailed error information that makes debugging simple:
node index.js
Error: Command failed with ENOENT: nonexistentcommand
spawn nonexistentcommand ENOENT
Command: nonexistentcommand
Exit code: undefined
Error output:
This comprehensive error handling turns complex error scenarios into predictable, easy-to-handle outcomes.
Customizing execution behavior with options
Execa's power comes from its comprehensive options system. These options give you precise control over how commands run while keeping your code clean.
import { execa } from 'execa';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
async function main() {
try {
const result = await execa('npm', ['list', '--depth=0'], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
LOG_LEVEL: 'info'
},
timeout: 10000,
shell: true
});
console.log('Command output:', result.stdout);
} catch (error) {
console.error('Error:', error.message);
// remove the other code
}
}
main();
This highlighted code shows how Execa gives you precise control over command execution:
cwd
sets the working directory to the parent folder.env
merges custom environment variables with the current process.timeout
ensures the command doesn't run longer than 10 seconds.shell: true
allows shell features like globbing or built-ins.- The result object provides clean access to
stdout
.
It’s a concise way to run external commands with full control and minimal clutter.
Run the script to see these options in action:
node index.js
If the parent directory has no dependencies installed, you'll get output like this:
Command output: /Users/stanley
└── (empty)
These options transform Execa from a simple execution library into a complete process management system.
Streaming and real-time processing
For long-running commands that generate a lot of output, you need to process data as it arrives. Execa gives you multiple ways to handle streaming output.
Let’s begin with the most straightforward method: piping output directly to the terminal:
import { execa } from 'execa';
// remove the other imports
async function main() {
// Method 1: Direct terminal piping
console.log('DEMO: Direct terminal output:');
try {
// Connect child process directly to your terminal
await execa('npm', ['list'], {
stdio: 'inherit' // Connect directly to terminal
});
console.log('Command completed');
} catch (error) {
console.error('Error:', error.message);
}
}
main();
In this code, Execa streams the output of npm list
directly to your terminal using stdio: 'inherit'
.
This mirrors what you'd see if you ran the command yourself in the shell—output appears live, without buffering or manual handling.
Once the command finishes, the message 'Command completed'
is printed, confirming it exited cleanly. This method is great for quickly running commands where you want to see the output as it happens.
Run this script to see the output displayed directly in your terminal:
node index.js
DEMO: Direct terminal output:
execa-demo@1.0.0 /path/to/execa-demo└── execa@9.5.2
Command completed
The stdio: 'inherit'
option passes all child process output straight to your terminal without any JavaScript processing.
This approach works well when you just want to show command output directly to the user.
Now, let's modify our script to handle output processing:
import { execa } from 'execa';
async function main() {
console.log('DEMO: Processing output chunks');
// Start command but don't wait for it yet
const findProcess = execa('find', ['.', '-name', '*.js']);
// Handle output as it arrives
findProcess.stdout.on('data', (data) => {
// Process each chunk of output
const filename = data.toString().trim();
console.log(`Found JavaScript file: ${filename}`);
});
try {
// Wait for command to complete
await findProcess;
console.log('Find command completed');
} catch (error) {
console.error('Error:', error.message);
}
}
main();
Run this modified script to see how each output chunk is processed:
node index.js
DEMO: Processing output chunks
Found JavaScript file: ./node_modules/is-plain-obj/index.js
./node_modules/shebang-regex/index.js
...
./node_modules/unicorn-magic/default.js
./node_modules/pretty-ms/index.js
./index.js
Find command completed
This event-based approach gives you complete control to transform, filter, or analyze the output data as it arrives. It's ideal for progress indicators, log analyzers, or handling large amounts of output.
Building command pipelines
Execa lets you create powerful command pipelines in JavaScript, giving you more flexibility than traditional shell pipelines.
Instead of chaining commands with |
, you can run one command, inspect or transform its output in JavaScript, then feed it into the next.
To do that, add the following highlighted code:
import { execa } from 'execa';
async function main() {
// remove the other code
try {
// Find all files in current directory
const { stdout: allFiles } = await execa('find', ['.', '-type', 'f']);
// Use JavaScript to filter for JS files
const jsFiles = allFiles
.split('\n')
.filter(file => file.endsWith('.js'))
.join('\n');
// Count lines in those JS files
const { stdout: lineCount } = await execa('wc', ['-l'], {
input: jsFiles // Pass filtered list as input to next command
});
console.log(`Found ${jsFiles.split('\n').length} JavaScript files`);
console.log(`Total lines of code: ${lineCount.trim()}`);
} catch (error) {
console.error('Pipeline error:', error.message);
}
}
main();
In this code, you’re building a custom command pipeline entirely in JavaScript using Execa.
First, it runs find
to list all files in the current directory. Instead of piping directly to another command, it captures the output in stdout
, filters for .js
files using native JavaScript, and joins them into a string.
That filtered list is then passed as input to wc -l
, which counts the total lines across those files.
This approach gives you full control between steps—add logic, transform data, or apply conditions—something traditional shell pipelines can’t easily do.
Run the script to see the pipeline in action:
node index.js
Found 162 JavaScript files
Total lines of code: 161
This JavaScript-powered approach gives you more flexibility than shell scripts while keeping the efficiency of command-line tools.
Synchronous execution for scripts and utilities
So far, you've seen how Execa handles async command execution with Promises. But sometimes, especially in scripts that set up projects or perform one-time tasks, you need things to run in strict sequence and block execution until they finish.
This is where execaSync()
comes in.
Create a new file called setup.js
and add the following code:
import { execaSync } from 'execa';
import fs from 'fs';
// Project initialization script
console.log('Setting up project...');
function setupProject() {
try {
// Check if git is clean
const gitStatus = execaSync('git', ['status', '--porcelain']);
if (gitStatus.stdout) {
console.error('Git working directory not clean.');
return false;
}
console.log('Git working directory clean');
// Install dependencies
console.log('Installing dependencies...');
execaSync('npm', ['install'], { stdio: 'inherit' });
// Create folders
const directories = ['src', 'tests', 'config'];
directories.forEach(dir => {
if (!fs.existsSync(dir)) {
console.log(`Creating ${dir} directory...`);
fs.mkdirSync(dir);
}
});
console.log('✨ Project setup complete!');
return true;
} catch (error) {
console.error('Setup failed:', error.message);
return false;
}
}
// Run the setup
const setupSucceeded = setupProject();
process.exit(setupSucceeded ? 0 : 1);
This script builds on ideas you've already seen:
- Like earlier examples, it uses Execa to run system commands, but this time with
execaSync()
to block until each step finishes. - It reuses familiar options like
stdio: 'inherit'
to stream output directly to the terminal—just like you saw withnpm list
. - It adds simple logic around each command, similar to the custom pipeline approach, but synchronously.
Before running the script, make sure you’ve initialized a Git repository in your project folder:
git init .
To run the script, just type:
node setup.js
When you run this script with node setup.js
, it first checks if your Git working directory is clean.:
Setting up project...
Git working directory not clean.
As shown above, the script will stop if your working directory isn’t clean. But if everything checks out, it moves on to install your project's npm dependencies.
Once that's done, it creates common project folders like src
, tests
, and config
—only if they don’t already exist.
Finally, it exits with a status code that reflects whether the setup completed successfully.
Be careful not to use synchronous execution in server code or applications that handle multiple users—it blocks Node’s event loop and can hurt performance. But for tasks like build scripts, CLI tools, or one-time setup routines, synchronous execution is often the right choice.
Final thoughts
This article showed how Execa makes working with shell commands in Node.js a lot less painful. You saw how to run commands with async and sync APIs, handle errors gracefully, stream output in real time, and even build command pipelines with full control in JavaScript.
Execa is a lightweight but powerful tool that fits right into scripts, dev workflows, or any project where you need to interact with the system shell without the usual mess.
If you're curious to go deeper, check out the official docs.
Make your mark
Join the writer's program
Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.
Write for us
Build on top of Better Stack
Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.
community@betterstack.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github