Cli Tools

Progress Indicators and Spinners in CLIs

How to build spinners, progress bars, and multi-step status indicators for command-line tools in Node.js from scratch and with libraries.

Progress Indicators and Spinners in CLIs

Nothing makes a CLI tool feel broken like silence during a long operation. Users stare at a frozen terminal wondering if the process crashed, if they should Ctrl+C, or if they should just wait. Progress indicators solve this by telling users three things: the tool is alive, what it is doing, and ideally how long they have to wait.

I have shipped CLI tools where adding a simple spinner doubled user satisfaction scores. The perception of speed matters as much as actual speed. This guide covers building every type of progress indicator from scratch, then shows how to compose them into polished multi-step displays.

Prerequisites

  • Node.js installed (v14+)
  • Understanding of process.stdout.write and ANSI escape codes
  • Familiarity with setInterval and async patterns
  • Basic knowledge of TTY/terminal behavior

Spinner Fundamentals

A spinner is an animation that cycles through frames on a single line. The trick is rewriting the same line repeatedly using carriage return (\r) and line clearing.

function createSpinner(text) {
  var frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  var frameIndex = 0;
  var interval = null;

  function render() {
    var frame = frames[frameIndex % frames.length];
    process.stdout.write(
      "\r\u001b[2K\u001b[36m" + frame + "\u001b[0m " + text
    );
    frameIndex++;
  }

  return {
    start: function() {
      if (!process.stdout.isTTY) {
        console.log(text + "...");
        return this;
      }
      process.stdout.write("\u001b[?25l"); // Hide cursor
      render();
      interval = setInterval(render, 80);
      return this;
    },

    update: function(newText) {
      text = newText;
      return this;
    },

    succeed: function(msg) {
      this.stop();
      process.stdout.write(
        "\r\u001b[2K\u001b[32m✔\u001b[0m " + (msg || text) + "\n"
      );
      return this;
    },

    fail: function(msg) {
      this.stop();
      process.stdout.write(
        "\r\u001b[2K\u001b[31m✖\u001b[0m " + (msg || text) + "\n"
      );
      return this;
    },

    warn: function(msg) {
      this.stop();
      process.stdout.write(
        "\r\u001b[2K\u001b[33m⚠\u001b[0m " + (msg || text) + "\n"
      );
      return this;
    },

    stop: function() {
      if (interval) {
        clearInterval(interval);
        interval = null;
      }
      process.stdout.write("\u001b[?25h"); // Show cursor
      return this;
    }
  };
}

// Usage
var spinner = createSpinner("Deploying to production").start();

setTimeout(function() {
  spinner.update("Building Docker image");
}, 2000);

setTimeout(function() {
  spinner.update("Pushing to registry");
}, 4000);

setTimeout(function() {
  spinner.succeed("Deployed successfully in 6.2s");
}, 6000);

Terminal output (animated):

⠹ Deploying to production

Then:

✔ Deployed successfully in 6.2s

Spinner Frame Collections

Different spinner styles suit different tools:

var SPINNERS = {
  dots: {
    frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
    interval: 80
  },
  line: {
    frames: ["-", "\\", "|", "/"],
    interval: 130
  },
  arrow: {
    frames: ["▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"],
    interval: 120
  },
  bounce: {
    frames: ["⠁", "⠂", "⠄", "⠂"],
    interval: 120
  },
  clock: {
    frames: ["🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛"],
    interval: 100
  },
  earth: {
    frames: ["🌍", "🌎", "🌏"],
    interval: 180
  },
  blocks: {
    frames: ["▖", "▘", "▝", "▗"],
    interval: 100
  },
  simpleDots: {
    frames: [".  ", ".. ", "...", "   "],
    interval: 300
  }
};

