Open-source project
mcollina/fastq avatar
mcollina/fastq

fastq is a task queue with a callback API and a promise API over the same engine

Fast, in memory work queue

1,139 stars62 forksJavaScriptISC

At a glance

What is it?
fastq is a JavaScript in-memory work queue by Matteo Collina, with a callback flavour and a promise flavour built on the same core, plus pause, drain, saturation callbacks and runtime-adjustable concurrency. It fits a Node.js service that needs bounded parallelism over a task list, and it does not fit a project that needs durability across a restart.
Who is it for?
fastq fits a Node.js service that needs a bounded worker pool over an in-memory list of tasks, and it does not fit a pipeline that needs tasks to survive a process crash or to be handed between machines. Before adopting it, note that it is in-memory only, so a restart loses queued tasks, and that the only runtime dependency is reusify, which keeps the footprint at one small package, while the licence is ISC and the current release is v1.20.3 from 2026-08-29.
Can I use it commercially?
Yes. ISC is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 40 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Two APIs over one queue, and the promise one is a thin wrapper

fastq is a fast, in-memory work queue. Its structure is simple enough to describe in one line: a worker function, a concurrency number, and a queue of tasks.

The callback API is the original:

js
'use strict'

const queue = require('fastq')(worker, 1)

queue.push(42, function (err, result) {
  if (err) { throw err }
  console.log('the result is', result)
})

function worker (arg, cb) {
  cb(null, arg * 2)
}

The promise API is the same thing with promises:

js
const queue = require('fastq').promise(worker, 1)

async function worker (arg) {
  return arg * 2
}

async function run () {
  const result = await queue.push(42)
  console.log('the result is', result)
}

run()

The documentation is explicit about the relationship. fastqueue.promise([that], worker(arg), concurrency) creates a new queue with Promise APIs, and it also offers all the methods and properties of the object returned by fastqueue, with the modified push and unshift methods. Node v10 and later is required for the promisified version.

That is the design choice worth understanding. Rather than maintaining two implementations, the promise variant wraps the same queue and swaps the two entry points. Everything else, the pause and resume, the drain, the empty and saturated callbacks, the concurrency property, is shared, which means the two flavours behave identically apart from those two entry points.

The author is Matteo Collina, the package is published as fastq on npm, and the licence is ISC.

The context argument exists for a reason that is not obvious

There is an optional first argument to the queue factory, and the documentation gives it a dedicated example. The that argument is an optional context of the worker function, and the worker is called with that as this if that is specified.

js
'use strict'

const that = { hello: 'world' }
const queue = require('fastq')(that, worker, 1)

queue.push(42, function (err, result) {
  if (err) { throw err }
  console.log(this)
  console.log('the result is', result)
})

function worker (arg, cb) {
  console.log(this)
  cb(null, arg * 2)
}

The point of this is dependency injection for the worker. A queue that has to call an object method, say a service client with a connection pool, would otherwise need a closure or a bound function, and a bound function per task is not free. Binding the context once at queue construction means the worker can be a plain function and the queue calls it with the right this every time.

It is a small feature with a real performance angle, given that the whole point of the library is to be fast. The package's only runtime dependency is reusify, and a lean dependency tree is exactly the kind of optimisation that matters when the queue is doing millions of small tasks. The README points to two sibling packages for cases where even this is overkill, fastseries for zero-overhead series function call and fastparallel for zero-overhead parallel function call.

The API surface is small and every method has a clear failure semantics

The API list is short, and each entry is documented with what it returns, which makes it easy to reason about what happens in a failure case.

For adding work, push(task, done) adds a task at the end of the queue and calls done(err, result) when the task was processed, and unshift(task, done) adds a task at the beginning with the same callback contract.

For control, pause() pauses the processing of tasks, and the documentation is careful that currently worked tasks are not stopped. resume() resumes processing. idle() returns false if there are tasks being processed or waiting to be processed, and true otherwise. length() returns the number of tasks waiting to be processed, and getQueue() returns all the tasks to be processed, or an empty array when there are none.

