Back to Scaling Node.js Applications guides

A Practical Guide to Execa for Node.js

Stanley Ulili
Updated on April 8, 2025

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:

index.js
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:

Diagram illustrating the key difference between Node's `child_process` and Execa's approach

Run the script with the following command:

 
node index.js
Output
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:

index.js
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
Output
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.

index.js
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
Output
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.

index.js
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:

Output
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.

Diagram of execa streaming diagram

Let’s begin with the most straightforward method: piping output directly to the terminal:

index.js
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
Output
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:

index.js
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
Output

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:

index.js
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
Output
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:

setup.js
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 with npm 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.:

Output

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.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Uploading Files with Multer in Node.js
Learn how to handle file uploads in Node.js using Multer, a popular middleware for Express applications. This step-by-step guide covers everything from setting up Multer, configuring disk and memory storage, validating file types, setting upload limits, and managing multiple file uploads.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

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
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
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.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github