Nakodo has 30 runtime dependencies. There is a database driver, a payments SDK, a mail SDK, a parser for HTML and a parser for MIME, and not one package for running asynchronous work with a limit on it. No p-limit, no p-map, no async, no bottleneck.
What there is instead is src/lib/async.ts, 42 lines including comments and blank ones, imported by 11 files. This is the whole of it, and then the measurements.
sleep, which is four characters of real content
export const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
Four call sites. Three of them are in code that waits for a rate limit slot, and the fourth waits for a source that is spaced out on purpose. Nothing in the UI sleeps.
mapLimit, the only one with any logic in it
// Runs `fn` over items with at most `limit` in flight.
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
Fifteen call sites: six in the app, nine in one benchmarking script. The limits that appear in them are 2, 3, 4 and 5, plus two named constants, and 4 is the one used most.
Three properties worth naming, because they are the reason this is 11 lines rather than a dependency:
next++ is a shared cursor in a closure, so there are no chunks. Nine items at a limit of 4 is not three batches of 4, 4, 1 waiting on each other's slowest member; it is four workers pulling from one queue until the queue is empty. The difference shows up most when one item takes ten times as long as the rest, which is normal when the items are websites.
results[i] is written by index into a preallocated array, so the output order is the input order even though the completion order is not. Call sites can zip the results back against what they passed in without carrying an id around.
Math.min(limit, items.length) means two items at a limit of 8 spawns two workers, not eight promises that immediately find nothing to do. That matters because this is called with batches whose size is decided by a queue claim, so single-item batches are common.
What it does not do
A rejection from fn ends that worker and rejects the Promise.all, which rejects mapLimit. The other workers are not cancelled, because nothing in JavaScript can cancel them: they are already running. The caller gets its error immediately while two or three more items are still being processed in the background.
That is not theoretical, so five of the six call sites in the app catch inside the callback and fail the individual job:
await mapLimit(wanted, 5, async (ch) => {
try {
await fetchVideos(ctx, ch);
ctx.summary.channelsEnriched++;
} catch (e) {
if (e instanceof QuotaError) throw e;
const job = jobByChannel.get(ch.id)!;
jobByChannel.delete(ch.id);
await failJob(job, e);
ctx.jobsFailed++;
}
});
The if (e instanceof QuotaError) throw e line is the one deliberate exception. When the daily API quota is gone, every remaining item in the batch is going to fail the same way, so the right move is to stop the batch rather than to record nine identical failures. It is also the only case where the "workers keep running" caveat is live, and it is harmless there precisely because the thing they are about to hit is the same wall.
If I were writing this again I would return settled results rather than throwing, in the shape Promise.allSettled uses, and let the one caller that wants to abort check for its own error type. The current signature makes the common case (handle each item's failure) more verbose than the rare one.
withTimeout, which is honest in a comment
// Rejects with `error()` if `promise` hasn't settled within `ms`. The work
// itself isn't cancelled; use it where each step has its own timeout anyway.
export function withTimeout<T>(promise: Promise<T>, ms: number, error: () => Error): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
return Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(error()), ms);
}),
]).finally(() => clearTimeout(timer));
}
Seven call sites, with these budgets: 10 seconds for a select 1 that asks whether the database is up, 8 seconds each for a sitemap and a logo that are both described in their own comments as a nice-to-have, 12 seconds for a logo in another path, 35 seconds for a whole site crawl, 60 seconds for one business site.
The .finally(() => clearTimeout(timer)) is not tidiness. A pending setTimeout keeps the Node event loop alive, so without it a CLI script that finished its work in 200 milliseconds would sit there for the rest of the longest budget before exiting. In a serverless function it is less visible and still wrong: the timer holds a closure over the rejection handler for as long as it exists.
The second argument being a factory rather than an Error has a nice property and a bad one. The nice one: nothing is allocated on the happy path, which is nearly every path. The bad one: new Error() captures its stack where it is constructed, and it is constructed inside the timer callback, so the stack of a timeout is the timer, not the code that was waiting. Every one of our seven call sites therefore writes a message that identifies itself by hand ("Sitemap timed out", "Logo timed out"), because the trace will not. The fix is to build the error at the call site and pass the object, or to capture a stack eagerly, and the reason we have not is that the hand-written messages are good enough and the file is 42 lines.
pacer, which books a slot before it waits for it
// Spaces calls out to a rate within this process. Shared by concurrent
// callers: each call books the next free slot before waiting for it.
export type Pacer = () => Promise<void>;
export function pacer(perSecond: number): Pacer {
let next = 0;
return async () => {
const now = Date.now();
const at = Math.max(now, next);
next = at + 1000 / perSecond;
if (at > now) await sleep(at - now);
};
}
One call site, for a provider that asks for a steady request rate rather than a burst. The ordering inside it is the whole point: next is advanced synchronously, before the await, so ten callers arriving in the same tick each take a different slot and then sleep for a different length. If the advance happened after the sleep, all ten would compute the same at, all ten would wake together, and the pacer would pace nothing.
next lives in a closure, so this spaces calls within one process only. That is the honest scope, and it is why the repo has three different spacing mechanisms rather than one:
- this one, per process, for a provider whose limit is generous and whose bursts are our own fault;
-
Ratelimit.fixedWindow(1, "1 s")in Redis withephemeralCache: false, for a public service whose usage policy says one request a second for the whole application, where every instance must share the count; - a single Postgres row with
set: { nextRequestAt: greatest(nextRequestAt, now()) + interval }, for a source where the slot has to survive a cold start and be visible to every function that might take it.
Per process, per application, per source. Three scopes, three implementations, and the only one that needed no infrastructure is the one above.
The measurement that made me write this
Four exports, 42 lines, 11 importers, 27 call sites, zero dependencies. Two of the four carry a comment admitting a limitation rather than hiding it, and in both cases the limitation is the same one: you cannot cancel a promise, so the best a helper can do is stop waiting and say so.
The product this runs is a background pipeline: searching, reading sites, writing emails and cleaning up after itself on a schedule, described for customers on how it works, with the retention deadlines it has to hit written on the privacy notice. None of that needed a concurrency package. It needed four functions and a rule about where errors are caught.
Top comments (0)