Cli Tools

CLI Performance Optimization

Techniques to make Node.js CLI tools start faster and run efficiently, from lazy loading and require caching to startup profiling and output streaming.

CLI Performance Optimization

A CLI tool that takes 500ms to start feels sluggish. One that takes 50ms feels instant. The difference is not the work the tool does — it is how the tool loads itself. Most CLI performance problems come from loading modules you do not need, parsing configuration you could defer, and doing I/O synchronously when you could stream.

I have profiled and optimized CLI tools from 800ms startup to under 50ms. The techniques are predictable and the gains are real. This guide covers every optimization that matters, from the first require() call to the final byte of output.

Prerequisites

  • Node.js installed (v14+)
  • A CLI tool to optimize (or willingness to build one)
  • Familiarity with require() and module loading
  • Basic understanding of Node.js event loop and streams

Measuring Startup Time

Before optimizing, measure. Use process.hrtime.bigint() for precise timing:

#!/usr/bin/env node

// Instrument startup at the very first line
var startTime = process.hrtime.bigint();

var path = require("path");
var fs = require("fs");

// After all requires
var requireTime = process.hrtime.bigint();

// After argument parsing
// ... parse args ...
var parseTime = process.hrtime.bigint();

// After config loading
// ... load config ...
var configTime = process.hrtime.bigint();

function reportTiming() {
  var total = Number(process.hrtime.bigint() - startTime) / 1e6;
  var requires = Number(requireTime - startTime) / 1e6;
  var parsing = Number(parseTime - requireTime) / 1e6;
  var config = Number(configTime - parseTime) / 1e6;

  console.error("\n[timing]");
  console.error("  requires:  " + requires.toFixed(1) + "ms");
  console.error("  parsing:   " + parsing.toFixed(1) + "ms");
  console.error("  config:    " + config.toFixed(1) + "ms");
  console.error("  total:     " + total.toFixed(1) + "ms");
}

if (process.env.CLI_TIMING) {
  process.on("exit", reportTiming);
}

Run with timing enabled:

CLI_TIMING=1 mytool --version

Output:

1.2.0

[timing]
  requires:  245.3ms
  parsing:   12.1ms
  config:    8.4ms
  total:     268.2ms

Using Node's Built-In Profiling

# Generate a startup trace
node --cpu-prof --cpu-prof-interval=100 ./bin/mytool.js --version

# This creates a .cpuprofile file
# Open it in Chrome DevTools: chrome://inspect -> Open dedicated DevTools

# Or use the --prof flag for V8 profiling
node --prof ./bin/mytool.js --version
node --prof-process isolate-0x*.log > profile.txt

Tracking require() Costs

Identify which modules are expensive to load:

// require-trace.js - Monkey-patch require to measure load times
var Module = require("module");
var originalLoad = Module._load;
var loadTimes = [];

Module._load = function(request, parent) {
  var start = process.hrtime.bigint();
  var result = originalLoad.apply(this, arguments);
  var duration = Number(process.hrtime.bigint() - start) / 1e6;

  if (duration > 1) { // Only track modules taking > 1ms
    loadTimes.push({
      module: request,
      time: duration.toFixed(1),
      from: parent ? parent.filename : "root"
    });
  }

  return result;
};

process.on("exit", function() {
  loadTimes.sort(function(a, b) { return parseFloat(b.time) - parseFloat(a.time); });
  console.error("\n[require trace] Top 10 slowest modules:");
  for (var i = 0; i < Math.min(10, loadTimes.length); i++) {
    var entry = loadTimes[i];
    console.error("  " + entry.time.padStart(8) + "ms  " + entry.module);
  }
});

Usage:

node -r ./require-trace.js ./bin/mytool.js --version

Output:

[require trace] Top 10 slowest modules:
     85.2ms  chalk
     42.3ms  yargs
     38.1ms  js-yaml
     22.4ms  glob
     18.7ms  ajv
     12.1ms  commander
      8.3ms  fs-extra
      6.2ms  inquirer
      4.1ms  ora
      3.8ms  cosmiconfig

Lazy Loading Modules

The single biggest optimization for CLI startup is lazy loading. Only require modules when the command that needs them actually runs.

Before: Everything Loaded Upfront

#!/usr/bin/env node

// BAD: All these load even for --version
var commander = require("commander");
var chalk = require("chalk");        // 85ms
var inquirer = require("inquirer");   // 120ms
var glob = require("glob");          // 22ms
var yaml = require("js-yaml");       // 38ms
var axios = require("axios");        // 45ms
var ora = require("ora");            // 30ms

