Blog

AJAX vs Async/Await: When Sequential-Looking Code Slows Down Interactive UIs

Date TBD

Introduction

I have been working with JavaScript since 1998. I learned web engineering in an era when you hand-rolled the experience — every line of HTML and JavaScript written by hand, before templates, package ecosystems, transpilers, build systems, and frontend frameworks became good enough to disappear into the background. AJAX is in my DNA.

Before fetch(), before async/await, and long before today's framework-heavy frontend stacks, we built interactive applications by explicitly starting a piece of work and registering what should happen when it finished. I built my earliest user experiences on those patterns, and so did the major web companies of that era. jQuery later smoothed over the rough edges, but the underlying event-driven model was already deeply understood well before it arrived.

So I want to be precise about the claim this post is actually making, because it's easy to hear "AJAX vs async/await" and assume I'm here to declare a winner between two eras of syntax. I'm not. The problem this post is about isn't that async/await is slow — it's that sequential-looking code is seductive, and seductive code hides architecture mistakes.

The actual bug has a name: accidental serialization. It's what happens when four independent pieces of work — four API calls that have nothing to do with each other — get chained into one sequential dependency, purely because the code that requests them happens to read top-to-bottom. Below, you can run the exact same four requests two ways and watch the gap for yourself: roughly 2200ms one way, roughly 700ms the other. Same requests. Same network. Only the architecture changed.

Old-School AJAX

Legacy JavaScript's XMLHttpRequest callback style gets an unfair reputation as primitive. It wasn't wrong — it was explicit. Every request had its own callback, registered independently, fired independently. Nothing about that pattern forced serialization on you; if anything, avoiding accidental serialization was harder to do wrong in callback style than it is with async/await, precisely because there was no syntax that looked sequential while quietly deciding to run four things one after another.

fetch().then().catch().finally() is the direct descendant of that pattern — a nicer API over the same fundamentally independent, callback-shaped model.

That's a fair comparison for four independent calls, but it undersells why callback style earned its bad reputation in the first place. The real pain shows up when the calls aren't independent — when you genuinely need one result before you can ask for the next. Loading a user, then their most recent post, then that post's latest comment, then that comment's author, is a real dependency chain: each step needs data only the previous step provides. Written in callback style, that's four levels of nested callbacks, each with its own error handler, marching the code steadily rightward. This is what people actually mean by "callback hell."

It's tempting to conclude that fetch() and promises solved this on their own. They didn't. .then() doesn't force a flat chain any more than XMLHttpRequest forced nesting — you can write the exact same pyramid with promises by nesting each .then() call inside the previous one instead of returning a promise and chaining onto it. The API changed. The discipline that actually fixes callback hell — return instead of nest — didn't come for free with it.

The .then() chain doesn't just look tidier — it collapses four scattered error handlers into one .catch() at the end, because a rejection anywhere in the chain skips straight to it. async/await flattens this even further into a single try/catch block that reads top-to-bottom — which, notably, is exactly the syntax this whole post is warning you can be too convincing when the steps aren't actually dependent. Here, they are, so sequential syntax is telling the truth.

Promises

Promises didn't change the underlying execution model either. A .then() chain is still just one callback registered to run after another's result — readable, composable, a real improvement over nested callbacks — but you can still serialize independent work by accident inside a .then() chain, the same way you can with plain callbacks. The chain shape doesn't force sequential execution any more than async/await does. What changes is how tempting it becomes to write things sequentially without meaning to, because the syntax reads so cleanly top-to-bottom.

The Attractive Async/Await Rewrite

Here's the part that actually matters: async/await makes asynchronous code read like synchronous code. That's the entire feature. It's also exactly the trap. A function full of await statements reads like a to-do list, one step after another, and there's nothing in the syntax itself that distinguishes "this step genuinely depends on the previous one" from "this step just happens to be typed below the previous one." The compiler doesn't know the difference. Neither does a reviewer skimming a diff. The code looks like a plan. Sometimes it's actually four unrelated plans, standing in line for no reason.

{ "maxMs": 2500, "groups": [ { "label": "Sequential — each request waits for the one before it", "totalMs": 2200, "lanes": [ { "label": "User Profile", "segments": [{ "startMs": 0, "durationMs": 500 }] }, { "label": "Posts", "segments": [{ "startMs": 500, "durationMs": 700 }] }, { "label": "Notifications", "segments": [{ "startMs": 1200, "durationMs": 400 }] }, { "label": "Preferences", "segments": [{ "startMs": 1600, "durationMs": 600 }] } ] }, { "label": "Independent — every request starts at the same time", "totalMs": 700, "lanes": [ { "label": "User Profile", "segments": [{ "startMs": 0, "durationMs": 500 }] }, { "label": "Posts", "segments": [{ "startMs": 0, "durationMs": 700 }] }, { "label": "Notifications", "segments": [{ "startMs": 0, "durationMs": 400 }] }, { "label": "Preferences", "segments": [{ "startMs": 0, "durationMs": 600 }] } ] } ] }

Measure It

