Programming

Flask Celery Redis: A Production Background Task Setup

Offload heavy processing from your Flask API to Celery and Redis with this battle-tested architectural pattern.

By · · 6 min read

Short answer

To offload background tasks in Flask, use Celery with Redis as a message broker. Define a Celery instance, decorate your heavy tasks with @celery.task, and run the worker process separately from your Gunicorn web server. This ensures your API stays responsive while long-running jobs process in the background.

Key takeaways

  • Separate your worker processes from your Gunicorn/Flask web processes to avoid blocking the event loop.
  • Use Redis as both your message broker and result backend for low-latency communication.
  • Configure systemd to manage your Celery worker persistence across server reboots.
  • Always use task signatures and avoid passing complex database objects directly to worker arguments.

Why combine Flask with Celery and Redis?

Flask is a request-response framework built on WSGI principles. When a client sends an HTTP request, the web server allocates a worker thread or process to handle it. This worker remains occupied until the logic finishes and the response is sent. If you trigger an expensive computation, like generating a complex PDF document or parsing a large file, the worker stays busy for the duration of that task. If your app receives five concurrent requests for such tasks, your server becomes unresponsive to any new traffic, creating a major performance bottleneck.

Celery functions as a distributed task queue that exists independently of your main application thread. Instead of executing code inside the HTTP request handler, you push a message into a queue. This message contains the function name and the necessary arguments. Redis acts as the intermediary, or message broker, storing these messages in a lightweight data structure. A separate Celery worker process monitors this queue, picks up the messages, and executes the functions in the background.

This decoupling strategy allows your API to respond with a 202 Accepted status almost instantly. The user does not need to wait for the task to finish; instead, you provide a task ID that can be used to check status later. This architecture is essential for any high-traffic application where responsiveness is a requirement, ensuring that your API layer stays lean and available while your worker pool handles heavy computational loads.

How to initialize the Celery application

You need a dedicated Celery instance that shares your application configuration. It is cleaner to initialize this in a separate file to avoid circular imports, where your main Flask app imports Celery and Celery imports the app. By creating a factory function, you can keep the initialization logic modular and easy to manage as your codebase grows.

When setting up the Celery instance, you must define both a broker and a backend. The broker defines how Celery sends and receives messages, while the backend defines how Celery stores the results of the tasks once they are finished. Using Redis for both purposes is a common choice because it provides low-latency communication and efficient storage. Ensure you point these configuration strings to your Redis instance, typically running on localhost:6379, though you should move these to environment variables in a real production setup to maintain security and flexibility.

from celery import Celery

def make_celery(app_name):
    return Celery(
        app_name,
        broker='redis://localhost:6379/0',
        backend='redis://localhost:6379/0'
    )

celery = make_celery('my_flask_app')

Triggering tasks from your routes

Once configured, you define your background logic by decorating functions with the @celery.task decorator. This tells Celery that the function is a distributed task rather than a standard Python function. When calling these functions from your Flask routes, do not call them normally. Instead, use the .delay() method. This method serializes the function arguments into a message, sends it to the Redis queue, and immediately returns control to your Flask application, which then finishes the HTTP request cycle.

One critical error developers make is passing complex database models or file buffers as arguments. Celery tasks are often executed in a separate memory space, sometimes even on a different server. When a task is queued, it must be serialized into JSON. SQLAlchemy models or complex Python objects cannot be easily serialized. Always pass the primary key ID of the database record instead of the object itself. Inside the task, you can query the database using that ID to fetch fresh data, which avoids stale data issues and serialization crashes.

@celery.task
def process_heavy_job(job_id):
    # Simulate expensive operation
    import time
    time.sleep(10)
    return f'Task {job_id} completed'

@app.route('/start-job', methods=['POST'])
def start_job():
    job_id = 123
    process_heavy_job.delay(job_id)
    return {'message': 'Job started'}, 202

Managing worker persistence with systemd

In production, your workers must survive server reboots and crashes. If your worker process dies, tasks will sit in Redis without ever being processed. To prevent this, use systemd to manage your worker as a background service. This ensures that the worker starts automatically when the server boots and restarts automatically if it encounters a fatal exception.

The systemd unit file requires specific fields. The [Unit] section provides metadata. The [Service] section tells systemd how to run the process: you specify the user (e.g., www-data), the directory where your application lives, and the exact path to the Celery binary within your virtual environment. Setting the concurrency level is also vital; you should set this based on your available CPU cores to avoid memory saturation. Finally, [Install] ensures the service is enabled for the multi-user target, allowing it to start during the normal boot sequence.

[Unit]
Description=Celery Worker
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/my_app
ExecStart=/var/www/my_app/venv/bin/celery -A app.celery worker --loglevel=info
Restart=always

[Install]
WantedBy=multi-user.target

Comparing background task strategies

While Celery is the industry standard for Python, other options exist depending on the scale and language ecosystem of your project. The choice usually comes down to whether you need complex task orchestration or a simple queue.

Celery offers advanced features like task retries, periodic schedules, and complex workflows that are ideal for distributed systems. However, this comes with higher configuration requirements. Smaller tools like RQ are built specifically for Redis and offer a much simpler API, though they lack the extensive feature set of Celery. Choosing the right tool depends on whether your project requires high-scale distribution or ease of maintenance.

ToolBrokerBest ForComplexity
CeleryRedis/RabbitMQLarge scale, distributedHigh
RQRedisSimpler Python appsLow
HueyRedis/SQLiteLightweight scriptsVery Low

Common gotchas and production trade-offs

Memory leaks are a frequent challenge in long-running Python workers. Because the worker stays alive for days or weeks, small leaks in your task code can gradually consume all available server RAM, eventually leading to OOM (Out of Memory) kills. To mitigate this, use the --max-tasks-per-child flag. This tells the worker to recycle its child process after it has finished a specific number of tasks, effectively clearing the memory before the leak becomes a problem.

You also need to monitor your Redis queue size. If your background tasks take longer to finish than the rate at which they are triggered, your queue will grow indefinitely. This leads to increasing latency between when a user initiates a task and when it finally finishes. Using a monitoring tool like Flower is highly recommended; it provides a web interface to view active tasks, history, and worker health, allowing you to identify bottlenecks before they impact your users.

Frequently asked questions

Do I need RabbitMQ instead of Redis?

For most Flask applications, Redis is sufficient and easier to maintain. Use RabbitMQ only if you require advanced message routing features or if you have extremely high-throughput requirements that demand persistent message delivery guarantees beyond what Redis provides.

How do I return task results to the user?

Use the AsyncResult object provided by Celery. Store the task ID in your database when the job is triggered, and create a separate GET endpoint where the frontend can poll for the status using that ID.

Can I run the worker on the same server as my API?

Yes, but be careful with resource allocation. If both the web server and the worker are intensive, they will fight for CPU and RAM. You may need to limit the concurrency of your workers to prevent the server from crashing during traffic spikes.

Sources & further reading

About the author

Tushar Gautam is a full-stack & ai product engineer in India. He builds AI SaaS products such as an AI video ad generator, a RAG chatbot platform and a Razorpay-powered report service, mostly with Next.js, Flask and the Gemini API. See the projects, GitHub or LinkedIn.

This article was drafted with the help of AI (Google Gemini) from official documentation and published by Tushar Gautam. Spotted an error? Let me know.