var program = new commander.Command();

program.version("1.0.0");

program
  .command("deploy")
  .action(function() {
    // Only deploy uses inquirer, axios, ora
    // But they loaded for every command
  });

program
  .command("init")
  .action(function() {
    // Only init uses glob, yaml, inquirer
  });

program.parse();

Startup time for --version: ~350ms (loading 6 unnecessary modules).

After: Lazy Loading Per Command

#!/usr/bin/env node

var commander = require("commander"); // 12ms - always needed

var program = new commander.Command();

program.version("1.0.0");

program
  .command("deploy")
  .action(function() {
    // Load only when deploy runs
    var inquirer = require("inquirer");
    var axios = require("axios");
    var ora = require("ora");
    // ...
  });

program
  .command("init")
  .action(function() {
    var glob = require("glob");
    var yaml = require("js-yaml");
    var inquirer = require("inquirer");
    // ...
  });

program.parse();

Startup time for --version: ~18ms. Startup time for deploy: ~180ms (only loads what deploy needs).

Lazy Require Helper

function lazy(moduleName) {
  var cached = null;
  return function() {
    if (!cached) {
      cached = require(moduleName);
    }
    return cached;
  };
}

// Define lazy loaders at the top
var getChalk = lazy("chalk");
var getInquirer = lazy("inquirer");
var getAxios = lazy("axios");

// Use in commands
function deploy() {
  var chalk = getChalk();
  var axios = getAxios();

  console.log(chalk.green("Deploying..."));
}

Lazy Property Pattern

For modules with many exports where you only use one function:

function lazyProp(moduleName, property) {
  var cached = null;
  return function() {
    if (!cached) {
      var mod = require(moduleName);
      cached = property ? mod[property] : mod;
    }
    return cached;
  };
}

var getGlob = lazyProp("glob", "sync");
var getYamlParse = lazyProp("js-yaml", "load");

// Usage - only loads when called
var files = getGlob()("src/**/*.js");
var config = getYamlParse()(yamlContent);

Avoiding Heavy Dependencies

Some npm packages are disproportionately expensive to load. Replace them with lighter alternatives or built-in modules.

Common Replacements

// HEAVY: chalk (85ms to load)
// LIGHT: manual ANSI codes (0ms)
var colors = {
  green: function(s) { return "\u001b[32m" + s + "\u001b[0m"; },
  red: function(s) { return "\u001b[31m" + s + "\u001b[0m"; },
  yellow: function(s) { return "\u001b[33m" + s + "\u001b[0m"; },
  cyan: function(s) { return "\u001b[36m" + s + "\u001b[0m"; },
  bold: function(s) { return "\u001b[1m" + s + "\u001b[0m"; },
  dim: function(s) { return "\u001b[90m" + s + "\u001b[0m"; }
};

// Disable colors when not a TTY or NO_COLOR is set
if (!process.stdout.isTTY || process.env.NO_COLOR) {
  var keys = Object.keys(colors);
  for (var i = 0; i < keys.length; i++) {
    colors[keys[i]] = function(s) { return s; };
  }
}

// HEAVY: fs-extra (8ms)
// LIGHT: built-in fs with recursive option (0ms)
var fs = require("fs");
// fs-extra's mkdirp is now built-in:
fs.mkdirSync(dir, { recursive: true });
// fs-extra's readJson:
var data = JSON.parse(fs.readFileSync(file, "utf8"));
// fs-extra's copy:
fs.cpSync(src, dest, { recursive: true }); // Node 16.7+

// HEAVY: glob (22ms)
// LIGHT: fs.globSync (Node 22+) or manual walk
function walkSync(dir, pattern) {
  var results = [];
  var ext = pattern.split(".").pop();

  function walk(currentDir) {
    var entries = fs.readdirSync(currentDir, { withFileTypes: true });
    for (var i = 0; i < entries.length; i++) {
      var entry = entries[i];
      var fullPath = path.join(currentDir, entry.name);
      if (entry.isDirectory()) {
        walk(fullPath);
      } else if (entry.name.endsWith("." + ext)) {
        results.push(fullPath);
      }
    }
  }

  walk(dir);
  return results;
}

// HEAVY: js-yaml (38ms)
// If you only need simple YAML, parse it yourself or use JSON config instead

