Skip to content
Tikab's Toolkit

Background jobs

The Celery counterpart is Hatchet — a self-hosted job engine with queues, retries, fan-out, crons and a dashboard. The Toolkit block is deliberately small: the env kill-switch and the shared client. The tasks themselves are app code, exactly like your Celery tasks were.

The block

Live, in its entirety — note the comment about what this entry deliberately does not import:

/**
 * Background jobs are optional. They light up only when `HATCHET_CLIENT_TOKEN`
 * is set — the same graceful-degradation contract as the mailer (`SMTP_HOST`
 * in @repo/mailer). When unset, the web app boots normally and enqueue
 * helpers simply log-and-skip.
 *
 * This entry imports NOTHING (no Hatchet SDK), so the web server can check the
 * flag — and the app's enqueue helpers can guard on it — without pulling the
 * gRPC client into the request bundle. The SDK lives behind the separate
 * `@repo/jobs/hatchet` entry, which is only ever loaded via dynamic import
 * (in the app's enqueue helpers) or by the standalone worker.
 */
export function isJobsEnabled(): boolean {
  return Boolean(process.env.HATCHET_CLIENT_TOKEN);
}

The client lives behind a separate subpath (/hatchet) so the web server can check the flag without ever pulling the gRPC SDK into its bundle. Consumers load it dynamically, only when jobs are on.

How the pieces split

Loading diagram...
Celery conceptHere
@shared_taska task definition in example/src/jobs/tasks.ts
delay() / apply_async()an enqueue helper calling task.runNoWait(input)
beat scheduleonCrons on the task definition
the worker processbun run worker — long-running, separate from the web server
FlowerHatchet's built-in dashboard

Run it locally

Jobs are off by default — the app boots and works without them. Turning them on is a one-time setup:

Start the engine
docker compose up -d

hatchet-lite (engine + dashboard) and its own Postgres are part of the default profile. Dashboard → http://localhost:8080, default login admin@example.com / Admin123!!.

Mint a client token

In the dashboard under Settings → API Tokens, or via the CLI:

docker compose exec hatchet-lite /hatchet-admin token create \
  --config /config --tenant-id 707d0855-80ab-4e1f-a156-f1c4546cbf52
Point the app at it

In example/.env.local:

HATCHET_CLIENT_TOKEN=<the JWT>
# Local hatchet-lite speaks plaintext gRPC — without this the SDK tries TLS
# and fails with "h2 is not supported".
HATCHET_CLIENT_TLS_STRATEGY=none
HATCHET_CLIENT_HOST_PORT=localhost:7077
Run the worker
bun run worker

It registers the app's tasks and starts listening. Without a worker, enqueued jobs simply wait in the queue. bun run jobs:demo enqueues a multi-user batch so you can watch the per-user round-robin in the dashboard.

Graceful degradation, concretely

HATCHET_CLIENT_TOKEN unset → every enqueue helper logs and returns. Uploads still work; the AI analysis simply stays pending until a worker exists. The web app never needs the engine to boot. This is the same contract as email and AI.

Fairness worth copying

The example's document-analysis task keys concurrency on the user with round-robin: one user dumping a hundred documents doesn't starve everyone else's three. When you write your own heavy tasks, decide the fairness key first.