๐Ÿ“‹ What was this drill?

Drill: No decoration: a landing page using ONLY typography, spacing, and alignment. No gradients, no illustrations, no decorative SVG, no glassmorphism. Target: looks designed, not empty.

Interpretation: Built as a developer docs overview page for a Postgres-native background-jobs API ('Creek'). Tests whether pure typography + spacing + alignment carries hierarchy when content is code-heavy technical reference (SQL schemas, curl examples, API params, quickstart) rather than marketing prose. Aesthetic reference: Mintlify/Stripe-docs restraint โ€” ink-on-white, Inter + JetBrains Mono, links and code distinguished by weight and underline not by hue.

Durable background jobs for Postgres

Creek runs background work โ€” emails, reports, batch processing, scheduled cleanups โ€” using the Postgres database you already have. No new infrastructure, no Redis, no separate queue service. Jobs survive restarts, retries are automatic, and your transactional state and your job queue live in the same database.

Updated July 29, 2026 ยท SDK creek@2.4.1 ยท Postgres 13+

Why Creek #

Most background-job libraries fall into one of two camps. The first is Redis-backed (Sidekiq, BullMQ, Celery) โ€” fast, but you now operate a second datastore whose durability model differs from your primary database. A job can be marked "done" in Redis while the transaction that enqueued it rolls back, or vice versa. The second camp polls your database directly (a jobs table with SELECT โ€ฆ FOR UPDATE SKIP LOCKED), which keeps everything in one database but ships without retries, scheduling, concurrency control, or observability โ€” you rebuild all of it.

Creek closes that gap. It uses SKIP LOCKED for job claiming (so it scales horizontally without contention), but layers on the operational machinery you'd otherwise hand-roll: exponential backoff with jitter, per-queue concurrency caps, cron-style scheduling, dead-letter routing, and structured logging that correlates a job back to the request that enqueued it.

The dual-write problem

If you've used a Redis-backed queue, you've hit this: the application writes to Postgres, then enqueues a job in Redis. If the second call fails, the database commit succeeds but the job never runs. Creek enqueues inside the same transaction โ€” the job exists if and only if the row it relates to exists.

Quickstart #

A job is an async function with a typed payload. Register it, enqueue it from anywhere in your transaction, and Creek handles the rest.

quickstart.ts
import { Creek } from "creek";

const creek = new Creek({
  connectionString: process.env.DATABASE_URL,
});

// 1. Define a job
creek.job("send-welcome-email", async (ctx, payload) => {
  await mailer.send({
    to: payload.email,
    template: "welcome",
    name: payload.name,
  });
});

// 2. Enqueue inside a transaction
await db.transaction(async (tx) => {
  const user = await tx.users.create({ email, name });
  await creek.enqueue("send-welcome-email", {
    email: user.email,
    name: user.name,
  });
});

// 3. Start the worker (one process, or many)
creek.start();

That's a production-grade worker. It will retry failed jobs with exponential backoff, respect per-queue concurrency limits, and persist its state in your existing database. Scale by running more processes โ€” SKIP LOCKED prevents double-processing without a coordination service.

How it works #

Creek creates a single creek_jobs table in your database on first run:

schema ยท auto-created
CREATE TABLE creek_jobs (
  id            bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  queue         text      NOT NULL DEFAULT 'default',
  handler       text      NOT NULL,
  payload       jsonb     NOT NULL,
  priority      smallint  NOT NULL DEFAULT 0,
  attempts      smallint  NOT NULL DEFAULT 0,
  max_attempts  smallint  NOT NULL DEFAULT 25,
  run_at        timestamptz NOT NULL DEFAULT now(),
  locked_by     text,
  locked_at     timestamptz,
  last_error    text,
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX creek_jobs_claim_idx ON creek_jobs
  (priority DESC, run_at) WHERE locked_by IS NULL;

A worker claims the next available row with a single query โ€” no advisory locks, no polling coordination, no leader election:

claim query ยท runs every poll interval
UPDATE creek_jobs
   SET locked_by  = $1,      -- worker id
       locked_at = now()
  WHERE id IN (
    SELECT id FROM creek_jobs
     WHERE queue = $2
       AND run_at <= now()
       AND locked_by IS NULL
     ORDER BY priority DESC, run_at
     LIMIT $3         -- batch size
     FOR UPDATE SKIP LOCKED
  )
RETURNING *;

FOR UPDATE SKIP LOCKED is the Postgres feature that makes this safe under concurrency. If worker A has locked a row, worker B's query silently skips it and grabs the next available one โ€” no contention, no deadlocks, no double-processing. This is the same primitive Stripe, Shopify, and GitHub use for their internal job systems.

Throughput

On a standard db.r6g.large instance, a single Creek worker processes ~3,000 jobs/second. Throughput is bound by Postgres transaction latency, not network hops โ€” there is no network hop. Horizontal scaling is linear up to connection-pool limits (typically 40โ€“60 concurrent workers before you need pgbouncer).

Scheduling #

Any job can be scheduled for the future by passing a run_at timestamp. For recurring work, Creek reads a creek_schedules table that a maintenance loop populates โ€” no separate cron process, no sidecar.

scheduling.ts
// Run in 5 minutes
await creek.enqueue("generate-report", { userId }, {
  run_at: new Date(Date.now() + 5 * 60_000),
});

// Every Monday at 09:00 UTC
creek.schedule("weekly-summary", "0 9 * * 1", {
  generateForAllTeams: true,
});

REST API #

If you enqueue jobs from outside your application process โ€” a webhook receiver, a different service, a cron trigger โ€” use the HTTP API. Every endpoint accepts JSON and returns the created job.

POST /v2/jobs

Enqueue one or more jobs. Jobs are durable the moment the response returns 201 โ€” they live in Postgres and will survive a worker restart.

Body parameters
NameTypeRequiredDescription
handler string required The job's registered name, e.g. "send-welcome-email".
payload object required JSON passed to the job handler. Validated against the handler's schema if one is registered.
queue string default Target queue. Defaults to "default". Use distinct queues to isolate concurrency limits.
priority integer default Higher runs first. Range โˆ’32,768 to 32,767. Default 0.
run_at timestamp default Schedule for the future. ISO 8601. Defaults to now.
max_attempts integer default Override the handler's retry count. After exhausting attempts, the job moves to the dead-letter queue.
curl
curl https://api.creek.dev/v2/jobs \
  -H "Authorization: Bearer $CREEK_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handler": "send-welcome-email",
    "payload": { "email": "ada@example.com", "name": "Ada" },
    "priority": 10
  }'
application/json
{
  "id":            8492104,
  "queue":         "default",
  "handler":       "send-welcome-email",
  "priority":      10,
  "attempts":      0,
  "run_at":        "2026-07-29T14:03:11.482Z",
  "created_at":    "2026-07-29T14:03:11.482Z"
}

When to reach for Creek โ€” and when not to #

Creek is the right default when your jobs relate to data in Postgres and you value operational simplicity over raw throughput at the extreme tail. Specifically:

It is not the right tool for event streams at Kafka scale (millions of events/second), sub-millisecond latency requirements (the database round-trip is ~1ms), or workloads where you deliberately want the queue decoupled from your transactional database for isolation.

Next
Quickstart โ€” your first job in 90 seconds โ†’
Set in Inter & JetBrains Mono. Body 16/25.6. Content measure 680px.
No boxes, no color, no icons, no gradients. Hierarchy by type and space alone.