---
title: Background Jobs & Scheduling
description: How scheduled (cron) and asynchronous background work runs on the platform
date: 2026-05-30
---

The platform runs scheduled tasks (cron) and asynchronous background work on
**[BullMQ](https://docs.bullmq.io/)**, backed by a shared Redis instance. Work
that shouldn't block an HTTP response — email delivery, AI calls, webhook
fan-out, file processing, periodic maintenance — is handed to a queue and
processed by a dedicated worker.

## Web pods vs. worker pods

Background jobs do **not** run inside the request-serving (web) pods. A
`WORKER_ROLE` environment variable decides what a process boots:

| `WORKER_ROLE` | What it runs                                  | Where               |
| ------------- | --------------------------------------------- | ------------------- |
| `web` (default) | HTTP only — no workers                      | the web Deployment  |
| `worker`      | cron + async job handlers, in one process     | the worker Deployment |

Because `WORKER_ROLE` defaults to `web`, the web pods never accidentally start
processing jobs. The cron and async handlers can later be split into separate
Deployments if their scaling needs diverge, without any code change.

```mermaid
graph LR
    Web[Web pods<br/>WORKER_ROLE=web] -- enqueue --> Redis[(Redis<br/>BullMQ)]
    Redis -- dequeue --> Worker[Worker pods<br/>WORKER_ROLE=worker]
    Worker -- cron + async handlers --> DB[(Database / external APIs)]
```

## Two kinds of work

- **Static cron** — schedules known at build time (e.g. "renew expiring mail
  subscriptions every hour"). Registered in code at worker boot; the schedule
  is reconciled automatically when it changes.
- **Async queues** — work triggered by a request that the user shouldn't wait
  for. The request handler enqueues a job and returns immediately; a worker
  picks it up.

## Idempotency is mandatory

A job may run **more than once** for the same input — BullMQ retries failed
jobs, recovers jobs from a crashed worker, and supports manual re-enqueue.
Every handler must therefore be **idempotent**: running it twice with the same
payload must be safe. Common patterns are compare-and-set state transitions,
idempotency keys, and naturally convergent ("renew if expiring, else no-op")
operations.

## For contributors

Step-by-step guidance for adding a cron or async job — local setup
(`npm run redis`, `npm run dev:worker*`), the handler registry, connection
rules, and the idempotency contract — lives in the internal developer docs at
`docs/development/queue-and-cron.md`. The shared cluster-level design (Redis
topology, network policy, per-app worker Deployments) is documented in the
`platform-infra` repository.
