Reference
yieldless/limiter
Semaphores and rate limiters for protecting shared async boundaries.
yieldless/limiter keeps pressure explicit. It gives you a tiny semaphore for local concurrency and a simple rate limiter for APIs that need paced calls. Both are built on promises and AbortSignal.
Use it when many independent flows share a limited resource: subprocess slots, database connections, API quota, filesystem pressure, or CPU-heavy local work.
Exports
createSemaphore(capacity): SemaphorewithPermit(semaphore, operation, options): Promise<SafeResult<T, E>>createKeyedMutex<Key>(): KeyedMutex<Key>withLock(mutex, key, operation, options): Promise<SafeResult<T, E>>createRateLimiter({ limit, intervalMs }): RateLimitertype Semaphore = { acquire, tryAcquire, withPermit, available, capacity, pending }type SemaphorePermit = { release, [Symbol.dispose] }type KeyedMutex<Key> = { withLock, isLocked, size }type RateLimiter = { take, takeSafe, clear, pending }
Example
import { createSemaphore, withPermit } from "yieldless/limiter";
import { safeTry } from "yieldless/error";
const gitSlots = createSemaphore(3);
const [error, result] = await withPermit(
gitSlots,
(signal) => safeTry(runGitCommand(repoPath, ["status"], { signal })),
{ signal },
);Serialize work per key while different keys run in parallel:
import { createKeyedMutex } from "yieldless/limiter";
const accounts = createKeyedMutex<string>();
await accounts.withLock(accountId, async (_signal) => {
const balance = await readBalance(accountId);
await writeBalance(accountId, balance + amount);
});Pace API calls:
import { createRateLimiter } from "yieldless/limiter";
const githubLimit = createRateLimiter({
limit: 2,
intervalMs: 1_000,
});
await githubLimit.take({ signal });
const response = await fetch(url, { signal });Behavior notes
createSemaphore(0)throws aRangeError.acquire()waits until a permit is available or the signal aborts.tryAcquire()returnsnullinstead of waiting.withPermit()always releases the permit in afinallyblock.- The exported
withPermit()helper expects tuple-returning work and normalizes acquisition or thrown failures into tuple errors. createKeyedMutex()creates one single-permit semaphore per key on demand and removes it when the key has no active or waiting work, so long-lived processes do not accumulate lock objects.- Use
singleFlight()instead of a keyed mutex when duplicate callers should share one result rather than run one after another. createRateLimiter()schedules calls into fixed-size windows.rateLimiter.clear(reason)rejects pending waiters.
Good
Protect the boundary, not every call site.
const subprocesses = createSemaphore(4);
export function runLimitedGit(args: readonly string[], signal: AbortSignal) {
return withPermit(
subprocesses,
(permitSignal) => runCommandSafe("git", args, { signal: permitSignal }),
{ signal },
);
}Use takeSafe() when rate-limit waiting is part of a tuple flow.
const [limitError] = await githubLimit.takeSafe({ signal });
if (limitError) return [limitError, null] as const;Avoid
Do not acquire a permit and forget to release it.
const permit = await semaphore.acquire();
await doWork();Prefer scoped release.
await semaphore.withPermit((signal) => doWork(signal), { signal });