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

delaykit

v0.4.0

Published

Run code later in Next.js. Reminders, expirations, follow-ups — backed by Postgres.

Readme

DelayKit

Run code later in Next.js. Reminders, expirations, follow-ups — backed by Postgres.

Quick start

npm install delaykit

Try it locally with MemoryStore — no database needed:

import { DelayKit } from "delaykit";
import { MemoryStore } from "delaykit/memory";
import { PollingScheduler } from "delaykit/polling";

const dk = new DelayKit({
  store: new MemoryStore(), // swap to PostgresStore for production
  scheduler: new PollingScheduler(),
});

dk.handle("send-reminder", async ({ key }) => {
  const user = await db.users.find(key);
  if (user.onboarded) return; // already acted, skip
  await sendEmail(user.email, "Complete your profile");
});

await dk.start(); // for serverless (Vercel), use poll() instead — see Deploy to production

// Send a reminder if the user hasn't onboarded after 24 hours
await dk.schedule("send-reminder", {
  key: "user_123",
  delay: "24h",
});

// User completed onboarding — cancel the reminder
await dk.unschedule("send-reminder", "user_123");

MemoryStore is for local development. For jobs that survive restarts, use PostgresStore — see Deploy to production.

What you can build with it

Expire a trial or reservation

await dk.schedule("expire-trial", { key: "acct_456", delay: "14d" });

// Or use an absolute time
await dk.schedule("expire-trial", { key: "acct_456", at: trialEndsAt });

// User upgraded — cancel the expiration
await dk.unschedule("expire-trial", "acct_456");

Reindex after a burst of edits

User updates several fields — reindex once after they stop, not on every change.

await dk.debounce("reindex", { key: "project_789", wait: "5s" });

Send a follow-up after inactivity

If the user comes back, the timer resets.

await dk.schedule("follow-up", {
  key: "user_123",
  delay: "3d",
  onDuplicate: "replace", // resets the timer on each visit
});

Safe to call from repeated requests

Same handler + same key won't create duplicate jobs. Call schedule from every request — only one pending job exists at a time.

await dk.schedule("welcome-email", { key: "user_123", delay: "10m" });

What DelayKit handles for you

  • Jobs survive restarts and deploys — they're in Postgres, not memory
  • No duplicate jobs — same handler + key won't create a second pending job
  • Fresh state at execution time — handlers receive the key and fetch current data, no stale payloads
  • Automatic retries — failed handlers retry with configurable backoff
  • Stalled job recovery — crashed processes don't leave stuck jobs
  • Bounded concurrencyPollingScheduler runs at most maxConcurrent handlers at once (default 10); the rest stay pending in the store and are claimed on subsequent polls
  • Handlers should be idempotent — DelayKit prevents duplicate scheduling, but handlers may re-execute after a crash recovery

Tuning concurrency

PollingScheduler runs at most maxConcurrent handlers at a time. Default is 10. Raise it for I/O-bound handlers, lower it for CPU-heavy ones:

new PollingScheduler({ maxConcurrent: 25 });

Excess due jobs stay pending in the store and are claimed on subsequent polls.

Cooperative timeouts. Every handler has a timeout — 30s by default, or whatever you set via timeout:. When the timer fires, DelayKit aborts ctx.signal and then waits for the handler to return before releasing its concurrency slot. Pass signal through to whatever the handler is calling (most modern Node APIs — fetch, pg, etc. — accept one) so the handler exits on abort. Handlers that ignore the signal hold their slot until they return on their own:

dk.handle("send-email", {
  handler: async ({ key, signal }) => {
    await fetch(`https://api.example.com/send/${key}`, { signal });
  },
  timeout: "10s",
});

Deploy to production

DelayKit has two moving parts: the store (Postgres) and the scheduler (how jobs get picked up at their scheduled time). Pick the scheduler that matches your infrastructure.

Connect to your Postgres

If your app already has a postgres (postgres.js) pool, pass it to DelayKit directly so both share one connection pool against the database:

// lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!);

// lib/delaykit.ts
import { sql } from "./db";
import { PostgresStore } from "delaykit/postgres";

const store = await PostgresStore.connect(sql);

