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

@idle-runner/core

v1.3.1

Published

Idle runner that defers and time-slices non-urgent work off the main thread, like requestIdleCallback but Safari-safe. Zero dependencies.

Downloads

1,004

Readme

@idle-runner/core

npm version npm downloads

Run non-urgent work without blocking the main thread. ~3kb, zero dependencies, works on Safari — where requestIdleCallback has never shipped enabled and most "idle" libraries quietly stop being idle libraries.

Tasks are deferred and time-sliced: the runner executes them in small budgeted slices (5ms by default) between the browser's latency-critical work, so input handling and rendering never wait behind your queue.

Install

npm install @idle-runner/core

Quick start

import { sharedRunner } from '@idle-runner/core';

// One queue per page is what you usually want — a second runner does not get
// a second main thread, it just splits the budget.
const runner = sharedRunner();

// Defer work nobody is waiting on. Resolves with the return value.
const index = await runner.push(() => buildSearchIndex(products));

// Work that must eventually run even if the page never goes idle:
await runner.push(() => flushAnalytics(events), { timeout: 2000 });

Heavy work goes through pushChunked, where every yield marks a point at which the runner is allowed to pause and hand the thread back:

function* thumbnailsFor(photos: Photo[]) {
    const out: Thumbnail[] = [];

    for (const photo of photos) {
        out.push(downscale(photo)); // ~4ms each — fine alone, 2s as a loop
        yield; // the runner stops here once the slice budget runs out
    }

    return out;
}

const thumbnails = await runner.pushChunked(thumbnailsFor(photos));

Without the runner that loop is one 2-second long task and the page is frozen for all of it. With it, the same work spreads across idle periods in ~5ms slices, and a click in the middle is still handled on the next frame.

Cancelling

const controller = new AbortController();
const preview = runner.pushChunked(renderPreview(doc), { signal: controller.signal });

// User navigated away from the preview before it finished:
controller.abort(); // `preview` rejects with AbortError; the generator's finally runs

In a component

The shared runner outlives every component, so there is nothing to clean up:

function ProductList({ products }: { products: Product[] }) {
    useEffect(() => {
        const controller = new AbortController();

        sharedRunner()
            .push(() => warmImageCache(products), { signal: controller.signal })
            .catch(() => {}); // the abort below lands here

        return () => controller.abort();
    }, [products]);

    // ...
}

Reach for your own instance when you need a queue with a lifetime you control — a different budget, or one you can clear() wholesale. Then destroy() is mandatory, because a runner binds page lifecycle listeners and is otherwise pinned in memory for as long as the document lives:

useEffect(() => {
    const runner = new IdleRunner({ budgetMs: 10 });

    void runner.push(() => precomputeRoute(target));

    return () => runner.destroy(); // unbinds listeners, rejects pending tasks
}, [target]);

Priority

Every task defaults to 'user-visible' — the existing FIFO behavior, unchanged if you never pass priority. Pass it to move a task to a different one of three buckets, drained highest-first:

runner.push(() => flushAnalytics(events), { priority: 'background' });
runner.push(() => renderVisibleTiles(), { priority: 'user-visible' }); // default
runner.push(() => finishInteractionWork(), { priority: 'user-blocking' });

Within a bucket, order is still FIFO. A steady stream of user-visible work can't starve background forever, though: a task that has waited longer than agingMs (default 1000ms) outranks everything ahead of it, oldest-starved-first. Set agingMs: Infinity for strict priority with no aging.

Priority is cooperative, not preemptive at the statement level — a running function always finishes. A suspended generator from pushChunked is the exception: at its next yield, higher-priority work queued in the meantime runs first, and the generator resumes afterward from the same point. Equal-or-lower priority never interrupts it.

Deduplicating by key

Pass key to make a new push supersede a pending one with the same key — the stale task rejects with AbortError (silently, if onError is set) and never runs. This is the one-liner for "recompute on every keystroke, only the latest result matters":

function onQuery(query: string) {
    return runner.push(() => search(query), { key: 'search' });
}

Only pending work is superseded — a task already running (including a suspended chunked generator mid-yield) keeps going; cancel that one yourself with signal if you need to. key and priority compose freely: the newest push keeps whichever priority it was given, independent of the one it replaced.

Lists and progress

Walking a long list is the common case, so it comes ready-made — idleMap and idleForEach build the generator for you:

import { idleMap } from '@idle-runner/core';

const thumbnails = await idleMap(photos, downscale, {
    onProgress: done => setProgress(done / photos.length),
});

