mrkeyoor.com_
Thu 06 Aug 15:38 UTC
npmCLI & Toolingupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5start, update, increment, setTotal, stop and MultiBar.create have kept the same signatures across the whole 3.x line, and 3.12.0 has been the published version since February 2023. The stability is real, but it is the stability of a project where nothing ships.
Docs4/5The README documents every option with its default, covers both bar modes, all format placeholders, custom formatter callbacks and presets, and there is a separate events page plus a runnable examples directory. It loses a point for small errors that cost real debugging time, such as the hyphenated preset names and a require('cli-progess') typo in the formatter section.
Maintenance2/5Last npm publish was February 2023 and the last commit October 2023, with 17 open issues (26 counting PRs) sitting unanswered. It is not deprecated or archived and it keeps working, but no one is fixing anything, including the string-width v4 pin.
Ecosystem4/5About 10.5M downloads a week means it is embedded across Node CLI tooling and nearly every failure mode has been hit and written up by someone. The catch is that community answers and the DefinitelyTyped package are now the only maintenance the library receives.

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
Skip it if

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 total

stopOnComplete 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

PackageRegistryPick it when
oranpmThe task has no countable total and a spinner with status text is the honest UI.
listr2npmYou have a list of named steps rather than one long task, and want per-task status, nesting and concurrency.
inknpmThe CLI needs a real layout with several live regions and you are willing to run React to get it.