function createStyledSpinner(text, style) {
  var spinnerDef = SPINNERS[style] || SPINNERS.dots;
  var frameIndex = 0;
  var interval = null;

  return {
    start: function() {
      if (!process.stdout.isTTY) {
        console.log(text + "...");
        return this;
      }
      process.stdout.write("\u001b[?25l");

      var self = this;
      function tick() {
        var frame = spinnerDef.frames[frameIndex % spinnerDef.frames.length];
        process.stdout.write("\r\u001b[2K" + frame + " " + text);
        frameIndex++;
      }

      tick();
      interval = setInterval(tick, spinnerDef.interval);
      return this;
    },

    stop: function() {
      if (interval) clearInterval(interval);
      process.stdout.write("\u001b[?25h");
    },

    succeed: function(msg) {
      this.stop();
      process.stdout.write("\r\u001b[2K\u001b[32m✔\u001b[0m " + (msg || text) + "\n");
    }
  };
}

Building a Progress Bar

Progress bars show how far along a determinate task has progressed. They are essential for downloads, file processing, and batch operations.

function createProgressBar(options) {
  options = options || {};
  var total = options.total || 100;
  var width = options.width || 30;
  var current = 0;
  var startTime = Date.now();
  var label = options.label || "Progress";

  function formatTime(ms) {
    var seconds = Math.floor(ms / 1000);
    var minutes = Math.floor(seconds / 60);
    seconds = seconds % 60;
    if (minutes > 0) {
      return minutes + "m " + seconds + "s";
    }
    return seconds + "s";
  }

  function formatBytes(bytes) {
    if (bytes < 1024) return bytes + " B";
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
    if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
    return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
  }

  function render() {
    if (!process.stdout.isTTY) return;

    var percent = Math.min(Math.floor((current / total) * 100), 100);
    var filled = Math.floor((current / total) * width);
    var empty = width - filled;

    var bar = "\u001b[32m" + "█".repeat(filled) + "\u001b[0m" +
              "\u001b[90m" + "░".repeat(empty) + "\u001b[0m";

    var elapsed = Date.now() - startTime;
    var rate = current / (elapsed / 1000);
    var eta = rate > 0 ? (total - current) / rate * 1000 : 0;

    var info = "";
    if (options.showBytes) {
      info = formatBytes(current) + "/" + formatBytes(total);
    } else {
      info = current + "/" + total;
    }

    var line = "\r\u001b[2K" + label + " " + bar + " " +
      percent + "% " +
      "\u001b[90m" + info +
      " | " + formatTime(elapsed) +
      " | ETA: " + formatTime(eta) + "\u001b[0m";

    process.stdout.write(line);
  }

  return {
    update: function(value) {
      current = value;
      render();
      return this;
    },

    increment: function(amount) {
      current += (amount || 1);
      if (current > total) current = total;
      render();
      return this;
    },

    done: function(msg) {
      current = total;
      render();
      var elapsed = Date.now() - startTime;
      process.stdout.write(
        "\r\u001b[2K\u001b[32m✔\u001b[0m " +
        (msg || label + " complete") +
        " \u001b[90m(" + formatTime(elapsed) + ")\u001b[0m\n"
      );
      return this;
    }
  };
}

// Usage: File processing
var bar = createProgressBar({
  total: 150,
  label: "Processing files",
  width: 25
});

var count = 0;
var timer = setInterval(function() {
  count++;
  bar.increment();

  if (count >= 150) {
    clearInterval(timer);
    bar.done();
  }
}, 50);

Terminal output (animated):

Processing files ████████████░░░░░░░░░░░░░ 48% 72/150 | 3s | ETA: 4s

Then:

✔ Processing files complete (7s)

Download Progress Bar

For downloads, show bytes transferred and transfer speed:

var http = require("http");
var https = require("https");
var fs = require("fs");

