Skip to content

delaykit/delaykit

Repository files navigation

DelayKit

CI npm dependencies types license

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 settle

DelayKit 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-sqlite3 and bun: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

Quick start

npm install delaykit   # Node
bun add delaykit       # Bun

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 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/.

What DelayKit guarantees

  • 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. PollingScheduler runs at most maxConcurrent handlers at once (default 10).
  • Zero runtime dependencies. postgres, better-sqlite3, and @posthook/node are optional peers.

What you can build

Wake an agent after a human-in-the-loop timeout

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.

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");

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
});

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" });

Deploy to production

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.

Pick a store

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 peer
import { 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.

Three runtime shapes

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.

Mental model

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.

Observability

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.

How it compares

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

API

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.

Contributing

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

License

MIT

From the maker of Posthook.

About

Durable wake-ups for TypeScript apps and agents. Reminders, expirations, retries, debounces, and agent resumes — backed by Postgres or SQLite.

Topics

Resources

License

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors