The Setup: Tasks That Vanish Into the Void
There's a particular kind of horror that every backend engineer eventually experiences. You've got a Celery task queue humming along, your workers look healthy, your monitoring dashboard shows green lights, and then someone from the business side asks — "Hey, why didn't those 10,000 emails go out last night?" You check your Celery logs. Nothing. No errors, no tracebacks, no retries. The tasks just... didn't happen. They evaporated.
I've spent more late nights than I'd like to admit chasing this exact ghost. And nine times out of ten, when tasks disappear without so much as a whisper in the logs, the culprit is Redis running out of memory. Not crashing dramatically. Not throwing errors that wake up your on-call engineer. Just quietly refusing to accept new work while your application code happily believes it's successfully queuing tasks.
This is what makes Redis OOM (Out of Memory) errors so uniquely dangerous in a Celery setup. The failure is silent on the producer side. Your Python code calls .delay() or .apply_async(), gets back what looks like a successful result, and moves on. Meanwhile, Redis has already decided it's too full and thrown the task into the trash. No receipt. No error. No second chance.
Understanding How Celery Uses Redis
Before we can fix the problem, we need to understand the relationship between Celery and Redis. Think of Celery as a restaurant with a kitchen (the workers) and an order ticket rail (Redis). When a customer places an order, the waiter clips the ticket to the rail. Workers grab tickets and cook food. Simple enough.
Redis, in this analogy, is that physical ticket rail. It has a finite amount of space. When the rail is full, new tickets have nowhere to go. But here's the critical difference from a real restaurant: in the digital version, the waiter doesn't notice that the ticket fell on the floor. They walk away thinking the order is placed. The customer waits forever for food that was never cooked.
Technically speaking, Celery serializes your task — converts it into a compact text or binary format — and publishes it as a message to a Redis list (or stream, depending on your configuration). The Redis list acts as the queue. Workers run BLPOP commands — that's Redis-speak for "block and wait until there's an item in this list, then pop it off" — to consume messages. When Redis hits its memory limit, it invokes an eviction policy, which determines what gets deleted or rejected. The default policy in many Redis configurations is noeviction, which means Redis starts refusing write commands instead of deleting old data. Your task publish silently fails, or the connection throws a barely-noticed exception that Celery's default configuration swallows.
The Memory Limit Problem in Detail
Redis is an in-memory data store — everything it holds lives in RAM. This is why it's blindingly fast. But RAM is finite and expensive. Most production Redis instances are configured with a maxmemory directive that caps how much RAM Redis is allowed to use. The moment Redis bumps up against that ceiling, things get interesting.
There are several eviction policies Redis supports, and each has dramatically different behavior. noeviction is the most treacherous for task queues because it doesn't delete anything — it just refuses new writes and returns errors. allkeys-lru would delete the least recently used keys to make room, which sounds better but means your queued tasks could themselves be evicted to make room for, say, a cached database query. Neither outcome is acceptable when you need guaranteed task delivery. I've found that many teams configure Redis as a general-purpose data store — caching, sessions, and task queuing all in one instance — which is essentially a ticking time bomb.
When the memory limit is hit and noeviction is active, Redis returns an error like OOM command not allowed when used memory > 'maxmemory'. The problem is what happens to that error at the application layer. If you're using Celery's default broker transport options without any retry logic or error handling on the producer side, this error can get swallowed. The .delay() call returns, your code continues, and the task is gone.
Reproducing the Problem
Let's make this concrete. Here's a simple Celery setup that demonstrates the vulnerability:
# tasks.py
from celery import Celery
app = Celery('tasks', broker='redis://localhost:6379/0')
@app.task
def process_user_notification(user_id: int, message: str):
# Imagine this sends an email or push notification
print(f"Processing notification for user {user_id}: {message}")
return True
# producer.py - This is how you're likely queuing tasks
from tasks import process_user_notification
def queue_notifications_for_all_users(user_ids: list):
for user_id in user_ids:
# This looks safe. It is NOT safe under memory pressure.
result = process_user_notification.delay(user_id, "Your report is ready")
print(f"Queued task for user {user_id}: {result.id}")
print(f"Done! Queued {len(user_ids)} notifications.")
# You will see this success message even if Redis silently dropped tasksNotice anything dangerous? There's no error handling around .delay(). No check that the task was actually accepted. The result.id you get back is generated client-side before the broker even confirms receipt. If you run this when Redis is at memory capacity, you'll see every single "Queued task for user X" log line print successfully, and then your workers will process far fewer tasks than you sent.
Detecting When Redis Is Actually Full
Before we fix the problem, you need to be able to detect it. Redis exposes detailed memory information through the INFO memory command, and checking this should be part of your monitoring setup.
# redis_health_check.py
import redis
import logging
logger = logging.getLogger(__name__)
def check_redis_memory_health(redis_url: str = 'redis://localhost:6379/0') -> dict:
"""
Returns memory health status of the Redis instance.
Should be called from your monitoring or alerting system.
"""
client = redis.from_url(redis_url)
info = client.info('memory')
used_memory = info['used_memory']
max_memory = info.get('maxmemory', 0) # 0 means no limit set
health = {
'used_memory_human': info['used_memory_human'],
'max_memory_human': info.get('maxmemory_human', 'No limit'),
'eviction_policy': info.get('maxmemory_policy', 'unknown'),
'mem_fragmentation_ratio': info.get('mem_fragmentation_ratio', 0),
}
if max_memory > 0:
usage_pct = (used_memory / max_memory) * 100
health['usage_percentage'] = round(usage_pct, 2)
health['is_critical'] = usage_pct > 85 # Danger zone starts here
if usage_pct > 85:
logger.warning(
f"Redis memory usage is at {usage_pct:.1f}%! "
f"Tasks may start getting dropped at 100%."
)
else:
health['usage_percentage'] = None
health['is_critical'] = False
logger.info("Redis has no maxmemory limit configured.")
return healthThe key metric to watch is usage_percentage. In my experience, once you're past 80%, you're in the danger zone. Past 90%, you're playing Russian roulette with every task you queue. The mem_fragmentation_ratio is also worth watching — when it climbs above 1.5, Redis is using significantly more physical memory than it thinks it is due to fragmentation, meaning your effective headroom is less than Redis itself reports.
The Real Fix: Separate Your Concerns and Add Resilience
The fundamental architectural mistake is treating Redis as a multi-purpose tool when it's serving as your task broker. Caches are intentionally ephemeral — losing a cached value is annoying but recoverable. Tasks are work orders — losing them means work doesn't happen. Mixing these two workloads on the same Redis instance is like storing both your to-do list and your scratch paper in the same pile and then setting the pile on fire when it gets too big.
The first thing you should do is run a dedicated Redis instance for Celery. Give it its own memory allocation, its own monitoring, and critically, a different eviction policy. For task queues, noeviction is actually the right policy — you want Redis to error loudly rather than silently delete tasks. But you need to pair that with proper error handling on the producer side so those errors don't get swallowed.
Here's a more resilient way to queue tasks with explicit error handling and a retry mechanism:
# resilient_producer.py
import time
import logging
import redis
from celery import Celery
from celery.exceptions import OperationalError
from kombu.exceptions import OperationalError as KombuOperationalError
app = Celery('tasks', broker='redis://localhost:6379/0')
logger = logging.getLogger(__name__)
def safe_queue_task(task_func, *args, max_retries: int = 3, retry_delay: float = 2.0, **kwargs):
"""
A wrapper that attempts to queue a Celery task with explicit
retry logic and error handling for broker failures.
"""
last_exception = None
for attempt in range(1, max_retries + 1):
try:
result = task_func.apply_async(args=args, kwargs=kwargs)
if attempt > 1:
logger.info(f"Task queued successfully on attempt {attempt}")
return result
except (OperationalError, KombuOperationalError, redis.exceptions.ResponseError) as e:
last_exception = e
error_msg = str(e)
# Check specifically for OOM error from Redis
if 'OOM' in error_msg or 'maxmemory' in error_msg.lower():
logger.error(
f"Redis OOM error on attempt {attempt}/{max_retries}. "
f"Redis is out of memory. Task: {task_func.name}, "
f"Args: {args}"
)
else:
logger.error(
f"Broker connection error on attempt {attempt}/{max_retries}: {e}"
)
if attempt < max_retries:
wait_time = retry_delay * attempt # Exponential-ish backoff
logger.info(f"Retrying in {wait_time} seconds...")
time.sleep(wait_time)
except Exception as e:
# Don't retry on unexpected errors — surface them immediately
logger.exception(f"Unexpected error queuing task {task_func.name}: {e}")
raise
# If we got here, all retries failed
logger.critical(
f"FAILED to queue task {task_func.name} after {max_retries} attempts. "
f"Last error: {last_exception}. Task args: {args}"
)
raise last_exception
# Usage — now you'll actually know if tasks fail to queue
from tasks import process_user_notification
def queue_notifications_safely(user_ids: list):
failed_users = []
for user_id in user_ids:
try:
safe_queue_task(process_user_notification, user_id, "Your report is ready")
except Exception:
failed_users.append(user_id)
logger.error(f"Permanently failed to queue notification for user {user_id}")
if failed_users:
logger.critical(f"Could not queue tasks for {len(failed_users)} users: {failed_users}")
# Here you'd alert your on-call team, write to a dead letter queue, etc.
return failed_usersThis approach won't magically fix an OOM condition, but it will tell you about it. That's the critical first step. Silent failures become loud failures. Loud failures get fixed.
Configuring Redis Properly for Task Queues
Beyond the application code, you need to configure Redis itself correctly. Your redis.conf or your managed Redis configuration should treat task queue data as precious. A few specific settings matter enormously here.
Set maxmemory-policy noeviction for your task broker Redis — this ensures tasks are never silently evicted. Set a realistic maxmemory value that gives you headroom, and make sure you have alerting in place when usage crosses 70%. Use hz 20 or higher — this controls how often Redis runs internal maintenance tasks and directly affects how quickly it can process commands under load. And absolutely enable appendonly yes with appendfsync everysec if you need some durability guarantee, though understand that this comes with a performance tradeoff.
On the Celery configuration side, set broker transport options that enforce visibility timeouts and tell Celery to actually raise exceptions when broker operations fail:
# celery_config.py
CELERY_BROKER_TRANSPORT_OPTIONS = {
'visibility_timeout': 3600, # 1 hour - tasks grabbed but not finished reappear after this
'socket_timeout': 5, # Don't wait forever for Redis to respond
'socket_connect_timeout': 5,
'retry_on_timeout': True,
'max_connections': 20, # Don't let a broken pool consume all Redis connections
}
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BROKER_CONNECTION_MAX_RETRIES = 10
# This is critical — tell Celery to actually raise errors instead of silently failing
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_TASK_ACKS_LATE = True # Only acknowledge a task AFTER it completes, not when receivedCELERY_TASK_ACKS_LATE = True deserves special mention. By default, Celery acknowledges tasks when they're received by a worker, not when they're completed. If a worker crashes mid-task, that task is gone. With late acknowledgment, the task stays in the queue until the worker reports it's done, and if the worker dies, the task reappears for another worker to pick up.
Monitoring, Alerting, and Long-Term Prevention
Getting burned once by Redis OOM should be the last time. The fix isn't just technical — it's cultural. Your team needs to treat Redis memory as a first-class concern in your monitoring stack, not an afterthought.
Set up Prometheus with the redis_exporter and create alerts that fire at 70% and 85% memory usage. If you're on AWS using ElastiCache, DatabaseMemoryUsagePercentage is the CloudWatch metric you want, and you should alarm on it at 80%. On Datadog, the redis.mem.used and redis.mem.maxmemory metrics give you everything you need to build a clean utilization graph. The goal is to get paged before Redis hits capacity, not after tasks have already been dropped.
Equally important is tracking queue depth over time. The Celery Flower monitoring tool (a web-based real-time monitor for Celery) gives you queue lengths per queue name. If you see queue depth climbing and never shrinking, that's often a sign that your workers are falling behind, which often precedes an OOM event as messages pile up. I've found that a simple Grafana dashboard showing queue depth alongside Redis memory usage tells you almost everything you need to predict problems before they become incidents.
Finally, consider whether Redis is the right broker for your workload at all. Redis is excellent for high-throughput, low-latency task queuing where at-least-once delivery is acceptable. But if you need guaranteed delivery — every task must run, no exceptions — you should seriously evaluate RabbitMQ or AWS SQS as your broker instead. These tools are built around durability and message acknowledgment in ways that Redis, as a general-purpose cache, simply wasn't designed for. That's not a knock on Redis — it's the wrong tool being used in the wrong job, and every engineer owes it to themselves to pick the right one.
The silent task killer isn't a bug in Celery. It isn't a bug in Redis. It's the gap between what these tools do by default and what engineers assume they do. Close that gap, add the error handling, separate your workloads, and monitor relentlessly. Your tasks will stop disappearing. And you'll get your nights back.