EverProduct
Frontend

Stage 03 · The Language

One Lane: How JavaScript Waits Without Stopping

You asked the server for data and printed the result: undefined. Nothing was broken — you read the answer before it arrived, and that one misunderstanding is the whole subject.

Everyone writes this bug once. You request some data, store it in a variable, print it on the next line — and get undefined. Then, a moment later, the data quietly arrives, and there's nowhere left to put it.

Nothing failed. The request takes 200 milliseconds and your next line ran 0.01 milliseconds later. You read the answer before it existed.

The reason JavaScript works this way is the fact from the first article: the page has one thread, and it also runs your clicks, your scrolling, your animations and your layout. If waiting for a server meant blocking that thread, every request would freeze the entire page. So the language never blocks. It schedules.

Nothing waits in JavaScript. Things get scheduled — and everything confusing about async follows from that one sentence.

The event loop, in one paragraph you can keep

Your code runs on a stack, one thing at a time, to completion. Anything slow — a network request, a timer, a file read — is handed to the browser, which does it elsewhere. When it finishes, the result isn't run immediately: a callback is placed in a queue. Only when your current code has run to the end does the loop take the next item off the queue and run it.

Three consequences fall out, and they explain most of the puzzles:

A timer is a minimum, not a promise. setTimeout(fn, 0) does not run now; it runs after everything currently on the stack. If some function is busy for 300 milliseconds, your "immediate" callback waits 300 milliseconds.

A long function freezes everything. Not just your logic — clicks, scrolling and animation are all in the same lane. This is why heavy work has to be broken up or moved to a worker.

Order is not source order. Async results arrive when they arrive, so reasoning about "what happens after what" is the actual skill this article is teaching.

From callbacks to await

The history is short and worth knowing, because you'll meet all three styles in real code.

Callbacks came first: pass a function to be called when the work is done. Fine for one operation, unbearable for five in sequence — each nested inside the last, with error handling duplicated at every level. The industry called it callback hell and meant it.

Promises made the pending result into an object you can hold. A promise is in one of three states — pending, fulfilled, rejected — and it settles exactly once, which is what makes it composable: you can pass it around, chain it, and combine several.

async/await is syntax on top of promises, and it's what you'll write. await pauses the function until the promise settles — without blocking the thread, because the rest of the page keeps running while this function is suspended. The result is asynchronous code that reads top to bottom like ordinary code.

Two facts prevent most confusion: await only works inside an async function, and an async function always returns a promise, whatever you return from it. Which means calling one without awaiting hands you a promise, not a value — the other half of the undefined bug from the opening.

Errors, and the trap in fetch

With await, errors are handled with an ordinary try/catch — which is most of why it won. A promise chain uses .catch() for the same thing.

But there's one behaviour that catches everybody, and it's worth putting in bold: fetch does not reject on a failed HTTP status. A 404 or a 500 is, as far as fetch is concerned, a successful round trip — the server answered, and the answer was "no". The promise only rejects when the request itself failed: no network, DNS failure, request aborted.

So every fetch needs an explicit check of response.ok before you touch the body. Without it, your error path never runs, and you cheerfully try to read a product list out of an error page. This single omission accounts for an enormous share of "it works until it doesn't" in production.

Then handle the rest honestly. An unhandled rejection is a silent failure with no user-visible message — the worst category, since nobody reports it and nothing shows up until someone asks why the list is empty.

Sequential versus parallel

Here's a mistake that costs real seconds. Awaiting inside a loop makes ten requests run one after another: if each takes 200 milliseconds, the user waits two seconds for work that could have taken 200 milliseconds.

The tools for doing better:

  • Promise.all — start all of them, wait for all. Fails as a whole if any one fails.
  • Promise.allSettled — waits for all and tells you each outcome. Right when partial success is acceptable: five widgets on a dashboard, one of which is down.
  • Promise.race — the first to settle wins. The classic use is a timeout.
  • Promise.any — the first to succeed.

The rule is easy to apply: if two requests don't depend on each other, they should start at the same time. If the second needs the first one's result, sequential is correct and there's nothing to fix.

Cancellation and stale answers

Two problems appear the moment a real user is involved, and neither is obvious.

