listr2 vs tasuku vs cli-progress: Task Runners and Progress Bars in Node.js (2026)
TL;DR
listr2 is the full-featured task list runner — sequential and concurrent tasks, nested subtasks, multiple renderers (default, verbose, silent), input prompts, and rollback support. tasuku is the minimal task runner — simple API, concurrent tasks, zero config, looks like vitest output. cli-progress is the progress bar library — single and multi-bar progress indicators, customizable formats, ETA calculation. In 2026: listr2 for complex CLI workflows with subtasks, cli-progress for download/transfer progress bars, tasuku for simple task lists.
Key Takeaways
- listr2: ~10M weekly downloads — full task list runner, used by Angular CLI, Prisma
- tasuku: ~50K weekly downloads — minimal task runner, vitest-like output
- cli-progress: ~5M weekly downloads — progress bars, multi-bar, ETA, custom formats
- Different tools: listr2/tasuku are task list runners, cli-progress is a progress bar
- listr2 supports concurrent tasks, subtasks, rollback, prompts
- cli-progress supports single-bar, multi-bar, and custom token formats
listr2
listr2 — task list runner:
Basic task list
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Installing dependencies",
task: async () => {
await exec("npm install")
},
},
{
title: "Building project",
task: async () => {
await exec("npm run build")
},
},
{
title: "Running tests",
task: async () => {
await exec("npm test")
},
},
])
await tasks.run()
// ✔ Installing dependencies
// ✔ Building project
// ✔ Running tests
Concurrent tasks
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Lint and test",
task: () => new Listr([
{
title: "ESLint",
task: async () => await exec("eslint ."),
},
{
title: "TypeScript",
task: async () => await exec("tsc --noEmit"),
},
{
title: "Vitest",
task: async () => await exec("vitest run"),
},
], { concurrent: true }), // Run all three in parallel
},
{
title: "Build",
task: async () => await exec("npm run build"),
},
])
await tasks.run()
// ◼ Lint and test
// ✔ ESLint
// ✔ TypeScript
// ✔ Vitest
// ✔ Build
Subtasks and context
import { Listr } from "listr2"
interface Context {
packages: string[]
results: Map<string, boolean>
}
const tasks = new Listr<Context>([
{
title: "Discovering packages",
task: async (ctx) => {
ctx.packages = await findWorkspacePackages()
ctx.results = new Map()
},
},
{
title: "Building packages",
task: (ctx, task) => {
return task.newListr(
ctx.packages.map((pkg) => ({
title: `Building ${pkg}`,
task: async () => {
await buildPackage(pkg)
ctx.results.set(pkg, true)
},
})),
{ concurrent: 3 }, // Max 3 concurrent builds
)
},
},
{
title: "Summary",
task: (ctx, task) => {
const success = [...ctx.results.values()].filter(Boolean).length
task.title = `Built ${success}/${ctx.packages.length} packages`
},
},
])
await tasks.run()
Skip, retry, and rollback
import { Listr } from "listr2"
const tasks = new Listr([
{
title: "Check if migration needed",
task: async (ctx, task) => {
const needed = await checkMigrationStatus()
if (!needed) {
task.skip("Already up to date")
}
},
},
{
title: "Run database migration",
task: async () => {
await runMigration()
},
retry: 3, // Retry up to 3 times on failure
rollback: async () => {
await rollbackMigration() // Run if task fails after retries
},
},
{
title: "Seed data",
enabled: (ctx) => ctx.environment === "development", // Conditional
task: async () => {
await seedDatabase()
},
},
])
Renderers
import { Listr } from "listr2"
// Default renderer (interactive terminal):
new Listr(tasks, { renderer: "default" })
// Verbose renderer (for CI — no spinners):
new Listr(tasks, { renderer: "verbose" })
// Silent renderer (no output):
new Listr(tasks, { renderer: "silent" })
// Auto-detect: use verbose in CI, default in terminal:
new Listr(tasks, {
renderer: process.env.CI ? "verbose" : "default",
})
tasuku
tasuku — minimal task runner:
Basic usage
import task from "tasuku"
// Single task:
await task("Installing dependencies", async ({ setTitle }) => {
await exec("npm install")
setTitle("Dependencies installed")
})
// Sequential tasks:
await task("Building project", async () => {
await exec("npm run build")
})
await task("Running tests", async () => {
await exec("npm test")
})
// Output:
// ✔ Dependencies installed
// ✔ Building project
// ✔ Running tests
Grouped tasks
import task, { group } from "tasuku"
// Concurrent group:
await task.group((task) => [
task("Lint", async () => {
await exec("eslint .")
}),
task("Type check", async () => {
await exec("tsc --noEmit")
}),
task("Test", async () => {
await exec("vitest run")
}),
])
// All three run concurrently, output updates in place
Error handling
import task from "tasuku"
await task("Deploy", async ({ setError, setWarning, setOutput }) => {
try {
const result = await deploy()
setOutput(`Deployed to ${result.url}`)
} catch (err) {
setError(err.message)
throw err // Task shows as failed
}
})
// Warning (task still succeeds):
await task("Check config", async ({ setWarning }) => {
const config = await loadConfig()
if (!config.apiKey) {
setWarning("No API key configured — using defaults")
}
})
tasuku vs listr2
tasuku:
✅ Simple API — single function
✅ vitest-like output
✅ Concurrent groups
✅ Minimal (~3KB)
❌ No subtasks (nested tasks)
❌ No rollback
❌ No retry
❌ No skip
❌ No context passing
❌ No CI renderer
For complex workflows: use listr2
For simple task lists: tasuku is great
cli-progress
cli-progress — progress bars:
Single progress bar
import { SingleBar, Presets } from "cli-progress"
const bar = new SingleBar({
format: "Downloading | {bar} | {percentage}% | {value}/{total} files",
}, Presets.shades_classic)
bar.start(100, 0)
for (let i = 0; i < 100; i++) {
await downloadFile(files[i])
bar.increment()
}
bar.stop()
// Downloading | ████████████████████ | 100% | 100/100 files
Multi-bar progress
import { MultiBar, Presets } from "cli-progress"
const multibar = new MultiBar({
format: "{name} | {bar} | {percentage}% | {value}/{total}",
clearOnComplete: false,
}, Presets.shades_grey)
// Create multiple bars:
const bar1 = multibar.create(100, 0, { name: "Package A" })
const bar2 = multibar.create(200, 0, { name: "Package B" })
const bar3 = multibar.create(150, 0, { name: "Package C" })
// Update independently:
bar1.increment(10)
bar2.increment(25)
bar3.increment(15)
// When done:
multibar.stop()
// Package A | ████████░░░░░░░░░░░░ | 40% | 40/100
// Package B | ██████████░░░░░░░░░░ | 50% | 100/200
// Package C | ████████████░░░░░░░░ | 60% | 90/150
ETA and speed
import { SingleBar } from "cli-progress"
const bar = new SingleBar({
format: "{bar} | {percentage}% | ETA: {eta}s | {speed} files/s",
etaBuffer: 10,
etaAsynchronousUpdate: true,
})
bar.start(1000, 0, { speed: "N/A" })
for (let i = 0; i < 1000; i++) {
await processFile(files[i])
bar.update(i + 1, {
speed: calculateSpeed(i, startTime),
})
}
bar.stop()
// ████████░░░░░░░░░░░░ | 40% | ETA: 12s | 83 files/s
Custom format tokens
import { SingleBar } from "cli-progress"
const bar = new SingleBar({
format: "[{bar}] {percentage}% | {filename} | {size}",
barCompleteChar: "█",
barIncompleteChar: "░",
barsize: 30,
})
bar.start(totalFiles, 0)
for (const file of files) {
await processFile(file)
bar.increment(1, {
filename: file.name,
size: formatBytes(file.size),
})
}
bar.stop()
// [█████████████░░░░░░░░░░░░░░░░░] 45% | utils.ts | 2.4 KB
Download progress
import { SingleBar } from "cli-progress"
import https from "node:https"
function downloadWithProgress(url: string, dest: string) {
return new Promise<void>((resolve, reject) => {
https.get(url, (res) => {
const total = parseInt(res.headers["content-length"] ?? "0", 10)
const bar = new SingleBar({
format: "Downloading | {bar} | {percentage}% | {value}/{total} bytes",
})
bar.start(total, 0)
let received = 0
res.on("data", (chunk) => {
received += chunk.length
bar.update(received)
})
res.on("end", () => {
bar.stop()
resolve()
})
})
})
}
Feature Comparison
| Feature | listr2 | tasuku | cli-progress |
|---|---|---|---|
| Purpose | Task list runner | Task list runner | Progress bars |
| Concurrent tasks | ✅ | ✅ (groups) | ❌ |
| Subtasks | ✅ (nested) | ❌ | ❌ |
| Rollback | ✅ | ❌ | ❌ |
| Retry | ✅ | ❌ | ❌ |
| Skip tasks | ✅ | ❌ | ❌ |
| CI renderer | ✅ (verbose) | ❌ | ❌ |
| Multi-bar | ❌ | ❌ | ✅ |
| ETA calculation | ❌ | ❌ | ✅ |
| Custom formats | ❌ | ❌ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| Weekly downloads | ~10M | ~50K | ~5M |
When to Use Each
Use listr2 if:
- Building complex CLI workflows (build, deploy, migrate)
- Need subtasks, rollback, retry, and skip
- Want different renderers for CI vs terminal
- Building tools like Angular CLI, Prisma, or Yeoman
Use tasuku if:
- Want a simple, minimal task list
- Need concurrent task groups with clean output
- Building scripts with sequential/parallel steps
- Prefer vitest-like visual output
Use cli-progress if:
- Need download or transfer progress bars
- Want multi-bar progress indicators
- Need ETA and speed calculations
- Building file processing or batch operation tools
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on listr2 v8.x, tasuku v2.x, and cli-progress v3.x.