A connection string works too — convenient for scripts and tests that don't already have a pool:

const store = await PostgresStore.connect(process.env.DATABASE_URL!);

Either form auto-migrates on first connect. Works with Neon, Supabase, Railway — any Postgres.

Option 1: Vercel + Posthook (managed delivery)

npm install delaykit postgres @posthook/node

Posthook delivers each scheduled job to your app as a webhook at the right time. No cron, no long-running process:

import { DelayKit } from "delaykit";
import { PostgresStore } from "delaykit/postgres";
import { PosthookScheduler } from "delaykit/posthook";
import { sql } from "./db"; // from the snippet above

const store = await PostgresStore.connect(sql);
const dk = new DelayKit({
  store,
  scheduler: new PosthookScheduler({
    apiKey: process.env.POSTHOOK_API_KEY!,
    signingKey: process.env.POSTHOOK_SIGNING_KEY!,
    basePath: "/api/delaykit",
  }),
});

Mount a catch-all route to receive deliveries:

// app/api/delaykit/[handler]/route.ts
import { dk } from "@/lib/delaykit";

export async function POST(req: Request) {
  const d = await dk();
  const handler = d.createHandler();
  return handler(req);
}

Option 2: Vercel + cron (self-hosted polling)

npm install delaykit postgres

Set up DelayKit with PollingScheduler:

// lib/delaykit.ts
import { DelayKit } from "delaykit";
import { PostgresStore } from "delaykit/postgres";
import { PollingScheduler } from "delaykit/polling";
import { sql } from "./db"; // from the snippet above

export async function dk() {
  const store = await PostgresStore.connect(sql);
  const dk = new DelayKit({ store, scheduler: new PollingScheduler() });

  dk.handle("send-reminder", async ({ key }) => {
    // your handler logic
  });

  return dk;
}

Add a poll route:

// app/api/delaykit/poll/route.ts
import { dk } from "@/lib/delaykit";

export async function GET(req: Request) {
  // Verify the request is from Vercel Cron or an authorized caller
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  const d = await dk();
  await d.poll({
    batchSize: 10, // jobs per batch, run concurrently (default: 10)
    timeout: "8s", // hard deadline — leave headroom under Vercel's 10s function limit
  });
  return Response.json({ ok: true });
}

Set CRON_SECRET in your Vercel environment variables. Vercel automatically sends it with cron requests. For external cron services, include it as Authorization: Bearer <secret>.

poll() processes due jobs in batches of batchSize, running each batch concurrently. It keeps processing batches until there are no more due jobs or timeout is reached. If a handler is still running when the deadline hits, it stays in running state and is automatically recovered on the next poll cycle.

Schedule the cron:

// vercel.json (Pro plan — runs every minute)
{ "crons": [{ "path": "/api/delaykit/poll", "schedule": "* * * * *" }] }

Vercel Hobby only allows daily cron — use an external service for more frequent polling:

  • Vercel Cron (Pro) — every minute, built-in
  • cron-job.org — free, calls any URL on a schedule
  • Posthook Sequences — hourly on the free tier
  • Any server with croncurl https://your-app.vercel.app/api/delaykit/poll

Running migrations at deploy time

By default, PostgresStore.connect() applies any pending migrations on first connect. That's fine for development and small deployments. For production — especially on Vercel, where cold starts can stack up and function timeouts can cut off a long migration — apply migrations once at build time and skip request-time migration. connect() still runs a cheap version check so a mis-wired deploy fails loudly instead of silently at the first query.

Add a postbuild script that runs migrations before the app starts serving:

// package.json
{
  "scripts": {
    "build": "next build",
    "postbuild": "node scripts/delaykit-migrate.js"
  }
}
// scripts/delaykit-migrate.js
import { runMigrations } from "delaykit/postgres";

await runMigrations(process.env.DATABASE_URL);
console.log("[delaykit] migrations applied");

Then disable request-time migration in your app:

const store = await PostgresStore.connect(sql, { runMigrations: false });

If the schema is behind what the installed library requires, connect({ runMigrations: false }) throws a clear error naming both versions. That's the safety net if you wire runMigrations: false but forget postbuild.