function downloadWithProgress(url, destPath) {
  return new Promise(function(resolve, reject) {
    var client = url.indexOf("https") === 0 ? https : http;

    client.get(url, function(response) {
      if (response.statusCode === 302 || response.statusCode === 301) {
        downloadWithProgress(response.headers.location, destPath)
          .then(resolve)
          .catch(reject);
        return;
      }

      var totalBytes = parseInt(response.headers["content-length"], 10);
      var receivedBytes = 0;

      var bar = createProgressBar({
        total: totalBytes || 0,
        label: "Downloading",
        width: 25,
        showBytes: true
      });

      var fileStream = fs.createWriteStream(destPath);

      response.on("data", function(chunk) {
        receivedBytes += chunk.length;
        if (totalBytes) {
          bar.update(receivedBytes);
        }
      });

      response.pipe(fileStream);

      fileStream.on("finish", function() {
        bar.done("Downloaded " + destPath);
        fileStream.close();
        resolve(destPath);
      });

      fileStream.on("error", function(err) {
        fs.unlink(destPath, function() {});
        reject(err);
      });
    }).on("error", function(err) {
      reject(err);
    });
  });
}

Output:

Downloading ████████████████░░░░░░░░░ 65% 3.2 MB/4.9 MB | 5s | ETA: 3s

Multi-Line Progress Display

When running multiple parallel tasks, you need to update several lines simultaneously. This requires careful cursor management.

function createMultiProgress(tasks) {
  var states = {};
  var order = [];
  var rendered = false;

  for (var i = 0; i < tasks.length; i++) {
    var task = tasks[i];
    states[task.id] = {
      label: task.label,
      status: "pending",  // pending, running, done, failed
      progress: 0,
      total: task.total || 0,
      message: ""
    };
    order.push(task.id);
  }

  var spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  var frameIndex = 0;
  var interval = null;

  function statusIcon(status) {
    switch (status) {
      case "pending": return "\u001b[90m○\u001b[0m";
      case "running": return "\u001b[36m" + spinnerFrames[frameIndex % spinnerFrames.length] + "\u001b[0m";
      case "done":    return "\u001b[32m✔\u001b[0m";
      case "failed":  return "\u001b[31m✖\u001b[0m";
      default:        return " ";
    }
  }

  function miniBar(current, total, width) {
    if (!total) return "";
    var filled = Math.floor((current / total) * width);
    var empty = width - filled;
    return " \u001b[32m" + "█".repeat(filled) + "\u001b[90m" + "░".repeat(empty) + "\u001b[0m" +
           " " + Math.floor((current / total) * 100) + "%";
  }

  function render() {
    if (!process.stdout.isTTY) return;

    // Move cursor up to overwrite previous render
    if (rendered) {
      process.stdout.write("\u001b[" + order.length + "A");
    }

    for (var i = 0; i < order.length; i++) {
      var state = states[order[i]];
      var line = "\u001b[2K  " + statusIcon(state.status) + " " + state.label;

      if (state.status === "running" && state.total > 0) {
        line += miniBar(state.progress, state.total, 15);
      }

      if (state.message) {
        line += " \u001b[90m" + state.message + "\u001b[0m";
      }

      process.stdout.write(line + "\n");
    }

    rendered = true;
    frameIndex++;
  }

  return {
    start: function() {
      process.stdout.write("\u001b[?25l");
      render();
      interval = setInterval(render, 80);
      return this;
    },

    updateTask: function(id, updates) {
      if (!states[id]) return this;
      var keys = Object.keys(updates);
      for (var i = 0; i < keys.length; i++) {
        states[id][keys[i]] = updates[keys[i]];
      }
      return this;
    },

    completeTask: function(id, message) {
      if (!states[id]) return this;
      states[id].status = "done";
      states[id].message = message || "";
      return this;
    },

    failTask: function(id, message) {
      if (!states[id]) return this;
      states[id].status = "failed";
      states[id].message = message || "";
      return this;
    },

    done: function() {
      if (interval) clearInterval(interval);
      render(); // Final render
      process.stdout.write("\u001b[?25h");
      return this;
    }
  };
}

// Usage
var multi = createMultiProgress([
  { id: "lint",  label: "Linting code" },
  { id: "test",  label: "Running tests", total: 48 },
  { id: "build", label: "Building project" },
  { id: "push",  label: "Pushing to registry" }
]);

multi.start();

// Simulate task progression
multi.updateTask("lint", { status: "running" });