For termination, kill() removes all tasks waiting to be processed and resets drain to an empty function, and killAndDrain() is the same but the drain function is called before being reset to empty. The difference is the point: kill discards queued work silently, killAndDrain lets the application react to the queue emptying.

For observation, error(handler) sets a global error handler, and handler(err, task) is called each time a task is completed, with err not null if the task has thrown an error. The three event properties are drain, called when the last item from the queue has been processed by a worker, empty, called when the last item has been assigned to a worker, and saturated, called when the queue hits the concurrency limit. All three can be altered at runtime, as can the concurrency property, which returns the number of concurrent tasks that could be executed in parallel.

The distinction between drain and empty is the subtle one. empty fires when work is handed to a worker, drain fires when the worker finishes with it. A queue that completes quickly can be empty but not drained, and code that waits for drain to know the work is truly done is correct, while code that waits for empty is not.

The paused property is read-only and returns true when the queue is in a paused state.

The benchmark compares against async.queue, and setImmediate is faster

The README carries a benchmark, and it is worth reading carefully rather than as a marketing number.

Benchmarks with 1 million tasks:

* setImmediate: 812ms * fastq: 854ms * async.queue: 1298ms * neoAsync.queue: 1249ms

The measurement conditions are stated: obtained on node 12.16.1, on a dedicated server.

The comparison is against async.queue and neoAsync.queue, and fastq is faster than both by roughly a third. That is the claim that matters, because those are the two libraries a developer would otherwise reach for.

The setImmediate line is more interesting, and it is a warning rather than a boast. A raw setImmediate loop is faster than fastq. That is expected, because setImmediate does no queueing, no concurrency limiting and no error handling. The gap between 812ms and 854ms is the price of the features, and the README is honest that it exists.

It is also worth noticing the conditions. The benchmark was run on node 12.16.1, which is a long way from a current Node release, and on a dedicated server. The relative ordering is likely stable, but the absolute numbers are not something to quote as a performance claim for your own workload, and the README does not present them that way.

The sibling packages make the point explicit. If you need zero-overhead series function call, the README says to check out fastseries, and for zero-overhead parallel function call, fastparallel. The queue's value is the features, and the benchmark shows what they cost.

TypeScript types ship with the package, and the promise API is typed separately

The package ships its own type definitions, index.d.ts, and the README shows both TypeScript forms.

The callback form uses the queue and done types:

ts
'use strict'

import * as fastq from "fastq";
import type { queue, done } from "fastq";

type Task = {
  id: number
}

const q: queue<Task> = fastq(worker, 1)

q.push({ id: 42})

function worker (arg: Task, cb: done) {
  console.log(arg.id)
  cb(null)
}

The promise form uses queueAsPromised instead:

ts
'use strict'

import * as fastq from "fastq";
import type { queueAsPromised } from "fastq";

type Task = {
  id: number
}

const q: queueAsPromised<Task> = fastq.promise(asyncWorker, 1)

q.push({ id: 42}).catch((err) => console.error(err))

async function asyncWorker (arg: Task): Promise<void> {
  // No need for a try-catch block, fastq handles errors automatically
  console.log(arg.id)
}

Two details are worth copying. The generic parameter is the task type, so the queue is typed at the level of what you put in it, not just the callback signature. And the comment about the promise API says no need for a try-catch block, fastq handles errors automatically, which is the practical difference between the two flavours: in the callback version the error arrives as the first argument to done, and in the promise version it arrives as a rejected promise you catch on the push.

The repository layout is small and matches the package's scope. The published files are queue.js, index.d.ts and SECURITY.md, the main entry is queue.js, and the type is commonjs. Alongside them sit bench.js, example.js, example.mjs, eslint.config.js, index.d.ts, a scripts/ directory and a test/ directory.

The test suite enforces full coverage as a gate

The package.json shows how the project treats correctness, and it is stricter than most libraries of this size.

The unit script runs nyc with --lines 100 --branches 100 --functions 100 --check-coverage against tape test/test.js test/promise.js. That is a coverage threshold of 100 percent on lines, branches and functions, with check-coverage meaning the run fails if it is not met. The test script runs lint and then the unit script, and pre-commit runs test and typescript, so neither a coverage regression nor a type error can be committed.