// HEAVY: axios (45ms)
// LIGHT: built-in http/https (0ms)
var https = require("https");
function httpGet(url) {
  return new Promise(function(resolve, reject) {
    https.get(url, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        resolve({ status: res.statusCode, data: JSON.parse(body) });
      });
    }).on("error", reject);
  });
}

Dependency Audit Script

#!/usr/bin/env node

// audit-deps.js - Measure the cost of each dependency

var childProcess = require("child_process");
var pkg = require("./package.json");

var deps = Object.keys(pkg.dependencies || {});

console.log("\nDependency load times:\n");

var results = [];

for (var i = 0; i < deps.length; i++) {
  var dep = deps[i];
  try {
    var output = childProcess.execSync(
      "node -e \"var s = process.hrtime.bigint(); require('" + dep + "'); " +
      "var e = process.hrtime.bigint(); console.log(Number(e-s)/1e6)\"",
      { encoding: "utf8", timeout: 5000 }
    ).trim();

    var time = parseFloat(output);
    results.push({ name: dep, time: time });
  } catch (e) {
    results.push({ name: dep, time: -1 });
  }
}

results.sort(function(a, b) { return b.time - a.time; });

for (var j = 0; j < results.length; j++) {
  var r = results[j];
  var bar = "";
  var blocks = Math.floor(r.time / 5);
  for (var k = 0; k < Math.min(blocks, 40); k++) bar += "█";

  var timeStr = r.time >= 0 ? r.time.toFixed(1) + "ms" : "error";
  var color = r.time > 50 ? "\u001b[31m" : r.time > 20 ? "\u001b[33m" : "\u001b[32m";

  console.log(
    "  " + r.name.padEnd(25) +
    color + timeStr.padStart(8) + "\u001b[0m " +
    "\u001b[90m" + bar + "\u001b[0m"
  );
}

var total = results.reduce(function(sum, r) { return sum + Math.max(0, r.time); }, 0);
console.log("\n  Total: " + total.toFixed(1) + "ms\n");

Output:

Dependency load times:

  inquirer                  122.4ms ████████████████████████
  axios                      45.2ms █████████
  chalk                      85.1ms █████████████████
  js-yaml                    38.3ms ███████
  glob                       22.1ms ████
  commander                  12.3ms ██
  minimist                    2.1ms

  Total: 327.5ms

Caching Expensive Operations

Cache results that do not change between invocations:

var fs = require("fs");
var path = require("path");
var os = require("os");
var crypto = require("crypto");

var CACHE_DIR = path.join(os.homedir(), ".mytool", "cache");

function ensureCacheDir() {
  if (!fs.existsSync(CACHE_DIR)) {
    fs.mkdirSync(CACHE_DIR, { recursive: true });
  }
}

function getCacheKey(input) {
  return crypto.createHash("md5").update(input).digest("hex");
}

function cacheGet(key, maxAgeMs) {
  maxAgeMs = maxAgeMs || 3600000; // 1 hour default
  var cachePath = path.join(CACHE_DIR, key + ".json");

  try {
    var stat = fs.statSync(cachePath);
    var age = Date.now() - stat.mtimeMs;

    if (age > maxAgeMs) {
      return null; // Expired
    }

    return JSON.parse(fs.readFileSync(cachePath, "utf8"));
  } catch (e) {
    return null;
  }
}

function cacheSet(key, value) {
  ensureCacheDir();
  var cachePath = path.join(CACHE_DIR, key + ".json");
  fs.writeFileSync(cachePath, JSON.stringify(value));
}

// Usage: Cache config file parsing
function loadConfig(configPath) {
  var content = fs.readFileSync(configPath, "utf8");
  var cacheKey = getCacheKey(configPath + ":" + content);
  var cached = cacheGet(cacheKey);

  if (cached) {
    return cached;
  }

  // Expensive: parse, validate, merge with defaults
  var config = parseAndValidateConfig(content);
  cacheSet(cacheKey, config);
  return config;
}

// Usage: Cache API responses
function getRemoteConfig(url) {
  var cacheKey = getCacheKey("remote:" + url);
  var cached = cacheGet(cacheKey, 300000); // 5 min cache

  if (cached) {
    return Promise.resolve(cached);
  }

  return httpGet(url).then(function(response) {
    cacheSet(cacheKey, response.data);
    return response.data;
  });
}

Streaming Output

For commands that produce large output, stream it instead of buffering:

var fs = require("fs");
var readline = require("readline");

