Skip to main content

listr2 vs tasuku vs cli-progress: Task Runners and Progress Bars in Node.js (2026)

·PkgPulse Team

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

Featurelistr2tasukucli-progress
PurposeTask list runnerTask list runnerProgress 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.

Compare CLI tools and developer utilities on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.