Preview deployments. Vercel Preview builds run postbuild too. Scope DATABASE_URL to both Production and Preview (pointing at separate databases), or scope the migration script to Production only.

Schema compatibility. Every DelayKit release ships migrations that are backwards-compatible with the previous release's code. Old pods continue to run during Vercel's rollover. See CONTRIBUTING.md → Schema changes for the full contract.

Option 3: Long-running server (VPS, Docker, Fly)

For any host that runs a long-lived Node process, call dk.start() to begin continuous polling:

import { DelayKit } from "delaykit";
import { PostgresStore } from "delaykit/postgres";
import { PollingScheduler } from "delaykit/polling";
import { sql } from "./db"; // from the snippet above

const store = await PostgresStore.connect(sql);
const dk = new DelayKit({ store, scheduler: new PollingScheduler() });

dk.handle("send-reminder", async ({ key }) => { /* ... */ });

await dk.start();

One PollingScheduler per database. Running more than one instance against the same store is not yet supported — concurrent pollers race on claim and can degrade throughput. For horizontal scaling today, use Option 1 above (Posthook delivery). Leader election for multi-instance polling is on the post-v1 roadmap.

Graceful shutdown. On SIGTERM, call dk.stop({ drainMs }) to wait for in-flight handlers to finish before the process exits:

process.on("SIGTERM", async () => {
  await dk.stop({ drainMs: 30_000 });
  process.exit(0);
});

How it works

Jobs live in Postgres. A cron route calls dk.poll() on a schedule to find due jobs and run your handlers. If the process crashes mid-execution, the job is still in Postgres — it recovers on the next poll cycle.

DelayKit stores keys, not payloads. Handlers receive the key (user_123) and fetch current state when they run. This means handlers always act on fresh data, not stale snapshots from scheduling time. If you need an immutable snapshot (e.g., the price at the time of an order), store that in your app's tables and schedule the job with a reference to it.

Not cron, not a queue, not a workflow engine

  • Cron is for recurring tasks on a fixed schedule. DelayKit is for one-time actions tied to a specific user or entity.
  • Queues (BullMQ, QStash) process background jobs as soon as possible. DelayKit schedules actions for a specific time in the future.
  • Workflow engines (Inngest, Temporal) orchestrate multi-step pipelines. DelayKit does one thing: run your handler at the right time.

Lifecycle events

dk.on("job:completed", ({ job, durationMs }) => {
  console.log(`${job.key} completed in ${durationMs}ms`);
});

| Event | Fires when | |-------|-----------| | job:scheduled | Job created (schedule, debounce, throttle) | | job:started | Handler begins executing | | job:completed | Handler succeeded | | job:failed | Retries exhausted | | job:retrying | Handler failed, will retry | | job:cancelled | Job cancelled | | job:stalled | Stalled job detected and recovered |

Listeners run inline during job execution — keep them fast (logging, metrics). Listener errors are caught and won't break your handlers.

API reference

| Method | Description | |--------|-------------| | dk.handle(name, handler) | Register a handler (before start/poll/createHandler) | | dk.schedule(handler, opts) | Schedule a one-time job | | dk.debounce(handler, opts) | Debounce rapid events into one handler call | | dk.throttle(handler, opts) | Throttle to one handler call per time window | | dk.cancel(id) | Cancel a pending job by ID | | dk.unschedule(handler, key) | Cancel by handler and key | | dk.getJob(id) | Look up a job by ID | | dk.getJobByKey(handler, key) | Look up the active job for a handler + key | | dk.poll(opts?) | Run one poll cycle (for cron routes) | | dk.createHandler() | Create a webhook route handler (for external schedulers) | | dk.on(event, listener) | Subscribe to lifecycle events |

Duration format

Delays and timeouts use human-readable strings: "5s", "30m", "24h", "14d", "500ms". Compound durations work too: "1h30m".

| Unit | Example | |------|---------| | ms | "500ms" | | s | "30s" | | m | "5m" | | h | "24h" | | d | "14d" |

Contributing

See CONTRIBUTING.md for project layout, test commands, and conventions.

License

MIT

Built by the team behind Posthook.