setTimeout(function() {
  multi.completeTask("lint", "0 errors");
  multi.updateTask("test", { status: "running" });
}, 1500);

var testCount = 0;
var testInterval = setInterval(function() {
  testCount++;
  multi.updateTask("test", { progress: testCount });
  if (testCount >= 48) {
    clearInterval(testInterval);
    multi.completeTask("test", "48 passed");
    multi.updateTask("build", { status: "running" });

    setTimeout(function() {
      multi.completeTask("build", "dist/app.js (2.1 MB)");
      multi.updateTask("push", { status: "running" });

      setTimeout(function() {
        multi.completeTask("push", "v1.2.0");
        multi.done();
        console.log("\n\u001b[32m\u001b[1mAll tasks completed!\u001b[0m\n");
      }, 2000);
    }, 3000);
  }
}, 100);

Terminal output:

  ✔ Linting code 0 errors
  ⠹ Running tests ████████████░░░ 75%
  ○ Building project
  ○ Pushing to registry

Final state:

  ✔ Linting code 0 errors
  ✔ Running tests 48 passed
  ✔ Building project dist/app.js (2.1 MB)
  ✔ Pushing to registry v1.2.0

All tasks completed!

Elapsed Time and ETA Calculation

Accurate ETA calculation uses exponential moving average to smooth out rate fluctuations:

function createEtaCalculator() {
  var samples = [];
  var maxSamples = 20;
  var smoothingFactor = 0.3;
  var ema = null;
  var startTime = Date.now();

  return {
    update: function(current, total) {
      var now = Date.now();
      var elapsed = now - startTime;

      if (elapsed === 0 || current === 0) {
        return { elapsed: 0, eta: Infinity, rate: 0, percent: 0 };
      }

      var instantRate = current / (elapsed / 1000); // items per second

      // Exponential moving average for rate
      if (ema === null) {
        ema = instantRate;
      } else {
        ema = smoothingFactor * instantRate + (1 - smoothingFactor) * ema;
      }

      var remaining = total - current;
      var eta = ema > 0 ? (remaining / ema) * 1000 : Infinity;

      return {
        elapsed: elapsed,
        eta: Math.max(0, eta),
        rate: ema,
        percent: Math.min((current / total) * 100, 100)
      };
    },

    format: function(ms) {
      if (!isFinite(ms)) return "--:--";
      var totalSec = Math.ceil(ms / 1000);
      var hours = Math.floor(totalSec / 3600);
      var minutes = Math.floor((totalSec % 3600) / 60);
      var seconds = totalSec % 60;

      if (hours > 0) {
        return hours + ":" + pad(minutes) + ":" + pad(seconds);
      }
      return minutes + ":" + pad(seconds);
    }
  };

  function pad(n) {
    return n < 10 ? "0" + n : String(n);
  }
}

// Usage in a progress bar
var eta = createEtaCalculator();
var total = 1000;
var current = 0;

var timer = setInterval(function() {
  current += Math.floor(Math.random() * 20) + 5;
  if (current > total) current = total;

  var stats = eta.update(current, total);
  var barWidth = 20;
  var filled = Math.floor((stats.percent / 100) * barWidth);

  process.stdout.write(
    "\r\u001b[2K" +
    "█".repeat(filled) + "░".repeat(barWidth - filled) +
    " " + Math.floor(stats.percent) + "%" +
    " | " + Math.floor(stats.rate) + " items/s" +
    " | ETA: " + eta.format(stats.eta) +
    " | Elapsed: " + eta.format(stats.elapsed)
  );

  if (current >= total) {
    clearInterval(timer);
    process.stdout.write(
      "\r\u001b[2K\u001b[32m✔\u001b[0m Complete in " +
      eta.format(stats.elapsed) + "\n"
    );
  }
}, 100);

Step Indicators for Sequential Operations

For operations that go through known phases, step indicators show where you are in the process:

function createStepper(steps) {
  var currentStep = -1;
  var startTimes = {};
  var spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  var frameIndex = 0;
  var interval = null;

  function formatMs(ms) {
    if (ms < 1000) return ms + "ms";
    return (ms / 1000).toFixed(1) + "s";
  }

  function render() {
    if (!process.stdout.isTTY) return;

    // Move up to overwrite
    if (currentStep >= 0) {
      process.stdout.write("\u001b[" + steps.length + "A");
    }

    for (var i = 0; i < steps.length; i++) {
      var step = steps[i];
      var line = "\u001b[2K";
      var stepNum = "[" + (i + 1) + "/" + steps.length + "]";

      if (i < currentStep) {
        // Completed
        var elapsed = startTimes[i + 1]
          ? startTimes[i + 1] - startTimes[i]
          : Date.now() - startTimes[i];
        line += "\u001b[32m✔ " + stepNum + "\u001b[0m " + step +
          " \u001b[90m(" + formatMs(elapsed) + ")\u001b[0m";
      } else if (i === currentStep) {
        // Current
        var frame = spinnerFrames[frameIndex % spinnerFrames.length];
        line += "\u001b[36m" + frame + " " + stepNum + "\u001b[0m \u001b[1m" + step + "\u001b[0m";
      } else {
        // Pending
        line += "\u001b[90m○ " + stepNum + " " + step + "\u001b[0m";
      }

      process.stdout.write(line + "\n");
    }

    frameIndex++;
  }

  return {
    start: function() {
      process.stdout.write("\u001b[?25l");
      console.log(); // Empty line for spacing
      currentStep = 0;
      startTimes[0] = Date.now();
      render();
      interval = setInterval(render, 80);
      return this;
    },

    next: function() {
      currentStep++;
      startTimes[currentStep] = Date.now();
      return this;
    },

    done: function() {
      currentStep = steps.length; // Mark all complete
      if (interval) clearInterval(interval);
      render();
      process.stdout.write("\u001b[?25h");
      return this;
    },

    fail: function(msg) {
      if (interval) clearInterval(interval);
      // Re-render with failure icon on current step
      process.stdout.write("\u001b[" + steps.length + "A");
      for (var i = 0; i < steps.length; i++) {
        var line = "\u001b[2K";
        var stepNum = "[" + (i + 1) + "/" + steps.length + "]";

        if (i < currentStep) {
          line += "\u001b[32m✔ " + stepNum + "\u001b[0m " + steps[i];
        } else if (i === currentStep) {
          line += "\u001b[31m✖ " + stepNum + "\u001b[0m " + steps[i] +
            (msg ? " \u001b[31m(" + msg + ")\u001b[0m" : "");
        } else {
          line += "\u001b[90m○ " + stepNum + " " + steps[i] + "\u001b[0m";
        }

        process.stdout.write(line + "\n");
      }
      process.stdout.write("\u001b[?25h");
      return this;
    }
  };
}

// Usage
var stepper = createStepper([
  "Installing dependencies",
  "Compiling TypeScript",
  "Running tests",
  "Building production bundle",
  "Uploading artifacts"
]);

stepper.start();

setTimeout(function() { stepper.next(); }, 2000);
setTimeout(function() { stepper.next(); }, 4000);
setTimeout(function() { stepper.next(); }, 7000);
setTimeout(function() { stepper.next(); }, 9000);
setTimeout(function() {
  stepper.done();
  console.log("\n\u001b[32m\u001b[1mDeploy complete!\u001b[0m\n");
}, 11000);

Output during step 3:

✔ [1/5] Installing dependencies (2.0s)
✔ [2/5] Compiling TypeScript (2.0s)
⠹ [3/5] Running tests
○ [4/5] Building production bundle
○ [5/5] Uploading artifacts

Complete Working Example: Build Pipeline with Full Progress

Here is a complete build pipeline CLI that combines spinners, progress bars, steps, and multi-task progress:

#!/usr/bin/env node

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

// ---- ANSI utilities ----

