npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zukhruf/single-flight

v0.3.32

Published

Callers of one key share the run already in flight, in one process or across the processes that use one directory, and get its outcome instead of a busy error.

Readme

@zukhruf/single-flight

Callers of one key share the flight in progress, in one process and across all the processes that use one directory. A caller that comes while a flight runs does not start a second flight and does not fail with a "busy" error. It joins the flight and gets its outcome. This pattern is also known as single-flight or request coalescing, as in Go's golang.org/x/sync/singleflight.

The words in these documents have one meaning each. See the glossary in CONTEXT.md.

The problem

A user runs sync. A second sync starts while the first one runs. The mutex of @zukhruf/mutex gives two choices, and the user wants neither:

  • In the acquire mode skip if busy, the second sync fails with a "busy" error.
  • In the acquire mode wait, the second sync waits for the first one, and then runs a second sync.

The user wanted a sync to happen. Thus the second sync must tell the user that a sync runs, wait for it, and report its outcome. That is a join:

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';

import { SingleFlight } from '@zukhruf/single-flight';

const directory = await mkdtemp(join(tmpdir(), 'reports-'));
const flights = new SingleFlight<string>({
  directory,
  codec: { encode: (report) => report, decode: (text) => text },
});
let builds = 0;

async function buildReport() {
  builds += 1;
  await delay(100); // Building the report takes time.
  return `report ${builds}`;
}

const calls = [1, 2, 3].map(() =>
  flights.run('report:daily', buildReport, {
    onJoin: () => console.log('A report is being built. Waiting for it…'),
  }),
);
console.log(await Promise.all(calls), { builds });
await flights[Symbol.asyncDispose]();
await rm(directory, { recursive: true, force: true });

Output:

A report is being built. Waiting for it…
A report is being built. Waiting for it…
[
  { value: 'report 1', joined: false },
  { value: 'report 1', joined: true },
  { value: 'report 1', joined: true }
] { builds: 1 }

The first caller is the leader: its call runs the work of the flight. The other two callers join it. onJoin runs once for each joiner, before it waits. A call after the flight ended starts a new flight: a flight is not a cache. To keep a value after its flight, see the mutex recipe Compute a value once and share it.

Use it

This project is an experiment.

npm install @zukhruf/single-flight

The lease that the work gets comes from @zukhruf/lease, and its token comes from @zukhruf/fencing. Both are dependencies of this package, so you install nothing more.

The directory

The directory is the local folder where the processes that share flights meet. Each process that uses a SingleFlight keeps three files and a folder there: flight.lock, flight.epoch, flight.sock and execution-locks/. The election of @zukhruf/election claims flight.lock with a file lock, and records the epoch of the last term in flight.epoch. The folder execution-locks/ has one file for each key that ever led: the key lock of that key, named by the SHA-256 of the key. The process that runs the work of a key holds a file lock on that file (see When a process stops). The files stay after the flights, so do not remove them while a process uses the directory. The journal of the claim is in memory, so a coordinator that stops leaves no flight.lock-journal file. All the processes that name the same directory are one group, and the callers of one key share a flight in that group only.

There is no default directory. One default would put each application on the host into one group, and unrelated applications that use the same key would join each other's flights. Give each group its own directory, for example a folder of your application's data.

  • The directory must be on a local file system. On a network file system, a call rejects with NetworkDirectoryError.
  • On macOS and Linux, the path of flight.sock must have 103 bytes or fewer. A longer path throws a RangeError when you create the SingleFlight. On Windows, the socket is a named pipe, and the length of the directory does not matter.
  • A single flight and a socket lock store of @zukhruf/mutex can use one directory. They use different files, and they never meet.

Run a flight

flights.run(key, work, { onJoin, signal }) resolves with { value, joined }. joined is false for the leader and true for each joiner.

All the processes of the directory take part in one election. The winner is the coordinator. Each call asks the coordinator, in one step, to lead a new flight of the key or to join the flight in progress. When the work of the leader ends, the coordinator pushes the outcome to each joiner. Callers in the same process go through the coordinator too, also in worker threads. ADR 0003 tells why.

