Skip to main content
Special EditionFull Stack

"Job Scheduler" — Distributed Background Job Platform

Screenshot of Job Scheduler

A distributed job scheduling platform that runs background jobs across a worker cluster with priorities, per-queue concurrency limits, retries with backoff, a dead-letter queue, cron-based recurring jobs, and a live WebSocket dashboard. Atomic claiming with FOR UPDATE SKIP LOCKED guarantees no job ever runs twice, and advisory-lock leader election keeps exactly one scheduler active with automatic failover. Six hand-rolled data structures power the scheduling layer — a binary min-heap for priority dispatch, Kahn's algorithm for DAG execution plans, three-colour DFS for cycle rejection, union-find for independent job clusters, a retry delay queue, and Deficit Round Robin for fair queuing — covered by 156 unit tests and CI.

Tech Stack
Node.jsTypeScriptExpressPostgreSQLWebSocketsReactViteDAG SchedulingCI
My role
Solo — backend, scheduling algorithms, dashboard, tests
Built for
Personal project · Jul–Sep 2026

“The most important line of code is a carefully worded FOR UPDATE SKIP LOCKED.”

I.

The Brief

A job scheduler has one promise that matters more than every feature: a job must never run twice by accident, and it must never be lost when a worker dies halfway through.

Most tutorials reach for Redis or a broker. I wanted to see how far correctness could go on the one dependency the system already needed — Postgres — and where the database genuinely runs out of expressiveness.

II.

The Approach

Claiming is a single UPDATE … WHERE id IN (SELECT … FOR UPDATE OF j SKIP LOCKED): many workers poll the same queue, each locks rows the others aren’t touching, and dependency gating happens in the same statement. Per-queue concurrency is a hard bound across the fleet — the count and the claim share one transaction behind a per-queue advisory lock — and a Postgres advisory lock elects a single scheduler leader, with automatic failover when the leader’s connection drops.

Where SQL can’t express the decision, six structures are implemented by hand and wired into the running scheduler: a binary min-heap for cross-queue priority dispatch, Kahn’s algorithm for DAG execution plans, three-colour DFS to reject dependency cycles, union-find for independent job clusters, a min-heap delay queue for retries, and Deficit Round Robin so one saturated queue can’t starve the rest.

III.

How It Works

  1. 01APIExpress · JWT · project RBAC
  2. 02Postgresjobs table, SKIP LOCKED claim
  3. 03Workersmin-heap dispatch, heartbeats
  4. 04Schedulerleader-elected tick, cron, reaper
  5. 05DashboardWebSocket push + polling fallback
IV.

Key Decisions

Postgres over a broker

One dependency instead of two. The trade-off is a latency floor from polling and the jobs table as the throughput ceiling — acceptable at this scale, with LISTEN/NOTIFY as the next step.

Lock only what you claim

The claim uses FOR UPDATE OF j, not a plain FOR UPDATE: it joins each job’s dependency only to read it, and locking those rows too would let two jobs that share a dependency block each other’s claims for no reason.

At-least-once, honestly

If a worker dies, the reaper requeues after the heartbeat timeout, so a job can run twice in a crash. Exactly-once isn’t achievable in general; handlers are expected to be idempotent.

A push that carries no data

WebSocket events are just “go refresh jobs/queues/workers”. The client re-fetches through the authenticated REST routes, so the socket never duplicates per-row access checks — and polling stays on as a fallback.

0
double-claims — 300 jobs, 10 concurrent claimers
6
data structures, from scratch
156
algorithm unit tests
<1s
dashboard updates via WebSocket
Known limits — stated plainly
  • Polling sets a latency floor; LISTEN/NOTIFY would remove idle polls at larger scale.
  • Guarantees are at-least-once — job handlers must be idempotent.