// BAD: Buffer entire file, then process, then output
function searchFileBad(filePath, pattern) {
  var content = fs.readFileSync(filePath, "utf8");
  var lines = content.split("\n");
  var matches = [];

  for (var i = 0; i < lines.length; i++) {
    if (lines[i].indexOf(pattern) !== -1) {
      matches.push((i + 1) + ": " + lines[i]);
    }
  }

  console.log(matches.join("\n"));
}

// GOOD: Stream the file and output matches as found
function searchFileGood(filePath, pattern) {
  return new Promise(function(resolve) {
    var lineNum = 0;
    var matchCount = 0;

    var rl = readline.createInterface({
      input: fs.createReadStream(filePath),
      crlfDelay: Infinity
    });

    rl.on("line", function(line) {
      lineNum++;
      if (line.indexOf(pattern) !== -1) {
        matchCount++;
        process.stdout.write(lineNum + ": " + line + "\n");
      }
    });

    rl.on("close", function() {
      resolve(matchCount);
    });
  });
}

Streaming JSON Output

For large JSON arrays, stream items instead of building the array in memory:

function streamJsonArray(items) {
  process.stdout.write("[\n");

  for (var i = 0; i < items.length; i++) {
    var json = JSON.stringify(items[i], null, 2)
      .split("\n")
      .map(function(line) { return "  " + line; })
      .join("\n");

    process.stdout.write(json);

    if (i < items.length - 1) {
      process.stdout.write(",");
    }
    process.stdout.write("\n");
  }

  process.stdout.write("]\n");
}

Parallel I/O Operations

When a command needs multiple independent resources, fetch them in parallel:

// BAD: Sequential fetches
function deploySequential(config) {
  return checkHealth(config.healthUrl)
    .then(function(health) {
      return getVersion(config.apiUrl);
    })
    .then(function(version) {
      return getDeployStatus(config.statusUrl);
    })
    .then(function(status) {
      // All three fetched sequentially: ~900ms total
    });
}

// GOOD: Parallel fetches
function deployParallel(config) {
  return Promise.all([
    checkHealth(config.healthUrl),
    getVersion(config.apiUrl),
    getDeployStatus(config.statusUrl)
  ]).then(function(results) {
    var health = results[0];
    var version = results[1];
    var status = results[2];
    // All three fetched in parallel: ~300ms total
  });
}

// Parallel file operations
function processFiles(filePaths) {
  var CONCURRENCY = 10;
  var results = [];
  var index = 0;

  function next() {
    if (index >= filePaths.length) return Promise.resolve();
    var filePath = filePaths[index++];
    return processFile(filePath).then(function(result) {
      results.push(result);
      return next();
    });
  }

  var workers = [];
  for (var i = 0; i < Math.min(CONCURRENCY, filePaths.length); i++) {
    workers.push(next());
  }

  return Promise.all(workers).then(function() {
    return results;
  });
}

V8 Snapshot and Compile Cache

For maximum startup performance, use V8's compile cache:

// Enable compile cache (Node.js 22+)
// Set in the entry point before any other requires:
if (process.config && process.versions.node >= "22") {
  var v8 = require("v8");
  var path = require("path");
  var os = require("os");

  var cacheDir = path.join(os.homedir(), ".mytool", "v8-cache");
  v8.enableCompileCache(cacheDir);
}

// For older Node versions, use --compiled-cache flag:
// node --compiled-cache=.cache ./bin/mytool.js

Complete Working Example: Optimized CLI

#!/usr/bin/env node

var startTime = process.hrtime.bigint();

// Only load the minimum needed for argument routing
var fs = require("fs");
var path = require("path");

// ANSI colors without dependencies
var c = {
  green: function(s) { return "\u001b[32m" + s + "\u001b[0m"; },
  red: function(s) { return "\u001b[31m" + s + "\u001b[0m"; },
  yellow: function(s) { return "\u001b[33m" + s + "\u001b[0m"; },
  cyan: function(s) { return "\u001b[36m" + s + "\u001b[0m"; },
  bold: function(s) { return "\u001b[1m" + s + "\u001b[0m"; },
  dim: function(s) { return "\u001b[90m" + s + "\u001b[0m"; }
};

if (!process.stdout.isTTY || process.env.NO_COLOR) {
  var noColor = function(s) { return s; };
  Object.keys(c).forEach(function(k) { c[k] = noColor; });
}

