Running Node.js Scripts

Once you have Node.js installed, running a program is as simple as pointing the node binary at a JavaScript file. But there is more going on than “it just runs” — Node parses your file, wraps it in a hidden function, executes it top to bottom, keeps the process alive if there is pending async work, and finally exits. Understanding this flow means you’ll know exactly when your code runs, how to pass information into a script, and how to avoid the mistakes that trip up almost every beginner.

Overview / How Node.js Executes a Script

Node.js is a runtime that executes JavaScript outside a browser, built on Google’s V8 engine plus a C library called libuv that provides the event loop and access to the filesystem, network, and other OS features. When you run node app.js, several things happen in order:

  • Node reads app.js from disk and checks whether it should be parsed as CommonJS or an ES module (based on the file extension and the nearest package.json‘s "type" field).
  • For a CommonJS file, Node wraps your code in a hidden function that supplies require, module, exports, __filename, and __dirname as arguments — this is why those identifiers are available even though you never declared them.
  • V8 compiles and runs the file’s top-level code synchronously, immediately, top to bottom. Any console.log, variable assignment, or function call at the top level executes right away, in order.
  • If your code schedules async work (a setTimeout, a file read, an HTTP server, a pending promise), Node registers that work with libuv and keeps the event loop running.
  • The process exits automatically once the call stack is empty and there is no more pending work in the event loop (no open timers, servers, or unresolved I/O). This is why a script with http.createServer().listen() never exits on its own — the server keeps a “handle” open — while a script that only logs a string exits immediately after.

Two details matter as soon as you start running real scripts: the working directory and arguments. The working directory (process.cwd()) is the folder you were in when you typed the node command — not necessarily the folder the script file lives in. Arguments are anything you type after the filename, and Node exposes them (plus the executable path and script path) through process.argv.

Syntax

node [options] file.js [arguments...]
Part Meaning
node The Node.js executable.
[options] Flags for Node itself, e.g. --watch, --env-file, --check, --version.
file.js The entry-point script Node loads and runs.
[arguments...] Anything after the filename is passed through to your script, unread by Node itself — available via process.argv.

You can also run Node with no file at all (node alone) to open the REPL — an interactive prompt where you can type JavaScript line by line and see results immediately. It’s useful for quick experiments but not for real programs.

Examples

Example 1: A basic script

// app.js
const greeting = "Hello from Node.js!";
console.log(greeting);
console.log("Node version:", process.version);
console.log("Running in:", process.cwd());

Run it from a terminal with:

node app.js

Output:

Hello from Node.js!
Node version: v22.5.0
Running in: /home/you/projects/demo

All three lines run synchronously, top to bottom, before Node checks whether anything else is pending. Since nothing is, the process exits right after the last line.

Example 2: Reading command-line arguments

// greet.js
const args = process.argv.slice(2);
const name = args[0] ?? "stranger";

console.log(`Hello, ${name}!`);
console.log("All raw argv entries:", process.argv);
node greet.js Alice

Output:

Hello, Alice!
All raw argv entries: [
  '/usr/local/bin/node',
  '/home/you/projects/demo/greet.js',
  'Alice'
]

process.argv[0] is always the path to the node executable, and process.argv[1] is always the path to the script being run. Your actual arguments start at index 2, which is why real programs call process.argv.slice(2) rather than reading process.argv directly.

Example 3: A more realistic script with async work and an exit code

// check-file.js
const fs = require("node:fs/promises");
const path = require("node:path");

async function main() {
  const target = process.argv[2];
  if (!target) {
    console.error("Usage: node check-file.js ");
    process.exitCode = 1;
    return;
  }

  const fullPath = path.resolve(process.cwd(), target);

  try {
    const stats = await fs.stat(fullPath);
    console.log(`${fullPath} exists. Size: ${stats.size} bytes.`);
  } catch (err) {
    console.error(`Could not read ${fullPath}: ${err.message}`);
    process.exitCode = 1;
  }
}

main();
node check-file.js package.json

Output:

/home/you/projects/demo/package.json exists. Size: 412 bytes.

This script mixes synchronous setup (reading argv, resolving a path) with asynchronous I/O (fs.stat). Because main() is async and top-level await isn’t used here in CommonJS, we call main() as a normal function and let its internal await pause only that function. Note the use of process.exitCode rather than calling process.exit() directly — more on why in Common Mistakes.