var ansi = {
  hide: "\u001b[?25l",
  show: "\u001b[?25h",
  clear: "\u001b[2K",
  reset: "\u001b[0m",
  bold: "\u001b[1m",
  dim: "\u001b[90m",
  green: "\u001b[32m",
  red: "\u001b[31m",
  yellow: "\u001b[33m",
  cyan: "\u001b[36m",
  up: function(n) { return "\u001b[" + n + "A"; }
};

// ---- Timer ----

function timer() {
  var start = Date.now();
  return {
    elapsed: function() {
      var ms = Date.now() - start;
      if (ms < 1000) return ms + "ms";
      return (ms / 1000).toFixed(1) + "s";
    },
    ms: function() { return Date.now() - start; }
  };
}

// ---- Spinner ----

function spin(text) {
  var frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  var idx = 0;
  var iv = null;
  var isTTY = process.stdout.isTTY;

  function draw() {
    process.stdout.write("\r" + ansi.clear + ansi.cyan +
      frames[idx++ % frames.length] + ansi.reset + " " + text);
  }

  return {
    start: function() {
      if (!isTTY) { console.log("  " + text + "..."); return this; }
      process.stdout.write(ansi.hide);
      draw();
      iv = setInterval(draw, 80);
      return this;
    },
    text: function(t) { text = t; return this; },
    ok: function(msg) {
      if (iv) clearInterval(iv);
      if (isTTY) {
        process.stdout.write("\r" + ansi.clear + ansi.green + "✔" +
          ansi.reset + " " + (msg || text) + "\n" + ansi.show);
      }
      return this;
    },
    fail: function(msg) {
      if (iv) clearInterval(iv);
      if (isTTY) {
        process.stdout.write("\r" + ansi.clear + ansi.red + "✖" +
          ansi.reset + " " + (msg || text) + "\n" + ansi.show);
      }
      return this;
    }
  };
}

// ---- Progress Bar ----

function progress(label, total, opts) {
  opts = opts || {};
  var width = opts.width || 20;
  var current = 0;
  var clock = timer();

  return {
    tick: function(n) {
      current += (n || 1);
      if (current > total) current = total;
      if (!process.stdout.isTTY) return this;

      var pct = Math.floor((current / total) * 100);
      var filled = Math.floor((current / total) * width);
      var bar = ansi.green + "█".repeat(filled) +
                ansi.dim + "░".repeat(width - filled) + ansi.reset;

      process.stdout.write("\r" + ansi.clear + "  " + label + " " +
        bar + " " + pct + "% " + ansi.dim + current + "/" + total + ansi.reset);

      return this;
    },
    done: function(msg) {
      if (process.stdout.isTTY) {
        process.stdout.write("\r" + ansi.clear + "  " + ansi.green + "✔" +
          ansi.reset + " " + (msg || label) + " " +
          ansi.dim + "(" + clock.elapsed() + ")" + ansi.reset + "\n");
      }
      return this;
    }
  };
}

// ---- Pipeline ----

function simulateStep(label, durationMs) {
  return new Promise(function(resolve) {
    var s = spin(label).start();
    setTimeout(function() {
      s.ok(label);
      resolve();
    }, durationMs);
  });
}

function simulateProgress(label, total, intervalMs) {
  return new Promise(function(resolve) {
    var bar = progress(label, total);
    var count = 0;
    var iv = setInterval(function() {
      count++;
      bar.tick();
      if (count >= total) {
        clearInterval(iv);
        bar.done();
        resolve();
      }
    }, intervalMs);
  });
}