They take any iterable (arrays, Set, a generator), pass (item, index), and accept every per-task option — signal, timeout, priority, key — plus runner (defaults to sharedRunner()) and chunkSize. By default the thread is handed back after every item, which is the safe choice when you don't know what one item costs; raise chunkSize when the per-item work is small enough that the check between items is the expensive part:

await idleForEach(rows, row => index.add(row), { chunkSize: 500 });

Both are tree-shakeable: importing IdleRunner alone does not pull them in.

onProgress also works on pushChunked directly, where it receives whatever your generator yields — turning yield into a progress channel at no cost when unused:

function* parse(lines: string[]) {
    for (const [i, line] of lines.entries()) {
        rows.push(parseLine(line));
        yield i + 1; // → onProgress
    }

    return rows;
}

await runner.pushChunked(parse(lines), {
    onProgress: done => setProgress(done / lines.length),
});

A throwing onProgress is swallowed with a dev warning — reporting progress cannot fail the task it reports on.

Waiting for the queue

whenIdle() resolves once nothing is left in the queue — useful in tests, before a prerender snapshot, or ahead of a teardown that must not race the queue:

await runner.whenIdle();

It never rejects: it says the runner has nothing left to do, not how the work went. Tasks that threw or aborted still count as done, and a paused runner with work queued keeps it pending.

Errors

push and pushChunked return real promises, and a task that throws rejects its promise. That means a task you never awaited is an unhandled rejection — including the AbortErrors that clear() and destroy() deliver to everything still queued:

runner.push(() => JSON.parse(maybeInvalid)); // ⚠️ throws → unhandled rejection

Pick one of these:

// 1. Handle it at the call site.
runner.push(() => JSON.parse(maybeInvalid)).catch(reportToSentry);

// 2. Or hand the runner an error channel once, and stop thinking about it.
const runner = new IdleRunner({
    onError: error => reportToSentry(error),
});

runner.push(() => JSON.parse(maybeInvalid)); // reported, never unhandled

onError marks every task promise as handled, so fire-and-forget stops being a footgun. It does not swallow anything — await runner.push(...) still rejects exactly as before, and a .catch() you attach yourself still runs.

Aborts are deliberately not reported to onError: destroy() cancelling ten pending tasks is a requested outcome, not ten errors. A custom reason passed to clear(reason) is reported, because that one is yours.

When to use this — and when not to

Good fits — work whose result nobody is waiting on right now:

  • prefetching and precomputing ahead of need
  • warming caches and derived indexes
  • analytics and logging flushes
  • non-urgent state/DOM reconciliation
  • hydrating below-the-fold widgets

Bad fits — use something else:

  • Work the next paint depends on. If the user just clicked "apply coupon" and is watching the total, deferring that calculation to idle makes INP worse, not better. Compute it now.
  • Genuinely heavy, parallelizable work. This library defers work on the main thread; it does not offload it. A 200ms computation is still a 200ms computation — chunk it with pushChunked, or move it to a Web Worker.
  • Async job concurrency control (rate-limiting N fetches, etc.) — that's p-queue's job. This library is about main-thread responsiveness, not async orchestration.

Does it actually work?

The claim is tested in a real browser rather than asserted, in test/browser/runner.browser.test.ts, with a PerformanceObserver watching for longtask entries — and, crucially, with a negative control, because a benchmark that only shows the good number proves nothing:

| workload | long tasks (≥50ms) observed | | ---------------------------------------------- | --------------------------- | | 180ms of work run directly (the control) | at least one — as it must | | the same work through IdleRunner (60 × ~3ms) | none |

A companion test keeps a requestAnimationFrame loop running while a 100-task queue drains and asserts that frames keep arriving — the thread is shared, not monopolised.

Both run on Chromium and WebKit in CI, so the Safari path is covered by the same suite as everything else.

How it works

requestIdleCallback does not exist in Safari and never has. The runner therefore picks the best available scheduling primitive at first use, in this order:

| rung | used where | why it sits here | | --------------------- | ----------------- | --------------------------------------------------------------------------------------------------------- | | requestIdleCallback | Chromium, Firefox | real idle deadlines, straight from the browser — nothing to synthesise | | setImmediate | Node | ahead of MessageChannel: Node ≥15 exposes a global MessageChannel whose open port pins the event loop | | MessageChannel | Safari / WebKit | no rIC, ever — and setTimeout would hit the 4ms nested-timer clamp, quadrupling the gap between slices | | setTimeout(0) | anything else | last resort |

