RE09 All articles
AI & Machine Learning

Promises All the Way Down: When Async Code Is a Symptom, Not a Solution

RE09
Promises All the Way Down: When Async Code Is a Symptom, Not a Solution

Photo: abstract code flow diagram async programming javascript developer whiteboard, via easy-peasy.ai

There's a specific kind of JavaScript file that every experienced frontend developer has opened and immediately closed again. You know the one. It's async functions calling async functions calling async functions, with Promise.all wrappers around things that probably shouldn't be running in parallel, and a try/catch block so wide it's catching errors from three conceptual layers at once.

The code works. Mostly. But reading it feels like trying to follow four conversations at the same time.

Here's the uncomfortable question nobody asks in code review: is all this async complexity solving a real concurrency problem, or is it papering over a design that was never quite right to begin with?

What Async Complexity Usually Signals

Async patterns — promises, async/await, event emitters, reactive streams — are genuinely powerful. They're the right tool for genuinely concurrent problems: network I/O, parallel data fetching, background processing. When you're waiting on three API calls and none of them depend on each other, running them in parallel makes sense. That's async doing its job.

But a lot of async code in production applications isn't solving a concurrency problem. It's managing state that's spread across too many places, sequencing operations that have implicit dependencies nobody documented, or compensating for a data model that forces you to make multiple round trips to get information that should have been colocated.

In those cases, async isn't a feature. It's a workaround. And like most workarounds, it works until it doesn't — and when it breaks, it breaks in ways that are genuinely hard to debug.

The Single-Responsibility Tell

One of the clearest signals that your async complexity has become structural is when a single async function is doing multiple conceptually distinct things.

A function that fetches user data, checks permissions, enriches the response with metadata from a second service, and then conditionally triggers a downstream event isn't a function with a concurrency concern. It's four functions that were never separated out. Wrapping that in async/await doesn't make it well-designed — it makes it well-disguised.

Single-responsibility violations are harder to spot in async code because the asynchronous scaffolding creates visual noise that obscures what the code is actually doing. You're looking at awaits and Promise.alls and you're thinking about the concurrency model when you should be thinking about whether this function has a coherent reason to exist.

The test is simple: can you describe what this function does in one sentence without using the word "and"? If you can't, the async complexity is probably hiding a design problem, not solving one.

Sequential Business Logic Doesn't Need to Be Concurrent

This sounds obvious, but it's worth saying plainly: most business logic is sequential. Step A has to happen before Step B. The output of one operation is the input to the next. That's not a concurrency problem — that's a pipeline.

When teams reach for async patterns to handle sequential logic, they often do it because async has become the default mode of the codebase. Everything is async, so new code is async, even when the operation it's performing is inherently linear.

The cost of that default shows up in a few ways. Error handling gets complicated because failures at different points in the async chain behave differently. Testing gets harder because you're now coordinating async state in your test setup. Debugging gets harder because the call stack in an async context looks different from what you'd expect in synchronous code.

Some teams are pushing back on this by deliberately asking, for each new piece of logic: does this actually need to be asynchronous? If the answer is no — if the operation is sequential, single-threaded, and doesn't involve waiting on external I/O — they write it synchronously. The result is code that's easier to read, easier to test, and easier to reason about.

The Abstraction Ladder Problem

Here's where async debt compounds in ways that catch teams off guard.

When async complexity gets uncomfortable, the natural instinct is to abstract it. You write a helper that wraps the messy Promise chain. Then you write a hook that wraps the helper. Then you write a context provider that manages the state that the hook produces. Each layer makes the immediate problem more manageable, but the underlying complexity doesn't go away — it just moves down the stack.

This is the abstraction ladder problem. You keep climbing up, adding layers that make the surface cleaner, but the foundation is still a tangle. And when something goes wrong — when you get a race condition three layers deep, or a memory leak in an event listener that nobody remembered to clean up — you have to descend the entire ladder to find it.

The alternative is to stop climbing and start excavating. What's the actual operation here? What does it need as input and what does it produce as output? Can you express that clearly without the async scaffolding? Sometimes the answer to that question produces code that's simpler by an order of magnitude.

Rethinking the Data Flow First

A lot of async complexity in UI applications comes from data fetching patterns that force the UI to orchestrate things it shouldn't have to think about. The component knows it needs user data and permission data and preference data, so it fires three requests and manages the loading states and error states for all three.

The modern answer to this — server components, React Query's dependent queries, GraphQL fragments — is essentially: push the orchestration somewhere else. Let the component describe what it needs, not how to get it. The async complexity doesn't disappear, but it moves to a layer that's designed to handle it.

That's a useful design principle beyond any specific tool: async orchestration belongs in infrastructure, not business logic. When your business logic is full of async complexity, that's often a sign that the infrastructure layer isn't doing enough.

A Practical Reset

If you're looking at an async-heavy codebase and feeling that ambient unease that something isn't quite right, here's a useful exercise:

Pick your most complex async function and try to draw a dependency graph. What does each awaited operation actually depend on? What could run in parallel versus what has to be sequential? What would this look like if you modeled it as a pure data transformation instead of a series of async operations?

Often, just doing that exercise surfaces design decisions that were made implicitly, under time pressure, without full visibility into where the code would end up. And sometimes, the right refactor isn't a better async pattern — it's a different data model that makes the async pattern unnecessary.

Async/await was a genuine improvement over callback hell. But it didn't solve the design problems that produce complex async code in the first place. That part is still on us.

All Articles

Related Articles

Ask Forgiveness, Not Permission: The Quiet Wins Behind Your Slowest Company's Fastest Launches

Ask Forgiveness, Not Permission: The Quiet Wins Behind Your Slowest Company's Fastest Launches

Your Architecture Is Exhausting Your Engineers — and You Built It That Way

Your Architecture Is Exhausting Your Engineers — and You Built It That Way

Read the Logs: How Developers Are Ditching Observability Sprawl and Just Thinking Again

Read the Logs: How Developers Are Ditching Observability Sprawl and Just Thinking Again