Race conditions on fast input. Someone types "chair" in a search box, so you fire a request per keystroke. The response for "cha" comes back after the response for "chair" — servers make no promises about order — and it overwrites the correct results with older ones. The screen now shows an answer to a question the user has already finished asking.

Work continuing after nobody needs it. They navigate away and three requests are still in flight, still setting state on a screen that's gone.

Both have the same fix: AbortController. Create one per request, pass its signal to fetch, and abort the previous one when a new one starts or when the component goes away. One mechanism, both bugs — and it's worth learning early rather than after a week of chasing ghosts.

The four states of every async screen

Any piece of interface fed by a request has four states, and the difference between an amateur and a professional implementation is almost entirely in the last three.

Loading — and it needs to be honest: a skeleton in the shape of the content beats a spinner, and nothing at all is the worst option, since a blank area is indistinguishable from an empty result.

Error — say what happened and offer a way out. "Something went wrong" with no retry is a dead end.

Empty — the request succeeded and there's nothing there. This state is skipped in almost every first implementation, and users read a blank screen as a broken page.

Success — the one everyone builds.

Write all four every time. This is the "design all five states" rule from the UX sphere, arriving in code form, and it's the single clearest marker of interface work done properly.

Network reality

Your machine is not the world. Development happens on a fast connection to a server on the same laptop, where every request takes four milliseconds and never fails.

Real users are on a train. So: throttle the network in devtools to slow 3G and use your own interface — a large fraction of async bugs only become visible when a request takes two seconds. Set timeouts, so a hung request doesn't produce a permanent spinner. Retry with increasing delays for transient failures, and don't retry things that shouldn't be repeated. And decide what your interface does offline, even if the answer is just an honest message.

Building the model, not memorising the rules

Async is where mental models matter more than syntax, and where reading is at its most deceptive: an explanation of the event loop is perfectly clear while you're reading it and produces nothing you can use.

The exercise that actually works is prediction. Write a small file with a few console.log calls scattered among a setTimeout, a resolved promise and an await, and — before running it — write down the order the lines will print. Then run it. Every mismatch is a precise map of where your model is wrong, and it takes two minutes. This is retrieval and immediate feedback in one, and it beats any amount of re-reading the diagram.

Then build the chunk once, properly: a single function that fetches with a loading state, a status check, error handling and abortion. Get it right one time, keep it, and rebuild it from memory next week. After that, "load data into a screen" is one move rather than twelve decisions — which is exactly the compression the How to Learn sphere describes, and it's what makes an experienced developer's version of this take four minutes.

In practice

Predict the log order before running. Once a week while you're learning.

Check response.ok on every fetch, without exception, until you no longer have to think about it.

Look for await inside loops in your own code and ask whether those requests actually depend on each other.

Write the loading, error and empty states first, before the success path. They're the three you'd otherwise never write.

Develop with the network throttled one day a week. It changes what you notice.

Add an AbortController to any request tied to typing or to a screen that can be left.

Check yourself

Close the article and answer in your own words:

  1. Why does JavaScript schedule instead of waiting, and what does the single thread have to do with it?
  2. What actually happens when you call setTimeout with a delay of 0?
  3. What are the three states of a promise, and what does an async function always return?
  4. Why doesn't fetch reject on a 500, and what must you write because of that?
  5. When is await inside a loop a bug, and what replaces it?
  6. Describe the stale-response race condition and its fix.
  7. Name the four states of an async screen and what goes wrong when each is missing.

In short

  • The page has one thread, so nothing blocks: slow work is handed off and its result is queued until your current code finishes.
  • A timer is a minimum delay, a long function freezes everything, and execution order isn't source order.
  • Callbacks became promises became async/await; await suspends the function without blocking the page, and an async function always returns a promise.
  • fetch only rejects on network-level failure — check response.ok yourself or your error path never runs.
  • Independent requests should start together via Promise.all; allSettled, race and any cover partial success, timeouts and first-success.
  • AbortController fixes both stale out-of-order responses and work continuing after a screen is gone.
  • Every async screen has four states — loading, error, empty, success — and the last three are what separate real work from a demo.
  • Build the model by predicting log order, and build a reusable fetch-with-states chunk once so loading data becomes a single move.