Asynchronous Thinking
Why async exists, what "callback" means, and how to think about code that doesn't happen in a straight line.
Most of the code you've written so far executes top-to-bottom, one line after another. Asynchronous code breaks that rule — some work is started now but finishes later. To use it effectively, you need a different mental model.
This chapter builds the intuition first, without the syntax. The
next chapter introduces Promises and async/await.
Why async exists at all
Many real things take time:
- Reading a file from disk.
- Asking another computer for data (an HTTP request).
- Waiting for the user to click a button.
- Waiting for a timer to fire.
A synchronous version of "fetch some data" would mean: stop the entire program until the data arrives. With a single-threaded language like JavaScript, that's catastrophic — the UI freezes, no other code runs, and your one CPU sits idle waiting.
The async philosophy is:
Start the slow work, don't wait, do something else, and arrange to be notified when it's done.
A program that adopts this style stays responsive. It can overlap many slow operations without blocking, even on a single thread.
A non-blocking everyday metaphor
Imagine ordering at a café:
- Synchronous (bad): you order, then stand at the counter staring, refusing to move until your drink is ready. No one else can be served.
- Asynchronous (good): you order, get a buzzer, sit down, and do something else. The buzzer fires when your drink is ready.
JavaScript's runtime is the café. Your code is the customer. The buzzer is a callback or a promise.
Callbacks: "tell me when you're done"
A callback is a function you give to another function so it can be called back later. Many older async APIs use this pattern.
Read the output carefully: 1, 2, 3. The arrow function we
gave setTimeout is a callback. We didn't call it ourselves
— we gave it to setTimeout to call later.
This is the heart of async thinking:
You're not writing code that "fetches data". You're writing code that says "when the data arrives, do this."
A simulated fetch
Real fetching needs the network. We can simulate the shape with a timer.
A few important conventions in the old-style callback world:
- The callback is the last parameter.
- The first argument is the error (or
nullif there wasn't one). - The remaining arguments are the result.
This is called the Node-style or error-first callback pattern.
The pain: callback hell
Callbacks scale poorly. When one async result feeds the next, you end up with deeply nested code.
This "rightward drift" is famously called callback hell or the pyramid of doom. Worse, error handling has to be repeated at every level. Promises (next chapter) were invented specifically to flatten this.
Concurrency without parallelism
A subtle but important word distinction:
- Concurrent: many tasks are in progress during overlapping periods of time. They don't have to run simultaneously.
- Parallel: many tasks literally run at the same time on separate CPU cores.
JavaScript gives you concurrency on a single thread. While one async task is "in flight" (waiting for the host to do something), your code can start another one. They make progress together, but only one bit of JS is executing at any given moment.
The JS thread is only ever busy in the short bursts; the long waits happen elsewhere.
Order is not what it looks like
This is the biggest mental shift. The order code is written in isn't always the order it runs in.
Output is A C E B D. The setTimeout callbacks wait until the
synchronous code finishes. Reading this requires you to mentally
split the code into:
- the synchronous "now" part: A, C, E
- the asynchronous "later" parts: B, D
If your prediction was A B C D E, you were reading the code as
a story. Async code isn't a story — it's a schedule.
A "do N things and then continue" pattern
A common need: start several async tasks, do something once they're all done. With callbacks alone, this requires manual counting.
Counting-down patterns like remaining-- are easy to get wrong.
That's another problem Promises solve: Promise.all does this
counting for you.
Mental model checklist
Before you write any async code, ask:
- What slow thing am I waiting for? (timer, network, file, user input, another promise…)
- What should run when it finishes successfully?
- What should run if it fails?
- Does anything else depend on it being done?
If you can answer those four questions, you can almost always sketch the async code, regardless of the syntax in front of you.
Multi-file: a tiny event bus
Async thinking goes beyond fetching. Many systems use an event bus: parts of the program register interest ("call me when X happens"), and another part publishes events. This is callback-driven by design.
There's no Promise here — just callbacks. This is the same
pattern the DOM uses (element.addEventListener), Node uses
(EventEmitter), and many libraries use internally.
Challenge
Write a function runSequentially(tasks, done) where:
tasksis an array of functions. Each function takes a single argument: a callbackcb(err, result).- Your function should run them ONE AT A TIME (not in parallel), passing each result into an accumulator array.
- When the last task finishes, call
done(null, results)whereresultsis an array of every result, in order. - If any task calls its callback with an error, call
done(err)immediately and run no more tasks.
This is a small but realistic exercise in "callback orchestration".
Why does JavaScript provide asynchronous APIs even though it runs on a single thread?
Because it makes code faster on multi-core CPUs
Because the language standard requires it
Because it lets the single thread stay busy on other work while waiting for slow operations, instead of blocking
Because asynchronous code is always easier to read