The separate typescript script, tsc --project ./test/tsconfig.json, type-checks the TypeScript usage in the test directory, which is what keeps the shipped index.d.ts honest against the documented examples. The legacy script, tape test/test.js, runs the callback tests alone, and the coverage script produces html and cobertura reports in addition to the text reporter.

For a library whose whole value proposition is that it is small and predictable, this is the right level of rigour. A queue has a lot of edge cases, and the ones that matter are the ones where a task throws, where the queue is killed mid-flight, where the concurrency limit is hit at the same moment as the last item is assigned. Full branch coverage is how you find those, and it is why the library can offer drain, empty and saturated as separate events without them behaving unpredictably.

The licence is ISC, which is a permissive licence with a short grant and no copyleft obligation, and there is a SECURITY.md in the published files, so the vulnerability reporting process is part of the package rather than an afterthought.

Where fastq is the wrong tool

The first wrong-tool case is durability. The library is an in-memory work queue, and the README says so in two words. Anything still in the queue when the process exits is gone, and kill() is the documented way to discard waiting tasks. For a job queue that must survive a deploy, a crash or a machine failure, this is the wrong category of tool, and a persistent queue backed by Redis, RabbitMQ or a database is the alternative.

The second is that the queue has no concept of a task beyond a function and an argument. There is no priority, no retry policy, no delay, no scheduling, and no visibility timeout. The pause and resume pair gives you a manual throttle, and the concurrency property gives you a dynamic limit, but the moment you need a task to run in ten minutes or to be retried three times with backoff, you are writing that yourself around the queue, and the queue's error handler becomes the place where the retry logic lives.

The third is scale. The queue holds tasks in memory, and getQueue() returns all of them as an array. A queue holding a million small task objects is a million objects in the heap, which is fine for a request-scoped fan-out and not fine for a long-running consumer that accumulates work. The benchmark runs one million tasks, and it does that on a dedicated server, which is the right context for the number but not evidence that the memory profile is comfortable for your use.

The fourth is the comparison. If you are choosing a queue for its throughput against other queue libraries, fastq is the faster of the three that were benchmarked, and the sibling packages cover the case where you want no queueing overhead at all. If you are choosing a queue for its operational behaviour, this library has very little of it, and that is the trade you are making.

Editorial conclusion

fastq fits a Node.js service that needs a bounded worker pool over an in-memory list of tasks, and it does not fit a pipeline that needs tasks to survive a process crash or to be handed between machines. Before adopting it, note that it is in-memory only, so a restart loses queued tasks, and that the only runtime dependency is reusify, which keeps the footprint at one small package, while the licence is ISC and the current release is v1.20.3 from 2026-08-29.

Frequently asked questions

How do I install and use fastq?

Install it with npm i fastq --save. The callback API is require('fastq')(worker, concurrency) and the promise API is require('fastq').promise(worker, concurrency), and both expose push, unshift, pause, resume, idle, length, kill, killAndDrain and error.

What is the difference between fastq's drain, empty and saturated events?

drain is called when the last item from the queue has been processed by a worker, empty is called when the last item has been assigned to a worker, and saturated is called when the queue hits the concurrency limit. All three can be altered at runtime.

How fast is fastq compared to async.queue?

The README reports a benchmark of 1 million tasks on node 12.16.1 on a dedicated server, with fastq at 854ms against async.queue at 1298ms and neoAsync.queue at 1249ms, while a raw setImmediate loop was 812ms. The setImmediate figure is the cost of having a queue with concurrency control, callbacks and error handling at all.

Does fastq persist queued tasks?

No. It is an in-memory work queue, so tasks waiting to be processed are lost when the process exits, and kill() is the documented way to remove all waiting tasks. Use a persistent broker if the work has to survive a restart.

Official sources

  1. Issues
  2. License: ISC
  3. mcollina/fastq on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/mcollina-fastq.svg)](https://hysenlabs.com/projects/mcollina-fastq)

Was this useful?