Celery
Reliable once you stop trusting the defaults
Celery runs the background work behind a voice agent that calls suppliers for parts quotes while the phone line stays free. It does the job well. Getting there took explicit acks, a memory ceiling on every worker, and a rule against ever letting a task assume it ran once.
The short answer
Celery is the right default for background jobs in a Python backend, provided you configure acknowledgements, retries and worker memory limits yourself rather than trusting what ships out of the box.
The library is mature and the documentation is thorough. But several defaults exist for backward compatibility, not safety. A job can be marked done before it actually finishes. A worker can leak memory for weeks before anyone notices. A task can silently run twice. None of these are bugs. They are settings, and the fix is knowing which ones to change before load finds them for you.
What decides this
4 things that decide this
- 01Celery is a task queue for Python: a web process hands off slow work, and one or more workers pick it up and run it separately.
- 02The default acknowledgement setting marks a task done as soon as a worker receives it, not when it finishes, so a worker crash mid-task can lose the job silently.
- 03Long-running workers accumulate memory over weeks unless you cap tasks per worker, because Python's allocator does not always return freed memory to the OS.
- 04Celery gives you retries and scheduling out of the box. It does not give you exactly-once execution. That is on you to build.
A task queue, not a message broker
Celery is the standard way a Python backend moves work off the request path. A web process publishes a task, a broker holds it, and a separate worker process picks it up and runs it. Redis or RabbitMQ usually plays the broker. Celery itself is the layer above that: retries, scheduling, rate limits and task routing.
That separation is the whole reason to reach for it. Anything slow, or dependent on a third party, belongs in a worker rather than blocking a response the user is waiting on. The trade is operational. You now run two kinds of process, and the queue between them can hide problems the request-response cycle used to surface right away.
Where it holds up and where it does not
Strengths
- Retries with backoff are a few lines of configuration, not something you hand-roll, and they cover the common case of a flaky downstream call cleanly.
- Celery Beat schedules recurring jobs from the same codebase as the tasks themselves, so a cron job and its logic never drift apart in two different places.
- Routing tasks to named queues lets you give slow, low-priority work its own workers, so a batch of supplier calls never starves the queue handling something time-sensitive.
- The ecosystem is old enough that most failure modes are documented somewhere, which matters more than it sounds once you are debugging a stuck queue at night.
Trade-offs
- The default acknowledgement setting confirms receipt, not completion. A worker that dies mid-task can lose that task with no error anywhere, and this bit us before we switched to late acknowledgement.
- Worker memory grows over days of continuous operation unless you set a task limit per worker process and let it recycle. Left unset, a long-running worker eventually gets killed by the host and takes its in-flight jobs with it.
- Celery assumes at-least-once delivery, which means at-least-once execution unless the task itself is written to be safe to run twice. A supplier call that is not built idempotent can go out twice under a retry.
- Observability is thin without extra tooling. Flower helps for a dashboard view, but knowing why a specific task is stuck usually means reading logs across two or three processes by hand.
What we use it for
ZhoopZhoop is a voice-agent platform for a multi-branch auto repair business. Inbound agents answer customer calls and book appointments. A separate outbound flow works through a queue of parts requests. It calls suppliers one at a time, collects availability and pricing, then hands back a comparison. That outbound work runs on Celery, with Redis as the broker, behind a FastAPI backend.
Putting supplier calls in a queue rather than a request handler was the point from day one. A supplier call can take a minute, or run into a busy line. None of that should hold up a customer on the phone or block the front desk dashboard. It also gave us a natural place to enforce order. One call to a given supplier finishes before the next one starts, instead of racing several requests against the same phone line.
A single deploy taught us the most. A worker restart mid-deploy silently dropped two in-flight supplier calls, because the default acknowledgement had already marked them done. We moved to late acknowledgement and added a visibility timeout on the Redis broker. Every outbound task now checks whether a quote already exists before placing the call again.
- Task dispatchedWeb process hands off, does not wait
- Worker acks earlyDefault setting confirms receipt, not completion
- Worker dies mid-taskDeploy, OOM kill, or a crash
- Task is goneBroker already marked it delivered
- No retry firesNothing knows the job failed
Switching to late acknowledgement closes this path, at the cost of a task running twice if the worker dies after finishing but before confirming. That is the trade you are actually choosing, not a bug you can avoid.
Celery against BullMQ
TrialTriage, a clinical trial matching platform on a Node backend, runs the equivalent background work on BullMQ instead. Both sit on Redis and solve the same problem: keep slow work off the request path and survive a worker restart. The difference is less about capability and more about which ecosystem the rest of the backend already lives in.
BullMQ's job states and events are easier to inspect from the same process that enqueued them, since everything stays in JavaScript. Celery's strength is scheduling and retry policy proven over a decade. It also fits naturally when the rest of the backend, like FastAPI, is already Python. Pick based on the language your team already owns, not a feature checklist. Both queues fail the same way if you skip idempotency.
Where this runs in production
What teams ask before committing
01Is Celery still the right choice in 2026?
For a Python backend, yes. It remains the default task queue, with the widest documentation and the most mature retry and scheduling tooling. The decision that matters more than picking Celery is configuring acknowledgement, worker memory limits and idempotency correctly, since the defaults favour compatibility over safety.
02Celery or BullMQ?
Match the queue to the backend language rather than treating this as a feature comparison. Celery fits a Python service. BullMQ fits Node. Both run on Redis and both need the same discipline around retries and idempotent tasks, so switching one for the other does not remove the work.
03How do you stop a Celery worker from leaking memory?
Set a per-worker task limit so the process recycles after a fixed number of tasks, rather than running indefinitely. Python does not always return freed memory to the OS, so a long-lived worker under continuous load grows until something kills it. Recycling on a schedule you control is safer than finding out from an outage.
04Does Celery guarantee a task runs only once?
No. Celery's delivery model is at-least-once, which means a task can run twice under certain failure conditions, particularly after a worker crash. The task itself has to check whether the work already happened, such as confirming a quote does not already exist before placing another supplier call.
Related
- Redis in production →The broker Celery runs on, reviewed on its own terms.
- FastAPI in production →The backend framework Celery workers sit behind on this build.
- Hire Python developers →Backend engineers who have run Celery queues under real load.
- Hire backend developers →For teams deciding between a Python or Node background job stack.