// Ultra-fast argument parsing (no library needed for simple CLIs)
function parseArgs(argv) {
  var args = argv.slice(2);
  var command = null;
  var subcommand = null;
  var flags = {};
  var positional = [];

  for (var i = 0; i < args.length; i++) {
    var arg = args[i];
    if (arg === "--version" || arg === "-V") { flags.version = true; continue; }
    if (arg === "--help" || arg === "-h") { flags.help = true; continue; }
    if (arg === "--verbose" || arg === "-v") { flags.verbose = true; continue; }
    if (arg === "--timing") { flags.timing = true; continue; }

    if (arg.indexOf("--") === 0) {
      var eq = arg.indexOf("=");
      if (eq !== -1) {
        flags[arg.slice(2, eq)] = arg.slice(eq + 1);
      } else if (i + 1 < args.length && args[i + 1].charAt(0) !== "-") {
        flags[arg.slice(2)] = args[++i];
      } else {
        flags[arg.slice(2)] = true;
      }
      continue;
    }
    if (arg.charAt(0) === "-") {
      flags[arg.slice(1)] = true;
      continue;
    }

    if (!command) command = arg;
    else if (!subcommand) subcommand = arg;
    else positional.push(arg);
  }

  return { command: command, subcommand: subcommand, flags: flags, positional: positional };
}

var parsed = parseArgs(process.argv);

// Handle --version immediately (no loading needed)
if (parsed.flags.version) {
  var pkg = require(path.join(__dirname, "..", "package.json"));
  console.log(pkg.version);
  process.exit(0);
}

// Handle --help
if (parsed.flags.help || !parsed.command) {
  console.log("");
  console.log("  " + c.bold("mytool") + " - Optimized CLI example");
  console.log("");
  console.log("  " + c.bold("Commands:"));
  console.log("    search <pattern>    Search files for a pattern");
  console.log("    stats               Show project statistics");
  console.log("    deploy <target>     Deploy the application");
  console.log("    config show         Show configuration");
  console.log("");
  console.log("  " + c.bold("Options:"));
  console.log("    --version, -V       Show version");
  console.log("    --help, -h          Show help");
  console.log("    --verbose, -v       Verbose output");
  console.log("    --timing            Show timing information");
  console.log("");
  process.exit(0);
}

// Timing setup
var timing = parsed.flags.timing || !!process.env.CLI_TIMING;

function reportTime(label) {
  if (!timing) return;
  var elapsed = Number(process.hrtime.bigint() - startTime) / 1e6;
  process.stderr.write(c.dim("[" + elapsed.toFixed(1) + "ms] " + label) + "\n");
}

reportTime("args parsed");

// Route to command handlers (lazy-loaded)
switch (parsed.command) {
  case "search":
    reportTime("loading search");
    var pattern = parsed.subcommand;
    if (!pattern) {
      console.error(c.red("Error:") + " Missing search pattern");
      process.exit(1);
    }

    // Stream search - no heavy deps needed
    var dir = parsed.flags.dir || process.cwd();
    var matchCount = 0;
    var fileCount = 0;

    function searchDir(dirPath) {
      var entries = fs.readdirSync(dirPath, { withFileTypes: true });

      for (var i = 0; i < entries.length; i++) {
        var entry = entries[i];
        if (entry.name.charAt(0) === "." || entry.name === "node_modules") continue;

        var fullPath = path.join(dirPath, entry.name);

        if (entry.isDirectory()) {
          searchDir(fullPath);
          continue;
        }

        if (!entry.name.match(/\.(js|ts|json|md|yaml|yml)$/)) continue;

        try {
          var content = fs.readFileSync(fullPath, "utf8");
          var lines = content.split("\n");

          for (var j = 0; j < lines.length; j++) {
            if (lines[j].indexOf(pattern) !== -1) {
              if (matchCount === 0) console.log("");
              var relPath = path.relative(process.cwd(), fullPath);
              console.log(
                c.cyan(relPath) + ":" + c.yellow(String(j + 1)) +
                ": " + lines[j].trim()
              );
              matchCount++;
            }
          }
          fileCount++;
        } catch (e) {
          // Skip binary files
        }
      }
    }

    searchDir(dir);
    console.log("");
    console.log(c.dim(matchCount + " matches in " + fileCount + " files"));
    reportTime("search complete");
    break;

  case "stats":
    reportTime("loading stats");
    // Compute stats without external deps
    var statsDir = process.cwd();
    var stats = { files: 0, lines: 0, bytes: 0, types: {} };

    function statDir(d) {
      var entries = fs.readdirSync(d, { withFileTypes: true });
      for (var i = 0; i < entries.length; i++) {
        if (entries[i].name.charAt(0) === "." || entries[i].name === "node_modules") continue;
        var fp = path.join(d, entries[i].name);
        if (entries[i].isDirectory()) {
          statDir(fp);
        } else {
          stats.files++;
          var ext = path.extname(entries[i].name) || "(none)";
          stats.types[ext] = (stats.types[ext] || 0) + 1;
          try {
            var st = fs.statSync(fp);
            stats.bytes += st.size;
            var content = fs.readFileSync(fp, "utf8");
            stats.lines += content.split("\n").length;
          } catch (e) {}
        }
      }
    }

    statDir(statsDir);

    console.log("");
    console.log("  " + c.bold("Project Statistics"));
    console.log("");
    console.log("  Files:  " + stats.files);
    console.log("  Lines:  " + stats.lines.toLocaleString());
    console.log("  Size:   " + (stats.bytes / 1024).toFixed(0) + " KB");
    console.log("");
    console.log("  " + c.bold("By type:"));

    var types = Object.keys(stats.types).sort(function(a, b) {
      return stats.types[b] - stats.types[a];
    });
    for (var t = 0; t < Math.min(types.length, 10); t++) {
      console.log("    " + types[t].padEnd(10) + " " + stats.types[types[t]]);
    }
    console.log("");
    reportTime("stats complete");
    break;

  case "deploy":
    reportTime("loading deploy module");
    // Lazy-load heavy deps only for deploy
    var target = parsed.subcommand || "staging";
    console.log(c.bold("Deploying to " + target + "..."));
    // Heavy modules loaded here, only when deploy runs
    reportTime("deploy complete");
    break;

  default:
    console.error(c.red("Unknown command: ") + parsed.command);
    console.error("Run " + c.cyan("mytool --help") + " for usage");
    process.exit(1);
}