Below the top rung there is no real deadline to read, so the runner synthesises one worth 2 × budgetMs and re-checks it before starting each task. The budget is checked before a task starts, never after: starting a 40ms task with 0.3ms left on the clock is exactly how an "INP library" ends up creating long tasks.

Detection is lazy — no host global is touched until the first push.

SSR / Node

Safe to import and run on the server. There is no top-level access to window, document or any timer; the lifecycle listeners no-op without a document, and in Node the queue drains on the setImmediate rung. Importing the package in a Next.js/Nuxt/Remix server bundle needs no typeof window guard and no dynamic import. This is covered by test/ssr.test.ts, which runs the full queue, chunked work and timeouts in a plain Node environment.

API

sharedRunner()

The page-wide runner with default options, created on first call. Use it unless you specifically need your own lifetime or budget. Not destroyable by design — it is meant to live as long as the page.

new IdleRunner(options?)

| Option | Type | Default | Description | | --------------- | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | budgetMs | number | 5 | Slice budget; a task starts only if more than this remains. Clamped to 1…49 (a task can only ever start when more than budgetMs remains, and the rIC deadline cap is 50, so 50 itself would never be satisfiable). | | scheduler | SchedulerAdapter | auto | Override the environment ladder — the seam for tests and exotic hosts. | | flushOnHidden | boolean | true | Drain the queue on visibilitychange: hidden / pagehide, because hidden pages may never get another idle period — or never come back. | | onError | (error: unknown) => void | — | Error channel for tasks nobody awaited. See Errors. Aborts are not reported. | | agingMs | number | 1000 | Starvation guard for priority (see Priority): a task waits at most this long before it outranks everything ahead of it. Infinity disables aging, i.e. strict priority. |

Methods

| Member | Description | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | push(fn, opts?) | Queue a function; resolves with its return value. | | pushChunked(generator, opts?) | Queue a generator; each yield is a pause point. Resolves with the generator's return value. | | clear(reason?) | Reject every pending task (AbortError by default, or your reason) and empty the queue. | | pause() / resume() | Stop/restart draining. A suspended generator resumes from the same yield. Note that timeout deadlines do not fire while paused. | | flush() | Run everything now, ignoring idleness. By construction this is a long task — it's the escape hatch, and what flushOnHidden calls. | | whenIdle() | Resolves when the queue is empty. Never rejects. See Waiting for the queue. | | size | Pending task count (including a suspended generator). | | isRunning | true while the runner is executing a slice. | | destroy() | Unbind lifecycle listeners (visibilitychange/pagehide/Safari beforeunload) and clear() pending tasks. Call this when a runner is no longer needed — otherwise it is pinned in memory for the page's lifetime. |

Per-task options (push / pushChunked):

| Option | Type | Description | | ---------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | timeout | number | ms after which the task is force-run even if the page never goes idle. Omit = may wait indefinitely. | | signal | AbortSignal | Abort this one task. Rejects with AbortError; a chunked task's finally blocks run via gen.return(). | | priority | 'user-blocking' \| 'user-visible' \| 'background' | Which bucket to run from first. Default 'user-visible'. See Priority. | | key | PropertyKey | Supersede any pending task with the same key. See Deduplicating by key. |

pushChunked takes one more:

| Option | Type | Description | | ------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | onProgress | (value: P) => void | Called with every value the generator yields, in the slice that produced it. Throws are warned and swallowed. Not called on the return value. |

Deadlines are measured on a monotonic clock (performance.now() where available), so a system clock change mid-flight cannot shift them.

idleMap(items, fn, options?) / idleForEach(items, fn, options?)

items.map(fn) / items.forEach(fn) spread across idle slices. Reject with the first throw from fn, or with AbortError on signal — the partial result is dropped either way. See Lists and progress.

| Option | Type | Default | Description | | ------------ | ------------------------ | ---------------- | ----------------------------------------------------------------------------------------------- | | runner | IdleRunner | sharedRunner() | Which queue to run on. | | chunkSize | number | 1 | Items processed between yields. Clamped to >= 1. | | onProgress | (done: number) => void | — | Items processed so far; the trailing partial chunk reports too, so it always ends on the total. |

Plus signal, timeout, priority and key, which behave exactly as on pushChunked.

createSchedulerAdapter(options?)

The environment ladder as a standalone adapter ({ request, cancel }, mirroring rIC's shape). Exported for tests and custom hosts; it is also the seam through which a future scheduler.postTask rung can land without a breaking change.

License

MIT