function runPipeline() {
  var pipeTimer = timer();

  console.log("");
  console.log(ansi.bold + "Build Pipeline" + ansi.reset);
  console.log(ansi.dim + "─".repeat(40) + ansi.reset);
  console.log("");

  simulateStep("Checking environment", 800)
    .then(function() {
      return simulateStep("Resolving dependencies", 1200);
    })
    .then(function() {
      return simulateProgress("Installing packages", 45, 40);
    })
    .then(function() {
      return simulateStep("Compiling source", 2000);
    })
    .then(function() {
      return simulateProgress("Running tests", 128, 30);
    })
    .then(function() {
      return simulateStep("Generating source maps", 600);
    })
    .then(function() {
      return simulateStep("Optimizing bundle", 1500);
    })
    .then(function() {
      return simulateProgress("Uploading artifacts", 12, 200);
    })
    .then(function() {
      console.log("");
      console.log(ansi.dim + "─".repeat(40) + ansi.reset);
      console.log(
        ansi.green + ansi.bold + "✔ Pipeline complete" + ansi.reset +
        " " + ansi.dim + "(" + pipeTimer.elapsed() + ")" + ansi.reset
      );
      console.log("");

      // Summary
      console.log("  " + ansi.bold + "Output:" + ansi.reset + "  dist/app.min.js");
      console.log("  " + ansi.bold + "Size:" + ansi.reset + "    247 KB (gzipped: 68 KB)");
      console.log("  " + ansi.bold + "Tests:" + ansi.reset + "   128 passed, 0 failed");
      console.log("  " + ansi.bold + "Version:" + ansi.reset + " 2.1.0");
      console.log("");
    });
}

runPipeline();

Full terminal output:

Build Pipeline
────────────────────────────────────────

✔ Checking environment
✔ Resolving dependencies
  ✔ Installing packages (1.8s)
✔ Compiling source
  ✔ Running tests (3.8s)
✔ Generating source maps
✔ Optimizing bundle
  ✔ Uploading artifacts (2.4s)

────────────────────────────────────────
✔ Pipeline complete (12.3s)

  Output:  dist/app.min.js
  Size:    247 KB (gzipped: 68 KB)
  Tests:   128 passed, 0 failed
  Version: 2.1.0

Common Issues and Troubleshooting

Spinner continues after process exits

If you do not clear the interval, the Node.js event loop keeps running:

$ mytool build
⠹ Building...  # Never exits

Fix: Always call clearInterval and set ref: false on the timer if possible:

var iv = setInterval(render, 80);
if (iv.unref) iv.unref(); // Allow process to exit

Progress bar flickers on slow terminals

Writing too frequently overwhelms the terminal buffer:

Fix: Throttle renders to at most once per frame (16ms) or only render on percentage change:

var lastPercent = -1;
function throttledRender(current, total) {
  var percent = Math.floor((current / total) * 100);
  if (percent === lastPercent) return;
  lastPercent = percent;
  render();
}

Multi-line progress corrupted by log output

If something writes to stdout between progress updates, the cursor position gets confused:

Fix: Intercept console.log during multi-line progress and buffer messages for display after completion, or use stderr for logging while progress uses stdout.

Progress percentage exceeds 100% or goes backward

When total is estimated and more items arrive than expected:

Fix: Cap current at total and handle the case where total changes during processing:

if (current > total) total = current;

ANSI codes appear as text in log files

When output is redirected to a file, escape codes pollute the log:

[32m✔[0m Build complete

Fix: Strip ANSI when stdout is not a TTY:

function strip(str) {
  if (process.stdout.isTTY) return str;
  return str.replace(/\u001b\[[0-9;]*m/g, "");
}

Best Practices

  • Always degrade gracefully for non-TTY. Check process.stdout.isTTY and fall back to simple console.log for piped output and log files.
  • Hide the cursor during animation and restore it on exit. Register cleanup on process.on("exit"), SIGINT, and SIGTERM.
  • Use exponential moving average for ETA. Instantaneous rate changes make the ETA jump wildly. Smoothing produces stable estimates.
  • Throttle render frequency. Updating the terminal more than 30 times per second wastes CPU and causes flicker. Render on meaningful changes, not every tick.
  • Show elapsed time on completion. Users want to know how long things took, not just that they finished.
  • Respect NO_COLOR and TERM=dumb. Some environments cannot render escape codes. Check these variables and disable formatting.
  • Keep spinner text short. Long messages with spinners wrap awkwardly on narrow terminals. Show detail after completion, not during.
  • Use progress bars for determinate tasks, spinners for indeterminate. A progress bar stuck at 50% is more stressful than a spinner. Only use bars when you actually know the total.

References

Powered by Contentful