cli-progress
cli-progress draws progress bars in a terminal. You construct a bar, call start(total, startValue), then update() or increment() as work completes, then stop(). Redrawing is throttled by an fps option rather than happening on every call, so a loop that increments a million times does not spend its life emitting escape sequences. On top of the single bar it gives you a multi-bar container where several bars stack and update independently, a format string with placeholders for value, total, percentage, ETA and duration plus any custom fields you attach, four built-in themes, and a non-TTY mode that prints periodic lines instead of redrawing so the output stays readable when piped to a log file.
Still the most capable progress bar in the Node ecosystem, with multi-bar, payloads and non-TTY output that the alternatives do not match. Choose it knowing you are adopting a frozen package: it has not been published since February 2023, so whatever it does today is the full extent of what it will ever do.
Use it if
- Your Node CLI does something countable and slow (files to convert, rows to import, bytes to fetch) and users deserve better feedback than a spinner that cannot tell them how much is left
- You need several bars at once, one per parallel worker or download, and you need to print log lines above them without shredding the display; MultiBar.log() exists for exactly that
- The same command runs interactively and in CI, and you want one code path: set noTTYOutput and it emits a line every couple of seconds instead of cursor-movement sequences
- You want extra fields in the bar such as the current filename, throughput or retry count, which the payload mechanism and format placeholders cover without writing a renderer
- You need a maintained dependency: 3.12.0 was published in February 2023 and the last commit landed in October 2023. That is close to three years of silence with 17 open issues (26 counting PRs), so anything broken today stays broken
- You are strict about ESM: the package is CommonJS with a main field and no exports map. Default-importing it from ESM works through Node's interop, but there is no ESM build and there will not be one
- You want types in the box: none are shipped. You install @types/cli-progress from DefinitelyTyped, itself last published in July 2024, so the type accuracy is now a third party's problem too
- Your work has no known total: a bar that cannot show a percentage is worse than a spinner, and ora does spinners in fewer lines with an actively maintained package
- You need colour: nothing is built in. Every colourised README example installs ansi-colors separately, and the barGlue option exists only because dropping escape sequences into the bar otherwise breaks the width calculation
- You are targeting the legacy Windows command prompt: the README supports PowerShell on Windows 10 and up and explicitly declares the old console out of scope
Setup reality
npm install cli-progress pulls exactly one runtime dependency, string-width v4, which is the last CommonJS release of that package, so a tree that also uses string-width v7 will carry both copies. TypeScript users add @types/cli-progress by hand. Three defaults catch people. Output goes to process.stderr rather than stdout, which is correct if your CLI pipes data out but means the bar lands in your log file when someone redirects stderr. hideCursor defaults to false and gracefulExit also defaults to false, so the moment you enable hideCursor a Ctrl+C leaves the user with an invisible cursor until they run reset; turn gracefulExit on in the same breath. And the preset names differ between the docs and the code: the README lists shades-classic and shades-grey with hyphens, but the exported keys are cliProgress.Presets.shades_classic and shades_grey with underscores. Getting that wrong passes undefined, which merges cleanly and silently gives you the plain default theme instead of an error.
Patterns
One bar around a loopsingle-bar-basic
const cliProgress = require('cli-progress');
const bar = new cliProgress.SingleBar(
{ hideCursor: true, gracefulExit: true },
cliProgress.Presets.shades_classic,
);
bar.start(files.length, 0);
for (const file of files) {
await convert(file);
bar.increment();
}
bar.stop();Always call stop(), including in a catch or finally, or the terminal is left mid-line with the cursor hidden. gracefulExit restores the cursor on SIGINT and SIGTERM, and it is off by default, which is the wrong default whenever hideCursor is on.
Import it from ESM and TypeScriptesm-and-typescript
// npm i cli-progress && npm i -D @types/cli-progress
import cliProgress from 'cli-progress';
import type { SingleBar, Options } from 'cli-progress';
const opts: Options = { format: '{bar} {percentage}% | {value}/{total}' };
const bar: SingleBar = new cliProgress.SingleBar(opts, cliProgress.Presets.rect);The package is CommonJS, so the default import is the safe form across Node ESM and every bundler. Named value imports happen to work under Node's CJS export detection because the entry file assigns an object literal, but bundler behaviour varies and it is not worth the risk.
Show your own fields in the barcustom-format-and-payload
const bar = new cliProgress.SingleBar({
format: '{bar} {percentage}% | {value}/{total} | {filename} | {speed} MB/s',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
});
bar.start(total, 0, { filename: 'starting', speed: 'N/A' });
bar.update(bytesDone, { filename: 'photos.zip', speed: '4.2' });
// payload only, leave the value alone
bar.update({ speed: '3.8' });Declare every payload key in the start() call with a placeholder value; a key that is missing when the bar first renders shows as the literal {speed} text. Payload keys must match \w+, so hyphens and dots in a key silently fail to substitute.
Several bars for parallel workmulti-bar-container
const multibar = new cliProgress.MultiBar({
clearOnComplete: false,
hideCursor: true,
gracefulExit: true,
format: ' {bar} | {name} | {value}/{total}',
}, cliProgress.Presets.shades_grey);
await Promise.all(urls.map(async (url) => {
const bar = multibar.create(await sizeOf(url), 0, { name: basename(url) });
await download(url, (chunk) => bar.increment(chunk.length));
multibar.remove(bar);
}));
multibar.stop();Options and the preset are set on the container and shared by every bar it creates; per-bar overrides go in the fourth argument of create() and should be limited to format. Call multibar.stop() once at the end, not stop() on each child.
Print messages without breaking the displaylog-above-bars
multibar.log(`retrying ${url} (attempt ${n})\n`);
// do NOT do this while bars are live:
// console.log('retrying');The trailing newline is required; without it the buffered output and the bars overwrite each other. A raw console.log writes to stdout while the bars write to stderr, so the two interleave and leave orphan bar fragments scrolling up the screen.
Behave in CI and when piped to a filehandle-non-tty
const isTTY = process.stderr.isTTY;
const bar = new cliProgress.SingleBar({
noTTYOutput: true, // emit periodic lines instead of redrawing
notTTYSchedule: 5000, // one line every 5s
hideCursor: isTTY,
linewrap: null, // leave the terminal's wrapping alone
});Without noTTYOutput a non-TTY stream gets nothing at all, so CI logs go silent for the whole run. Note the option name asymmetry: it is noTTYOutput to enable it and notTTYSchedule to set the interval, which is easy to mistype and produces no error.
Change the total while the bar is runningdynamic-total
const bar = new cliProgress.SingleBar({});
bar.start(queue.length, 0);
while (queue.length) {
const job = queue.shift();
const discovered = await process(job);
queue.push(...discovered);
bar.setTotal(bar.getTotal() + discovered.length);
bar.increment();
}
bar.stop();setTotal recalculates the percentage but not the ETA history, so the estimate stays skewed by the old total for etaBuffer updates. For crawl-style work raise etaBuffer to 50 or more, or drop {eta} from the format entirely.
Replace the format string with a callbackcustom-formatter-function
const { BarFormat } = require('cli-progress').Format;
const bar = new cliProgress.SingleBar({
format: (options, params, payload) => {
const rendered = BarFormat(params.progress, options);
const done = params.value >= params.total;
return `${done ? 'OK ' : '.. '}${rendered} ${params.value}/${params.total} ${payload.task ?? ''}`;
},
});The formatter runs on every redraw, up to fps times a second, so keep it allocation-light. Reuse the built-ins from the Format export (BarFormat, ValueFormat, TimeFormat) rather than reimplementing padding, and note the README spells that require with a typo.
Control redraw rate on very fast loopsthrottle-redraw
const bar = new cliProgress.SingleBar({
fps: 5, // redraw at most 5 times per second
etaBuffer: 100, // smoother ETA over a longer window
synchronousUpdate: false, // do not force a redraw inside update()
forceRedraw: false,
});
bar.start(10_000_000, 0);
for (let i = 0; i < 10_000_000; i++) {
crunch(i);
if (i % 1000 === 0) bar.update(i);
}The library throttles internally, but calling update() ten million times still costs the call and the ETA bookkeeping; batching with a modulo is much cheaper. forceRedraw is worth turning on only when other code writes to the same terminal and overwrites the bar.
Hook the bar lifecyclelisten-to-events
const bar = new cliProgress.SingleBar({});
bar.on('start', () => log.info('import started'));
bar.on('update', (total, value) => metrics.gauge('import.progress', value / total));
bar.on('stop', (total, value) => log.info(`import finished at ${value}/${total}`));
bar.start(total, 0);Both bar classes extend EventEmitter, with start, stop, update, redraw-pre and redraw-post available. The update handler fires on every update() call, not on every redraw, so do not put anything expensive or anything that writes to the terminal inside it.
Define a reusable themecustom-preset
// theme.js
const colors = require('ansi-colors');
module.exports = {
format: colors.cyan(' {bar}') + ' {percentage}% | ETA: {eta}s | {value}/{total}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
barGlue: colors.grey(''),
};
// usage
const bar = new cliProgress.SingleBar({ barsize: 30 }, require('./theme'));A preset is a plain options object merged underneath your own options, so anything you pass in the first argument wins. Any visible characters in barGlue are counted into the width, which is why it is only meant to carry zero-width escape sequences.
Let the bar finish itselfstop-on-complete
const bar = new cliProgress.SingleBar({
stopOnComplete: true,
clearOnComplete: true, // erase the bar line when done
hideCursor: true,
gracefulExit: true,
});
bar.start(items.length, 0);
items.forEach((item) => { handle(item); bar.increment(); });
// no explicit stop() needed once value reaches totalstopOnComplete only triggers when the value actually reaches the total, so an early return or a thrown error still leaves the bar running; keep a stop() in a finally block regardless. clearOnComplete removes the line entirely, which is right for transient work and wrong when the final count is the result.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ora | npm | The task has no countable total and a spinner with status text is the honest UI. |
| listr2 | npm | You have a list of named steps rather than one long task, and want per-task status, nesting and concurrency. |
| ink | npm | The CLI needs a real layout with several live regions and you are willing to run React to get it. |