The work gets the lease of the flight:

  • token: a FencingToken. Its high 32 bits are the epoch of the coordinator's term, so a newer term always gives higher tokens.
  • signal: it aborts with LeaseLostError when the flight is no longer the leader's, so its outcome reaches no joiner. The subject of the error is the key. This occurs when a new coordinator refuses the reassert of the leader, because it knows a newer flight of the key, or when this process cannot connect to a coordinator again.

| Option | What it does | | ----------- | --------------------------------------------------------------------------------------------- | | directory | The local folder where the processes of one group meet. See The directory. | | codec | Turns the value of a flight into text for the joiners, and back. See The codec. |

flights[Symbol.asyncDispose]() waits until each landing of this process reached a coordinator, and then it disconnects. When this process is the coordinator, the other processes elect a new one. The work of a flight that this process leads continues, and this process keeps the key lock of that key until the work ends.

The codec

The value of a flight goes to the joiners as text. The codec has two functions: encode turns a value into text, and decode turns the text back into a value. The coordinator only carries the text. Thus the format is yours: JSON, or a format that keeps what JSON drops, such as undefined and Map. The leader gets decode(encode(value)) too, so each caller gets the value in the same shape.

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { type Codec, SingleFlight } from '@zukhruf/single-flight';

interface Sync {
  at: Date;
  files: number;
}

const syncs: Codec<Sync> = {
  encode: (sync) => JSON.stringify(sync),
  decode: (text) => {
    const { at, files } = JSON.parse(text);
    return { at: new Date(at), files };
  },
};

const directory = await mkdtemp(join(tmpdir(), 'syncs-'));
const flights = new SingleFlight({ directory, codec: syncs });
const { value } = await flights.run('sync', async () => ({
  at: new Date('2026-10-09T12:00:00Z'),
  files: 42,
}));
console.log(value.at instanceof Date, value.files);
await flights[Symbol.asyncDispose]();
await rm(directory, { recursive: true, force: true });

Output:

true 42

A codec that cannot encode the value fails the flight for all callers. A codec that cannot decode the text fails only the call that decodes it.

Errors

| The caller is | The flight | The call rejects with | | ------------- | --------------------------------------------------------------- | ---------------------------------------------------------------- | | The leader | Its work threw | The original error of the work | | A joiner | Its work threw | FlightFailedError | | A joiner | Interrupted: the leader's process stopped, or it lost the lease | FlightInterruptedError | | The leader | It lost the lease | The reason of the lease's signal: LeaseLostError | | The leader | It lost the lease, and then its work threw | A new LeaseLostError. Its cause is the error of the work | | Each caller | encode threw in the leader | The leader: the error of encode. A joiner: FlightFailedError | | One caller | Its own decode threw | The error of decode, for that caller only | | Each caller | The coordinator speaks another protocol version | ProtocolVersionError | | Each caller | The directory is on a network file system | NetworkDirectoryError | | Each caller | The SingleFlight is disposed | Error('This single flight is closed.') |

A joiner in the leader's own process is a joiner too: it gets FlightFailedError, not the original error. FlightFailedError has the key and the failure: the name, the message and the code of the error. A thrown value that is not an error keeps only its text.

LeaseLostError is the class of @zukhruf/lease. This package exports it too, because the call of a leader rejects with it.

Cancel a wait

Give a signal to stop the wait of one caller: flights.run(key, work, { signal: AbortSignal.timeout(30_000) }). The call rejects with the reason of the signal. The flight continues for the other callers. When the caller of the leader cancels, the work continues and lands for the joiners. A call whose signal aborted before the call rejects at once: it never leads and never joins. ADR 0004 tells why.

When a process stops