How It Works Step by Step

    • 1. Parse: Node determines the module format (CommonJS vs ESM) from the file extension (.cjs/.mjs) or the nearest package.json‘s "type" field, then parses the file.
    • 2. Wrap and run top-level code: Every synchronous statement at the top level executes immediately, in file order. This includes variable declarations, require() calls (which themselves run the required file’s top-level code), and any function calls you make directly.
    • 3. Register async work: Calls like fs.readFile, setTimeout, or opening a server don’t run their callback right away — they hand the work to libuv (using its thread pool for filesystem/DNS work, or OS-level async I/O for networking) and return control to your script immediately.
    • 4. Event loop takes over: Once the top-level code finishes, Node enters the event loop, cycling through phases (timers, pending callbacks, poll, check, close callbacks) and firing callbacks as their underlying work completes.
    • 5. Exit: When there is no more code to run and no pending handles (open timers, listening servers, unresolved I/O), the process exits with process.exitCode (default 0, meaning success).

    Common Mistakes

    Mistake 1: Assuming relative paths are relative to the script file

    Beginners often assume a path like "./data.json" resolves relative to the script’s own folder. It doesn’t — it resolves relative to process.cwd(), the directory you ran node from.

    // WRONG: breaks if you run this from a different directory
    const fs = require("node:fs");
    const data = fs.readFileSync("./data.json", "utf8");
    
    // RIGHT: always resolve relative to the script's own location
    const fs = require("node:fs");
    const path = require("node:path");
    
    const dataPath = path.join(__dirname, "data.json");
    const data = fs.readFileSync(dataPath, "utf8");
    

    In an ES module, __dirname does not exist — use import.meta.dirname (Node 20.11+) instead, or path.dirname(fileURLToPath(import.meta.url)) on older versions.

    Mistake 2: Calling process.exit() before async work finishes

    const fs = require("node:fs/promises");
    
    fs.writeFile("log.txt", "done\n").then(() => {
      console.log("Saved!");
    });
    process.exit(0); // WRONG: exits before the write (and the log) can finish
    

    process.exit() terminates the process immediately, abandoning any pending I/O and even unflushed console.log output on some platforms. Prefer setting process.exitCode = 1 (or 0) and letting the script exit naturally once its work is done; reserve process.exit() for cases where you deliberately need to force an immediate shutdown after everything important has completed.

    Mistake 3: Blocking the event loop with synchronous work

    // Freezes the entire process for everyone it's serving
    function blockFor(ms) {
      const end = Date.now() + ms;
      while (Date.now() < end) { /* spin */ }
    }
    blockFor(5000);
    console.log("5 seconds of nothing else could happen");
    

    Node is single-threaded for your JavaScript. A tight synchronous loop (or a sync API like fs.readFileSync on a huge file) blocks everything — timers, incoming HTTP requests, all of it — until it finishes. This is fine in a short-lived CLI script but fatal inside a running server. Use the async/promise-based APIs and let the event loop breathe.

    Best Practices

    • Use process.argv.slice(2) to get real arguments; index 0 and 1 are always the Node path and script path.
    • Prefer process.exitCode = n over process.exit(n) so pending output and I/O can flush naturally.
    • Resolve file paths with path.join(__dirname, ...) (CommonJS) or import.meta.dirname (ESM) instead of assuming a working directory.
    • Use node --watch app.js during development so your script restarts automatically on file changes — no need for a separate tool for basic cases.
    • Add an npm script (e.g. "start": "node app.js") so the whole team runs the program the same way, via npm start.
    • Run node --check app.js to verify a file's syntax without executing it — useful in CI before deploying.
    • Load environment variables with node --env-file=.env app.js (Node 20.6+) instead of hardcoding config or secrets in the script.
    • Give CLI-style scripts a #!/usr/bin/env node shebang line and executable permission (chmod +x) so they can be run directly as ./script.js.

    Practice Exercises

    • Exercise 1: Write a script sum.js that reads any number of numeric arguments from the command line (e.g. node sum.js 4 7 2) and prints their sum. Handle the case where no arguments are given by printing a usage message and setting a non-zero exit code.
    • Exercise 2: Write a script that prints process.cwd() and __dirname side by side, then run it twice from two different directories to confirm they can differ.
    • Exercise 3: Add a "start" script to a package.json that runs your sum.js file, then try running it both ways: node sum.js 1 2 3 and npm start -- 1 2 3. Compare the results.

    Summary

    • node file.js parses the file, runs its top-level code synchronously, then processes any scheduled async work through the event loop until nothing is left pending.
    • process.argv holds the Node path, the script path, then your real arguments starting at index 2.
    • Relative paths inside a script resolve against process.cwd(), not the script's folder — use __dirname (CommonJS) or import.meta.dirname (ESM) for paths relative to the file itself.
    • Prefer process.exitCode over an abrupt process.exit() so pending work can finish cleanly.
    • node --watch, node --env-file, and node --check are built-in flags that cover common development needs without extra tooling.