Durable wake-ups for TypeScript apps and agents. Reminders, expirations, retries, debounces, and agent resumes. Backed by Postgres or SQLite.
await dk.schedule("send-reminder", { key: "user_123", delay: "24h" });
await dk.debounce("reindex", { key: "doc_789", wait: "5s" }); // run once after edits settleDelayKit gives your app primitives for scheduling work to do later, without standing up a workflow engine, queue, or extra service. Jobs persist as rows in your Postgres or SQLite database and your handler runs in your own process when its row comes due.
Runs on: Node, Bun, and serverless functions (Vercel, Lambda). Tested against Node 22, Bun 1.3.13, Postgres 14/15/16/17, and SQLite via
better-sqlite3andbun:sqlite.Status: pre-1.0. Minor releases may include breaking changes. See the changelog.
Jump to: Quick start · What you can build · Deploy · Mental model · Compare · API
npm install delaykit # Node
bun add delaykit # BunTry 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 or SQLiteStore 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 below.
// 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, swap in PostgresStore or SQLiteStore. See Pick a store.
Want to run something end-to-end? Start with examples/basic.ts (MemoryStore), examples/sqlite.ts, or examples/postgres.ts. For a full HTTP server, see examples/bun-sqlite-server/. All runnable examples live in examples/.
- Jobs survive restarts and deploys. Durable in Postgres or SQLite, not in memory.
- At most one active job per
(handler, key). Same handler and key won't create a second pending job. - Handlers see fresh state. They receive the key and fetch current data at execution time, not a snapshot from when the job was scheduled.
- Failures retry on a configurable schedule. Exponential, linear, or fixed backoff per handler.
- Crashed processes don't strand jobs. Stalled-job recovery reclaims any handler whose lease expired.
- Handler concurrency is capped.
PollingSchedulerruns at mostmaxConcurrenthandlers at once (default 10). - Zero runtime dependencies.
postgres,better-sqlite3, and@posthook/nodeare optional peers.
When a long-running agent waits for human input, schedule a timeout so the run doesn't hang.
await dk.schedule("approval-timeout", { key: "run_789", delay: "24h" });
// When approval arrives:
await dk.unschedule("approval-timeout", "run_789");If approval doesn't come in time, the handler resumes the agent run with a "timed out" outcome.
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");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
});User updates several fields. Reindex once after they stop, not on every change.
await dk.debounce("reindex", { key: "project_789", wait: "5s" });DelayKit has two moving parts. Pick each independently based on your infrastructure.
Store. The durable source of truth. Owns the job rows. Every execution decision reads from the row, so duplicate or stale wake-ups don't cause incorrect runs. Implementations: PostgresStore, SQLiteStore, MemoryStore (dev only).
Scheduler. The wake-up signal. Decides when to claim due jobs: PollingScheduler polls the store; PosthookScheduler registers a webhook with Posthook. Wake-ups are disposable. The store row has the final say on what actually runs.
The interface between them is small (claim, complete, fail, defer), so you can pair them freely.
SQLite. Local-first, zero infra. The simplest path for single-process apps: a Bun server, a Node backend on one VPS, a desktop or CLI tool.
bun add delaykit # Bun: bun:sqlite is built in
npm install delaykit better-sqlite3 # Node: better-sqlite3 is the optional peerimport { SQLiteStore } from "delaykit/sqlite";
const store = await SQLiteStore.connect("./delaykit.db");Auto-migrates on first connect. Single-process: one PollingScheduler per file. For horizontal-scale polling, use Postgres. Multi-process Bun servers (Bun.serve({ reusePort: true })) need Postgres too — SQLite cannot serialize writes across processes safely under load.
Postgres. Multi-replica. For multi-instance apps and serverless. Share an existing postgres (postgres.js) pool, or pass a connection string:
import postgres from "postgres";
import { PostgresStore } from "delaykit/postgres";
const sql = postgres(process.env.DATABASE_URL!);
const store = await PostgresStore.connect(sql);
// Or: await PostgresStore.connect(process.env.DATABASE_URL!);Auto-migrates on first connect. Works with Neon, Supabase, Railway, or any Postgres.
Pick by where your code lives:
- Long-running process (Node, Bun, Docker, VPS, Fly). Call
dk.start()to poll continuously. Works with SQLite or Postgres. - Serverless and cron (Vercel, Lambda). Call
dk.poll()from a cron route. Postgres only. - Managed delivery with Posthook. Push-based — Posthook calls your endpoint when each job is due, so there's no cron route or worker process to operate.
For walkthroughs of each option, plus tuning maxConcurrent, polling interval, cooperative timeouts, and Postgres migrations at deploy time, see docs/deploy.md.
What DelayKit is. DelayKit is the pending-jobs table and polling loop you'd otherwise build yourself - with deduplication, crash recovery, and retries handled.
Keys, not payloads. Jobs carry a key ("user_123"), not a payload snapshot. Handlers fetch current state when they run, so they always act on fresh data. If you need an immutable value from scheduling time (the price at the time of an order, for example), store it in your own tables and look it up by key in the handler.
Crash recovery. Jobs are durable in Postgres or SQLite, and DelayKit re-runs the handler after a stalled lease expires. Treat execution as at-least-once — a handler may run more than once for the same job. Fetching current state at execution time makes many handlers naturally safe: a guard like if (user.onboarded) return skips correctly on re-run if the user resolved between attempts. For handlers with external side effects (sending an email, charging a card), use an idempotency key when the service supports it, or check whether the action already completed before executing it.
What DelayKit doesn't track. DelayKit wakes a handler with a key at a scheduled time. It doesn't store workflow state, branch on outcomes, or pass payloads. Multi-step flows (do X, wait, do Y, wait, do Z) live in your own tables; DelayKit provides the timing primitive between steps. See How it compares below for what handles the broader cases.
When DelayKit fits. Reach for DelayKit when you need per-entity timers that fire on their own, regardless of whether anyone visits your app: reminders, expirations, agent timeouts, debounces across replicas. If you can defer the work until the next request, you probably don't need this.
For the full correctness model (the invariants that hold across stores and schedulers), see docs/invariants.md.
DelayKit has no built-in dashboard. Instead it emits structured lifecycle events and exposes dk.stats(). Wire those into whatever you already use for monitoring and alerting.
dk.on("job:failed", ({ job, error, reason }) => {
logger.error("job failed", { handler: job.handler, key: job.key, reason });
metrics.increment("delaykit.job.failed", { handler: job.handler });
});
dk.on("job:completed", ({ job, durationMs }) => {
metrics.histogram("delaykit.job.duration", durationMs, { handler: job.handler });
});| 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 |
job:awaiting_handler |
Webhook delivery for an unregistered handler; rescheduled |
job:rescheduled |
Handler called ctx.reschedule(...); row continues at the new time |
Listeners run inline. Keep them fast (logging, metrics). Listener errors are caught and won't break your handlers.
For backlog stats, single-job retry, and bulk retry of failed jobs, see dk.stats(), dk.retryJob(id), dk.listFailed(opts), and dk.retryFailed(opts) in docs/api.md. Triage and bulk-retry workflows, connection-pool sizing, and retention are covered in docs/operations.md.
| Tool | Best for | How DelayKit fits |
|---|---|---|
Cron (incl. cron-as-code: vercel.json, graphile crontab) |
Declarative, build-time schedules — recurring tasks like generate-monthly-report or sync-exchange-rates |
DelayKit handles the imperative case: runtime-defined per entity, change frequency or cancel without redeploy |
| Queues (BullMQ) | Short-lived, high-throughput jobs (Redis-backed) | Long-future durable timers as rows in Postgres/SQLite, not Redis memory. Compose cleanly: DelayKit fires at time T, handler enqueues a BullMQ job |
| Workflow engines (Inngest, Temporal) | Multi-step pipelines with branching, waiting, orchestration | DelayKit does one thing: run your handler at the right time |
DelayKit's TypeScript types are the canonical reference. Hover any method in your editor for full signatures and inline docstrings.
For an at-a-glance lookup table of every method plus the duration format, see docs/api.md.
Contributions welcome. See CONTRIBUTING.md for project layout, test commands, and conventions.
MIT
From the maker of Posthook.