Sync vs Async
Mental model: Synchronous code owns the thread until it returns. Asynchronous code hands the waiting to somebody else and asks to be called back.
Level: intermediate · about 12 minutes
Start with the failure. This function takes about a second, and while it runs the page cannot repaint, cannot scroll, and cannot respond to a click. In a browser tab the cursor keeps blinking and nothing else happens.
function blockFor(ms) {
const end = Date.now() + ms;
while (Date.now() < end) { /* burning the thread */ }
}
console.log('before');
blockFor(300);
console.log('after 300ms of nothing else happening');
// -> before
// -> after 300ms of nothing else happeningA busy loop. Notice that the two log lines print together, after the wait.
That loop is blocking: it holds the one thread that runs your JavaScript, so no other JavaScript, no rendering and no event handler can run until it returns. Blocking is not about how long a function takes on paper, it is about whether the thread is free while the work happens.
One thread, one line at a time
Your JavaScript runs on a single thread. There is exactly one place where statements execute, so two lines of your code never run at the same instant. Everything you learn in this module exists because of that one sentence.
blocking (one thread, one job)
|==== read file ====|==== parse ====|== render ==|
0ms 900ms 1100ms 1150ms
the tab is frozen for the first 900ms
non-blocking (one thread, three jobs)
|=| |==|
^ ask for the file ^ parse when it arrives
|-- render, clicks, scroll --|
the thread stays free while the platform waits
console.log('1: runs now');
setTimeout(() => console.log('3: runs later'), 0);
console.log('2: also runs now');
// -> 1: runs now
// -> 2: also runs now
// -> 3: runs laterSame script, two kinds of instruction. The order on screen is not the order on the page.
setTimeout does not pause anything. It registers a callback with the platform and returns immediately, which is why line 2 wins. The callback runs once the current script has finished, and not a moment earlier.
const marks = [];
marks.push('a');
setTimeout(() => marks.push('b'), 0);
marks.push('c');
console.log(marks.join(','));The log runs while the script is still going, so the timer callback has not fired yet. marks is ['a', 'c'] at that moment. It becomes ['a', 'c', 'b'] a moment later, but nobody printed it then.
What non-blocking really means
Here is the misconception worth naming early. Asynchronous does not mean "runs in parallel with my code", and it does not make anything faster. It means the waiting happens somewhere that is not your thread, and you get told when it is done.
| You call | Who actually waits | Your thread while waiting |
|---|---|---|
setTimeout(fn, 500) | the platform timer subsystem | free |
fetch(url) | the browser network stack, on its own threads | free |
fs.readFile (Node) | the libuv thread pool | free |
element.animate(...) | the compositor | free |
JSON.parse(hugeString) | you, right now | blocked |
while (Date.now() < end) {} | you, right now | blocked |
crypto.subtle.digest(...) | the platform, off-thread | free |
Blocking shape (imaginary API)
const user = readUserSync(1);
const posts = readPostsSync(user.id);
render(user, posts);
// simple to read
// the tab is frozen the whole time
Non-blocking shape
const user = await readUser(1);
const posts = await readPosts(user.id);
render(user, posts);
// same reading order
// the thread is free at every awaitThe point of the last twenty years of async JavaScript is to keep the readability of the left column while keeping the thread free like the right. await is the payoff, and the rest of this module is how it works.
Cutting long work into pieces
Not everything can be handed off. A million-item loop is your own CPU work, and no promise makes it disappear. What you can do is split it so the thread gets handed back regularly.
const yieldToLoop = () => new Promise((resolve) => setTimeout(resolve, 0));
async function sumInChunks(numbers, chunkSize) {
let total = 0;
for (let i = 0; i < numbers.length; i += chunkSize) {
for (const n of numbers.slice(i, i + chunkSize)) total += n;
await yieldToLoop(); // hand the thread back
}
return total;
}
setTimeout(() => console.log('a click handler could run here'), 0);
sumInChunks([1, 2, 3, 4, 5, 6], 2).then((t) => console.log('total', t));
// -> a click handler could run here
// -> total 21Yielding between chunks. The timer callback gets a turn in the middle of the work.
- Find the long task In DevTools, open Performance and record. Anything over 50ms is flagged as a long task, with a red corner on the block.
- Ask who is waiting If the time is spent inside your own loops, split it or move it to a worker. If it is spent waiting on IO, make the call non-blocking and let the thread go.
- Give feedback before you optimise A spinner after 100ms and a disabled button beat shaving 20% off the work, because the complaint was never about milliseconds.
blocking- the thread cannot do anything else until this returns
non-blocking- the call returns immediately, the result arrives later
concurrency- several jobs in flight, interleaved on one thread
parallelism- several jobs executing at the same instant, on different threads
asynchronous- the result is delivered later, through a callback, promise or event
long task- over 50ms on the main thread, which is where dropped frames come from
Try it yourself
Feel the difference
function blockFor(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
const started = Date.now();
setTimeout(() => console.log('timer waited about', Date.now() - started, 'ms for a 0ms delay'), 0);
blockFor(250);
console.log('sync work done in', Date.now() - started, 'ms');
Increase the block to 1000ms and watch the timer callback get delayed by exactly that much. Then swap blockFor for the chunked version and watch the delay disappear.
Order of arrival
// a stand-in for a network call: no network, just a delay
const fakeRequest = (label, ms) =>
new Promise((resolve) => setTimeout(() => resolve(label + ' arrived'), ms));
console.log('asking for both');
fakeRequest('slow', 60).then(console.log);
fakeRequest('fast', 10).then(console.log);
console.log('asked, thread is free');
Add a second fake request with a shorter delay. Does the code change, or only the timing? Now make one of them throw and see who notices.
Exercises
Time any function, sync or async
Write timed(fn). It calls fn(), waits for the result if the result is a promise, and resolves with { value, ms } where value is the settled result and ms is roughly how long the whole thing took. If fn throws or rejects, timed must reject too.
Hand the thread back before you work
Write deferWork(work). It must return a promise, must not call work during the current turn of the event loop, and must let any already-scheduled timer run first. Resolve with whatever work() returns, and reject if it throws.
Check yourself
- What does this print?
- a c b —
setTimeoutregisters the callback and returns immediately, so the rest of the script runs first. A 0ms delay means "as soon as the thread is free", not "now". - Which statement about
asyncis true? - It changes when other work can interleave, not how long the work takes — There is still one thread. An
asyncfunction body runs on it like any other code, up to eachawait. Whatawaitbuys you is a point where the thread is released so other queued work can run. - A colleague says "we made the report generation async, so the page will not freeze". The report is a 900ms
reduceover an array in the browser. Are they right? - No, the reduce is still your own CPU work on the main thread — Async helps when somebody else is doing the waiting. CPU work in your own code holds the thread whether or not there is an
asynckeyword above it. Split the work into chunks with yields, or move it to a worker. - What is the difference between concurrency and parallelism in a browser?
- Concurrency means interleaving several in-flight jobs on one thread, parallelism means running code at the same instant on different threads — Your JavaScript is concurrent by default: many operations in flight, one thread taking turns. Parallel JavaScript means a Web Worker or a worker thread, with its own isolated instance and message passing.
Common mistakes
- Believing async means parallel, then being surprised that a heavy loop still freezes the page.
- Reading a log written during the script and concluding the timer callback never fired.
- Wrapping CPU work in a promise and calling the problem solved.
- Using a busy-wait loop as a sleep, which blocks everything including the rendering you are waiting for.
- Forgetting that JSON.parse, big sorts and huge template renders are blocking too.
Takeaways
- Synchronous code holds the one thread until it returns. Nothing else runs, including rendering.
- Asynchronous means the waiting happens off your thread and the result is delivered later.
- Async never makes work faster. It makes waiting free and lets other work interleave.
- Your own CPU work is always blocking. Split it into chunks with yields, or move it to a worker.
- Concurrency is interleaving on one thread. Parallelism needs a separate worker.