Talk is cheap. Below is a small, deterministic simulation of a dashboard loading four independent resources — a user profile, posts, notifications, and preferences — with configurable latency and failure per endpoint. Nothing here makes a real network call; the timings are simulated so the demo is repeatable and doesn't depend on your actual connection. Adjust the sliders if you want, then run it both ways.

Request results

Performance metrics

Click "Run Sequential Await" first, and actually wait for it — watching the playhead crawl across ~2200ms is a more convincing argument than any paragraph I could write. Then click "Run Independent Requests" and watch the same four bars start together and finish by ~700ms. Nothing about the requests changed. Only whether they were told to wait for each other.

One Failure

Go back to the demo above and check "Fail this request" on one endpoint, then run it both ways again. In the sequential version, a failure partway through the chain doesn't just fail that one panel — it can stall or abort everything queued behind it, since each step was written assuming the previous one had already succeeded. In the independent version, one endpoint failing has no effect on the other three; they render whether or not their neighbor made it.

The retry behavior in this demo is deliberately simplified for teaching purposes: a failing endpoint keeps retrying up to your configured retry count, and the final allowed retry always succeeds. Real systems don't get that guarantee — a retry can fail too, exponential backoff and jitter matter, and eventually you need a circuit breaker instead of an infinite promise that "one more try" will fix it. This demo shows the shape of retry-and-recover, not a production-grade implementation of one.

{ "elements": [ { "id": "request", "label": "Request" }, { "id": "fail-check", "label": "Failed?", "shape": "decision" }, { "id": "retry", "label": "Retry" }, { "id": "retry-result", "label": "Retry Succeeded?", "shape": "decision" }, { "id": "success", "label": "Success" }, { "id": "failure", "label": "Failure" } ], "connections": [ { "from": "request", "to": "fail-check" }, { "from": "fail-check", "to": "success", "label": "no" }, { "from": "fail-check", "to": "retry", "label": "yes" }, { "from": "retry", "to": "retry-result" }, { "from": "retry-result", "to": "success", "label": "yes" }, { "from": "retry-result", "to": "failure", "label": "no" } ] }

The real architectural point isn't about retries at all — it's that Promise.all and Promise.allSettled encode two very different failure philosophies for the exact same four requests.

Python Has the Same Trap

This isn't a JavaScript-specific problem. I think about it most concretely through a robotics analogy: a robot polling LIDAR, a camera, an IMU, wheel encoders, and GPS all at once, then converging those independent readings into one decision about what to do next. If that robot's sensor loop awaited each sensor one at a time — LIDAR, then camera, then IMU, then encoders, then GPS — it would be reacting to a world that no longer exists by the time it finished asking about it. Independent sensors need independent polling, with their results converging into shared state only once they're all in. The exact same principle that makes a dashboard feel slow makes a robot dangerous.

Python's async/await has the identical trap, and the identical fix: await one coroutine at a time and you've written a sequential chain regardless of what the syntax looks like; hand independent work to asyncio.gather() and it runs concurrently, the same way Promise.all does in JavaScript.

TypeScript Does Not Change Scheduling

TypeScript is a compile-time layer. It catches a wrong type before you ship it; it says nothing at all about when your code actually runs. A sequential await chain written in TypeScript still runs sequentially — the types don't know or care that four unrelated requests got serialized. Type safety and execution architecture are two entirely separate axes, and it's worth not conflating "well-typed" with "well-scheduled."

Cached-First Architecture

The production-grade version of everything above adds one more idea: don't make the reader wait on the network at all if you already have something useful to show them. A cache-first architecture renders whatever's already known immediately, then lets the network refresh update only the UI regions it actually owns — independently, in the background, without blocking anything the reader can already see.

{ "elements": [ { "id": "request", "label": "Request" }, { "id": "cache-check", "label": "Cache Check", "shape": "decision" }, { "id": "serve-cached", "label": "Serve Cached Response" }, { "id": "background-refresh", "label": "Background Refresh" }, { "id": "network-request", "label": "Network Request" }, { "id": "populate-cache", "label": "Populate Cache" }, { "id": "serve-response", "label": "Serve Response" } ], "connections": [ { "from": "request", "to": "cache-check" }, { "from": "cache-check", "to": "serve-cached", "label": "hit" }, { "from": "serve-cached", "to": "background-refresh" }, { "from": "cache-check", "to": "network-request", "label": "miss" }, { "from": "network-request", "to": "populate-cache" }, { "from": "populate-cache", "to": "serve-response" } ] }

That's the deeper version of this post's thesis: independent UI data should have independent loading, success, failure, and retry lifecycles. Cached data should render immediately. Network refreshes should update only the UI domains they own.

Architecture Matters More Than Syntax

None of this is an argument against async/await. It's the best syntax we've had for asynchronous code, and I use it by default. The argument is narrower and, I think, more useful: syntax that reads sequentially doesn't obligate you to execute sequentially, and it's worth pausing at every await to ask whether the next line actually depends on this one, or whether it just happens to come after it. That one question is the difference between a dashboard that loads in 700ms and one that loads in 2200ms — same requests, same network, same framework. Just a different answer to "does this actually have to wait?"