Products / SaaS / Backend Infrastructure
CronLock
Distributed cron for Node + Postgres — no double-runs across instances
Register cron jobs in code, enqueue due fires with idempotent runKey (jobName@fireTime), and claim runs with FOR UPDATE SKIP LOCKED so only one worker executes each fire.
Value
Problem. Running cron on multiple Node processes double-fires the same schedule. Redis-heavy job systems are overkill when you already run Postgres.
How it helps. Register cron jobs in code, enqueue due fires with idempotent runKey (jobName@fireTime), and claim runs with FOR UPDATE SKIP LOCKED so only one worker executes each fire.
Why buy. A thin distributed-cron spine you can read and own — no Redis required for single-region SaaS.
- In-code job registry with standard cron expressions
- Idempotent runKey = jobName@scheduledAt ISO — races insert once
- SKIP LOCKED claim across workers; Express API for jobs/runs/trigger
How it works
flowchart LR
Registry[Job Registry] -->|cron expr + handler| Worker
Worker -->|enqueue due fires| DB[(PostgreSQL)]
Worker -->|FOR UPDATE SKIP LOCKED| DB
Worker -->|run handler| Handler[Job Handler]
Handler -->|done / failed| DB
API[Express API] -->|health / jobs / runs| DB
Mermaid flowchart (render in GitHub / VS Code / mermaid.live).
Use case
Two app instances both run the worker; at :00 only one claims heartbeat@… — lockedBy shows the winner; the other gets zero rows.
What you get
- API + worker processes
- Built-in heartbeat + cleanup-old-runs jobs
- Prisma CronJob / CronRun models
- docker-compose.yml (host port 5434)
- 25 Vitest tests (cron parse, runKey, claim, process)
- MIT license
Project structure
cronlock/
src/
jobs/ heartbeat, cleanup, index
worker/ schedule, claim, process
services/ registry
routes/ jobs, health
lib/ cron, runKey, config, prisma
prisma/ tests/ docker-compose.yml
Quick start
docker compose up -d
cp .env.example .env
npm install && npm run prisma:generate && npm run prisma:migrate
npm run dev
npm run worker
Example
From src/services/registry.ts
registerJob({
name: "send-digest",
cronExpr: "0 9 * * 1-5", // 09:00 UTC weekdays
handler: async ({ jobName, scheduledAt, runId }) => {
// your work — email digests, rollups, etc.
},
});
// runKey = `${jobName}@${scheduledAt.toISOString()}`
Tested
25 Vitest tests — cron parsing, runKey uniqueness, claim SKIP LOCKED behavior (mocked), process done/failed, registry.
npm test
2026-09-20 — 25 passed; npm run build OK
Design decisions
- runKey uniqueness handles enqueue races; SKIP LOCKED handles claim races
- Jobs defined in code (not a dynamic UI) so deploys stay reviewable
- Postgres-only so CronLock pairs with HookQueue/TenantScope on one DB family
Limitations
- Not BullMQ/Agenda — no Redis dashboards, delayed job UI, or rate-limit suites
- Missed fires use SCHEDULE_LOOKBACK_MS on startup — not a full catch-up calendar
- Handlers must be registered in both API and worker processes
- At-most-once per runKey under normal Postgres primary semantics — not a distributed consensus guarantee
Who it’s for
Teams with multiple Node instances that need scheduled jobs without double-runs.
Not for: Shops that already standardized on Redis queues or need multi-region leader election.
What you own
Full MIT-licensed TypeScript source via Gumroad ZIP. Fork it, ship it commercially, keep the license notice. You are responsible for secrets, hardening, and production ops.
Related
HookQueue · TenantScope · IdemKey · RateGuard
Demo