Run background tasks with Celery on Vercel
Deploy Celery on Vercel with the Python runtime, Vercel Queues, and Vercel Functions. Vercel builds each Celery worker as a private, queue-triggered Vercel Function, so you don't need to run a long-lived worker process.
Create a Celery app or use an existing one:
Celery projects on Vercel must declare their dependencies in pyproject.toml
[project]
name = "celery-on-vercel"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"celery>=5.3.0",
"fastapi",
]This example uses FastAPI to enqueue tasks. You can use any supported Python web framework for the producer.
from celery import Celery
app = Celery(
"celery-on-vercel",
broker="vercel://",
backend="vercel-runtime-cache://",
)
@app.task
def add(x: int, y: int) -> int:
return x + yThe vercel:// broker sends Celery tasks to Vercel Queues, and
vercel-runtime-cache:// stores task results in Runtime
Cache. Vercel installs the Celery adapter during
the build and registers both, so you don't need to add vercel-celery to your
dependencies or provision broker credentials.
Set the broker and backend explicitly rather than relying on the adapter's
defaults. Your configuration stays visible in tasks.py, and the application
behaves the same way wherever you run it.
Export the Celery application from a worker entrypoint. Importing tasks
registers its tasks on the application:
from tasks import app
__all__ = ["app"]Add the web entrypoint and Celery worker to pyproject.toml:
[tool.vercel]
entrypoint = "main:app"
[[tool.vercel.subscribers]]
entrypoint = "worker:app"The subscriber entrypoint takes the module:object format or a bare module
path such as worker. During the build, Vercel imports the entrypoint, reads
every queue the Celery application declares, and compiles the subscriber into a
queue-triggered function. You don't need to configure experimentalTriggers in
vercel.json.
With no topics filter, the subscriber consumes every queue the application
declares. See splitting queues across functions
to scope a subscriber to a subset of them.
Vercel builds main:app as the public web application and worker:app as a
private Vercel Function. Only Vercel Queues can invoke the worker function.
Import a task into your web application and call delay as you would in any
Celery application. Use AsyncResult to retrieve its status and result:
from fastapi import FastAPI
from tasks import add, app as celery
app = FastAPI()
@app.post("/add")
def enqueue(x: int, y: int):
return {"id": add.delay(x, y).id}
@app.get("/result/{task_id}")
def result(task_id: str):
task = celery.AsyncResult(task_id)
return {"status": task.status, "result": task.result}Each call to delay publishes a message to the topic that matches the task's
Celery queue. Vercel Queues then invokes the subscriber function to run the
task. The result endpoint reads the task state and return value from the result
backend.
Import tasks from tasks, not from worker. The worker.py module exists only
as the subscriber entrypoint, so your web application never needs to import it.
Use vercel dev to run the web application and Celery subscriber locally:
vercel devvercel dev starts both your web application and Celery worker locally. You
don't need to run celery worker in another terminal.
Deploy the project by connecting your Git repository or by using the Vercel CLI:
vc deployVercel provides Queue authentication to the deployed functions, so you don't need to provision Redis, RabbitMQ, or separate queue credentials.
When your web function calls delay or apply_async, the Vercel broker
transport publishes the task to the topic that matches its Celery queue. Vercel
Queues invokes the private subscriber function, which hands the delivery to an
in-process Celery worker and runs the task.
Named applications publish to app-prefixed topics. Celery("celery-on-vercel")
maps a Celery queue named emails to the Vercel Queue topic
celery-celery-on-vercel-emails. Applications with no name use unprefixed
topics. Set queue_name_prefix to change or remove the prefix. See splitting
queues across functions for how the build
reads these names.
Each invocation runs one task at a time. Your throughput comes from Vercel Queues invoking the subscriber function many times in parallel, not from worker processes or a pool size. See concurrency control.
Set broker_transport_options on the application to control naming and
delivery:
app.conf.broker_transport_options = {
"consumer_group": "workers",
"lease_duration": 300,
"requeue_delay_seconds": 60,
}| Option | Type | Default | Description |
|---|---|---|---|
consumer_group | str | celery | Consumer group used for subscriptions and polling |
queue_name_prefix | str | celery-<app name>- | Prefix applied to Celery queue names before topic sanitization |
retention | duration | Service default | Retention applied to published messages |
delay | duration | No delay | Delay applied to every message this application publishes |
lease_duration | duration | Service default | Processing timeout for received messages |
requeue_delay_seconds | int | Zero seconds | Visibility delay used when Celery requeues a message |
push_retry_delay_seconds | int | One second | Visibility delay used when a push delivery finds no free worker slot |
push_handoff_wait_seconds | float | 30 seconds | Maximum request-time wait for worker readiness and settlement |
use_task_id_as_idempotency_key | bool | False | Publish the Celery task ID as the Queues idempotency key |
The transport also accepts token, region, base_url, deployment,
timeout, and headers, and forwards them to the underlying queue client.
Workers that share a topic and a consumer group compete for tasks. Workers that
share a topic with different consumer groups each receive a copy of every task.
Set queue_name_prefix when other producers in the project publish to topics
with the same names as your Celery queues.
Celery acknowledges each delivery, not Vercel Queues. With Celery's default
task_acks_late = False, the worker acknowledges a message as soon as it accepts
the task, before running it. A task that raises is never redelivered, and neither
is one whose function times out while the task is still running.
Set task_acks_late = True to acknowledge after the task finishes:
app.conf.task_acks_late = TrueThe message then survives a function timeout or crash. Its processing lease
expires, and Vercel Queues delivers it again. Queues provides at-least-once
delivery, so tasks should be
idempotent. Use lease_duration to set how long a delivery may be in flight
before Queues treats it as lost.
Even with task_acks_late, a task that raises is still acknowledged, because
Celery treats a failed task as handled. Use Celery's retries to handle
application-level failures:
@app.task(bind=True, max_retries=5)
def notify(self, user_id: str) -> None:
try:
send_notification(user_id)
except TimeoutError as exc:
raise self.retry(exc=exc, countdown=5)The adapter registers vercel-runtime-cache://, a Celery result backend that
stores results in Runtime Cache. Configure it
through result_backend_transport_options:
app.conf.result_backend_transport_options = {
"namespace": "celery-on-vercel-results",
"ttl": 3600,
}Results go in a namespace derived from the application name. Producers and
workers that use different Celery app names need an explicit shared namespace
to read each other's results. Stored results use Celery's result_expires value
as their TTL unless you set ttl.
With no topics filter, the generated function consumes every queue the
application declares. Add a filter to split those queues across separate
functions.
Filters match Vercel Queue topic names, not Celery queue names. Declare the
queues in task_queues so the build can read them from the application:
from celery import Celery
from kombu import Queue
app = Celery(
"celery-on-vercel",
broker="vercel://",
backend="vercel-runtime-cache://",
)
app.conf.task_queues = [Queue("emails"), Queue("reports")]Each queue name gets the queue_name_prefix, which defaults to
celery-<app name>-, so this application declares two topics:
| Celery queue | Vercel Queue topic |
|---|---|
emails | celery-celery-on-vercel-emails |
reports | celery-celery-on-vercel-reports |
Filter each subscriber by those topic names:
[[tool.vercel.subscribers]]
entrypoint = "worker:app"
topics = ["celery-celery-on-vercel-emails"]
[[tool.vercel.subscribers]]
entrypoint = "worker:app"
topics = ["celery-celery-on-vercel-reports"]A trailing * matches by prefix, so topics = ["celery-celery-on-vercel-*"]
matches every queue this application declares.
A filter that matches none of the application's topics fails the build, and the error lists the topics the build found. That error is the quickest way to check a topic name:
subscriber "worker_app" declared topics [emails] but no introspected queue
subscriptions matched them; introspected topics
[celery-celery-on-vercel-emails, celery-celery-on-vercel-reports]
Celery tasks run inside Vercel Functions, so all Vercel Functions limitations apply, including maximum duration and bundle size.
- Task arguments: Use the default JSON serializer where you can. Other Celery serializers work, because non-JSON message bodies are stored through a base64 wrapper. Vercel Queues supports messages up to 100 MB.
- Long-running processes: On Vercel, tasks run in queue-triggered functions
instead of a persistent
celery workerprocess, so worker control commands and features that require persistent process state aren't available. To run a regularcelery workerelsewhere against the same queues, set the broker tovercel-poll://and see poll mode. - Periodic tasks:
celery beatrequires a long-running process. Use Vercel Cron Jobs to call a route that enqueues Celery tasks.
For more about deploying Celery on Vercel, see:
Was this helpful?