A leader keeps its flight while its process lives. Before its work starts, the leader takes a kernel lock on its key, the key lock. It frees the key lock only when its work settled and its flight needs nothing more from it: the coordinator acknowledged its landing, its lease was lost, or its SingleFlight closed. The kernel keeps the key lock while the process lives, also while the process is frozen, and it frees the key lock when the process dies. Thus no other process starts the work of the key beside it. ADR 0010 tells why.

  • The process of a leader stops. The coordinator tells each joiner that the flight is interrupted, and each joiner rejects with FlightInterruptedError at once. No joiner runs the work again. The kernel frees the key lock, and the next call of the key leads a new flight.
  • A leader disposes its SingleFlight while its work runs. The coordinator interrupts the flight, because a disposed leader cannot send its outcome. Each joiner rejects with FlightInterruptedError. The work continues, and the leader keeps the key lock until the work ends. A call of the key in another process waits until then, and then it leads a new flight.
  • The coordinator stops or disposes. The other processes elect a new coordinator, and it answers at once:
    • Each leader reasserts its flight, and sends its landing again when it has one.
    • Each joiner rejoins its flight by the flight's token, and it gets the outcome of that flight.
    • A new call of a key that no process holds leads or joins at once.
    • A new call or a rejoin of a key whose key lock another process holds, while the new coordinator knows no flight of the key, waits. When the leader reasserts its flight, the call or the rejoin joins it. When the key lock frees first, the call leads, and the rejoin gets FlightInterruptedError.
  • A leader is frozen or busy, for example stopped by a debugger or in a long synchronous step. It keeps its flight and its key lock. When the coordinator stops in that time, the calls of the key wait. When the leader continues, it reasserts its flight, and the calls that waited join it. When its process dies instead, the kernel frees the key lock, the joiners get FlightInterruptedError, and the next call leads.
  • A leader loses its lease. This occurs only when a new coordinator refuses its reassert, because it knows a newer flight of the key, or when this process cannot connect to a coordinator again. The signal of the lease aborts with LeaseLostError, and the call of the leader rejects with that error. When the work throws its own error after the loss, the call rejects with a new LeaseLostError, and the error of the work is its cause. Thus LeaseLostError tells the leader that its outcome reached no joiner. Its joiners get FlightInterruptedError. The leader keeps the key lock until its work ends, so the next flight of the key runs after it, not beside it. ADR 0007 tells why.
  • A joiner's leader and the coordinator stop together. The joiner gets FlightInterruptedError from the new coordinator. It never runs the work again.

A call that was on its way to a coordinator that stopped joined nothing. It goes again to the next coordinator as a new call. ADR 0005 tells why.

How long a call waits

  • A call of a key waits with no limit while a process that lives holds the key lock, for example a frozen leader, or a leader whose work does not end. To set a limit, give a signal. A call of another key does not wait for it.
  • A leader frees the key lock only when it reads the acknowledgment of its landing. A new call of the same key in the process of the leader waits in that process until the key lock frees, and then goes to the coordinator. Thus two flights of one key in one process follow each other with no wait. A call of the key from another process that comes before the key lock frees waits for one more check of the key lock, about 10 ms.

Changes from 0.3.x

Version 0.3.13 to 0.3.15 had a pull design. These parts changed:

  • SharedFlight, FlightRecords and FileFlightRecords are gone. Each SingleFlight now works across processes. Give it a directory and a codec.
  • parse is gone. The codec replaces it.
  • The records, keepFor and pollInterval are gone. The coordinator pushes each outcome, so nothing is kept and nothing is read again. FlightOutcomeLostError is gone too.
  • A joiner in the leader's own process now gets FlightFailedError, not the original error of the work. Each caller goes through the coordinator.
  • The signal that told the work that all its callers left is gone. The work gets the lease of the flight.
  • A caller whose signal aborted before the call rejects at once.
  • A joiner of an interrupted flight no longer continues with the next flight of the key. It gets FlightInterruptedError.

Documentation

Development

This package is part of the zukhruf workspace. Run the commands from the workspace root.

npm install
npx nx run single-flight:test        # builds, then runs the tests in src/
npx nx run single-flight:typecheck   # formats, lints, then type checks
npx nx run single-flight:build       # compiles src/ to dist/

The tests run from src/, not from dist/. The tests across processes start the script of src/testing/caller.ts in child processes.

src/
  single-flight.ts      SingleFlight: each call goes to the coordinator
  codec.ts              the Codec interface
  errors.ts             FlightFailedError, FlightInterruptedError
  client/               the requests of one process: runs, leads, landings
  coordinator/          the coordinator of one term: flights, sessions, parked runs, its server
  key-lock/             the key locks: the kernel lock on each key that a leader holds
  protocol/             the messages between a process and its coordinator
  connection/           the socket, the handshake and the supervisor (a copy); the election of the directory (@zukhruf/election)
  shared/               the record check (a copy); file writes and the network directory check are in @zukhruf/fs
  testing/              the caller process that the tests start