if (timing) {
  var totalTime = Number(process.hrtime.bigint() - startTime) / 1e6;
  process.stderr.write(c.dim("\n[total] " + totalTime.toFixed(1) + "ms\n"));
}

Run with timing:

$ mytool --version --timing
1.0.0
[2.1ms] version printed

$ mytool search "require" --timing
[1.8ms] args parsed
[1.9ms] loading search

src/cli.js:3: var fs = require("fs");
src/cli.js:4: var path = require("path");
src/commands/deploy.js:1: var http = require("http");

3 matches in 12 files
[45.2ms] search complete

[total] 45.3ms

Common Issues and Troubleshooting

--version takes 200ms+ to respond

Your entry point loads heavy modules before checking for --version:

Fix: Check --version before any require() calls. Parse it manually from process.argv:

if (process.argv.indexOf("--version") !== -1) {
  console.log(require("./package.json").version);
  process.exit(0);
}
// Now load the heavy framework

Startup is fast locally but slow in CI

CI runners often have cold npm caches and slower disks. The V8 compile cache is also cold:

Fix: Enable V8 compile cache in CI. Consider pre-building your CLI with esbuild or ncc to bundle everything into a single file.

Memory usage grows with large output

Building a huge array and then JSON.stringify-ing it uses 2x the memory:

Fix: Stream output. Write items as they are processed instead of buffering them.

Lazy loading breaks require in transpiled code

Bundlers like webpack inline require() calls. Dynamic requires inside functions may not be resolved:

Fix: Use dynamic require() with string variables that bundlers cannot statically analyze, or configure your bundler to leave specific requires untouched.

Cache files cause stale behavior

Users update config but the CLI shows old values from cache:

Fix: Include a hash of the source data in the cache key. When the source changes, the cache key changes and the old cache is invalidated.

Best Practices

  • Measure before optimizing. Add --timing support to identify where time is actually spent. Intuition about bottlenecks is usually wrong.
  • Lazy-load everything that is not needed for argument routing. The only modules that must load at startup are your argument parser and the minimum for command dispatch.
  • Replace heavy dependencies with built-in modules. chalk can be replaced with 10 lines of ANSI codes. axios can be replaced with built-in https. fs-extra is mostly unnecessary since Node.js 16.
  • Handle --version and --help before loading anything. These are the most common invocations and should return in under 20ms.
  • Cache expensive computations between runs. Config parsing, API responses, and file system scans can all be cached with TTL-based invalidation.
  • Stream output for large results. Never buffer thousands of items just to print them. Write items as they are generated.
  • Profile with --cpu-prof periodically. New dependencies or features may introduce regressions. Catch them early.
  • Bundle for production. Tools like esbuild or ncc compile your entire CLI into a single file, eliminating require() resolution overhead entirely.

References

Powered by Contentful