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
| Celery concept | Here |
|---|---|
@shared_task | a task definition in example/src/jobs/tasks.ts |
delay() / apply_async() | an enqueue helper calling task.runNoWait(input) |
| beat schedule | onCrons on the task definition |
| the worker process | bun run worker — long-running, separate from the web server |
| Flower | Hatchet'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 -dhatchet-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-f1c4546cbf52Graceful 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.