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.writeand ANSI escape codes - Familiarity with
setIntervaland 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.isTTYand fall back to simpleconsole.logfor piped output and log files. - Hide the cursor during animation and restore it on exit. Register cleanup on
process.on("exit"),SIGINT, andSIGTERM. - 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_COLORandTERM=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.