RE09 All articles
Engineering Culture

Silent Failures: The Async Work Your System Forgot It Was Doing

RE09
Silent Failures: The Async Work Your System Forgot It Was Doing

The dashboard was green. The uptime was 99.9%. The on-call engineer had a quiet week. And somewhere in the background, the system had stopped sending welcome emails to new users — for eleven days.

Nobody noticed because the job that sent those emails was asynchronous. It failed, got retried, failed again, and eventually got buried in a dead letter queue that nobody had set up alerts for. The queue depth metric existed in the monitoring system. Nobody was watching it.

This is async rot. It's not a single catastrophic failure. It's a slow accumulation of silent misfires that your observability stack wasn't designed to catch.

Why Async Is Hard to Trust

Asynchronous systems are genuinely useful. Offloading work to background jobs means your request cycle stays fast, your users don't wait, and you can absorb traffic spikes without cascading failures. The pattern is sound. The problem is what happens when the work fails.

With synchronous code, failure is loud. An exception bubbles up. The user sees an error. The engineer gets paged. The feedback loop is tight and the problem is visible.

With async code, the work happens somewhere else, at some other time, with no direct connection to the user who triggered it. When it fails, that failure lands in a queue, or a log file, or nowhere at all, depending on how the system was built. The user never knows. The engineer never knows. The business keeps running, sort of, while a whole category of work quietly stops happening.

The Retry Loop That Makes Things Worse

Most async systems have retry logic. That's good. But retry logic without proper backoff and failure budgets turns a small problem into a large one.

Imagine a background job that processes a webhook payload. The downstream service it calls is having a slow day. The job times out and gets retried. The downstream service is still slow. The job times out again. Now you have a growing backlog of retry attempts, all hammering a service that's already struggling, all consuming worker capacity that other jobs need.

This is the retry storm pattern. It's well-documented and still extremely common, because the logic that creates it — "if it fails, try again" — feels correct in isolation. The failure mode only becomes visible at scale, or when the downstream service is degraded rather than completely down.

The compounding effect is the real danger. A retry storm doesn't just delay the failing jobs. It can starve healthy jobs of worker capacity, slow your entire async layer, and create the appearance of a system-wide problem when the actual issue is localized. You end up debugging the wrong thing.

The Monitoring Blind Spot

Here's what async monitoring usually looks like: queue depth, job throughput, error rate. Here's what it often misses: jobs that fail silently without emitting an error, jobs that succeed technically but produce incorrect results, jobs that are running but producing no output, and the age of the oldest item in a queue.

That last one is underrated. Queue depth tells you how many jobs are waiting. Queue age tells you how long they've been waiting. A queue with 500 jobs that are all less than 30 seconds old is healthy. A queue with 50 jobs where the oldest is 6 hours old is not. The depth metric looks better in the second scenario. The system is much more broken.

Silent success is the sneakiest failure mode. A job that runs, completes without error, and produces no output is indistinguishable from a healthy job if you're only watching the success/failure flag. This is how teams discover, weeks later, that their data processing pipeline has been running but not actually processing anything — because a configuration change broke the output path and the job had no way to express that it had failed.

What Teams Actually Find

The discovery stories are remarkably consistent. An engineer is debugging something unrelated and happens to glance at a queue metric they don't normally watch. Or a customer complains about a missing feature and the investigation traces back to a background job that stopped working after a deploy three weeks ago. Or a quarterly audit reveals that a compliance report that was supposed to run weekly has been silently failing for two months.

One team found out their image resizing pipeline had been broken for six weeks when a designer noticed that all user avatars uploaded after a certain date were serving the original full-resolution files instead of thumbnails. The job was running. It was completing without errors. It was just silently producing no output because the storage path had changed and nobody had updated the job's configuration.

Another team discovered their fraud detection pipeline had a retry bug that was causing the same transactions to be evaluated multiple times. The job wasn't failing — it was succeeding too much. Their fraud model had been seeing duplicate data for months, skewing its outputs in ways that were hard to trace.

Making Async Observable

The fix isn't glamorous. It's mostly about building in the visibility that async systems don't provide by default.

Emit explicit completion signals. Don't just let a job finish — have it write a record of what it did. Jobs processed, records updated, bytes written. If the number is zero when it shouldn't be, that's a signal.

Alert on queue age, not just queue depth. Set a threshold for how old the oldest item in a queue is allowed to be. If it crosses that threshold, something is wrong regardless of what the depth looks like.

Build dead letter queues and watch them. Every async system should have a place where failed jobs land after exhausting retries. That queue should have an alert on it. If anything lands there, someone should know.

Use idempotency keys and log them. If you can tell whether a job has already run successfully, you can detect duplicate execution and missing execution. Both are failure modes.

Test failure paths explicitly. When you write a background job, write a test that simulates the downstream service being unavailable and verify that the job fails in the way you expect — loudly, with a meaningful error, without cascading.

The Zombie Layer Problem

Async rot accumulates because background jobs are out of sight. They don't have users waiting on them. They don't have engineers watching them. They run in the background, which is exactly the point, until the background becomes a place where work goes to quietly disappear.

The teams that avoid this aren't the ones who avoid async patterns — those patterns are too useful to abandon. They're the ones who treat background jobs as first-class citizens of their system, with the same observability requirements as any user-facing feature.

The work that happens in the background matters just as much as the work that happens in the foreground. Build your monitoring like you believe that.

All Articles

Related Articles

Prototype Forever: The Code You Wrote in a Weekend That Now Runs Your Business

Prototype Forever: The Code You Wrote in a Weekend That Now Runs Your Business

Your GitHub Profile Is the New Business Card Nobody Told You About

Your GitHub Profile Is the New Business Card Nobody Told You About

Deploy on a Tuesday Afternoon and Go Home Calm: The Feature Flag Mindset

Deploy on a Tuesday Afternoon and Go Home Calm: The Feature Flag Mindset