Layer 0 · the browser

The tab already runs JavaScript. No tools required.

Before React, before Node, before any of it: every browser has a JavaScript engine inside it. Chrome's is called V8. It can run a script and change the page.

On the right is a page, its script, and the document tree the script pokes. Click the button. Watch the tree change. That's the whole trick, and everything else is convenience on top of it.

The document tree is called the DOM: the browser's live, in-memory version of your HTML. Change the DOM, the screen changes.

a page and its DOM
// the script on this page
button.addEventListener("click", () => {
  span.textContent = "clicked " + (++n) + "×";
});
<div>
  <button>Click me</button>
  <span></span>
</div>
The constraint everything bends around

JavaScript does one thing at a time.

One thread. If your code is busy, nothing else on the page can happen. Not clicks, not animations, not typing.

Try it. The spinner is a CSS animation the browser runs on its own. Click Block for 3 s, then immediately try the counter button.

This is real. That's a true busy loop, on the same thread this page uses. You are freezing the tab you're reading.

the thread
0
Everything responds. Now block the thread.
// "Block for 3 s" runs exactly this
const end = Date.now() + 3000;
while (Date.now() < end) {}  // spin
But then

How does fetch not freeze the page?

A network request takes 300 ms. If JavaScript sat there waiting, every page would stutter on every request. It doesn't. Because slow things aren't done by JavaScript at all.

The browser has helpers (timers, network, the file picker) that run outside the thread. JS hands them a job and a function to call when it's done. That function waits in a queue. The event loop moves it onto the stack only when the stack is empty.

Place your bet. Run these three in order: log("A"), setTimeout(→ log("B"), 0), log("C"). What prints?

A C B. Even with a 0 ms delay, B goes to the queue and can only come back when the stack is empty, which is after C. Press run A · B · C to watch exactly that, then press the other buttons in any order.

bet, then play
the event loop, simplified

call stack · JS runs here

browser helpers · outside JS

queue · waits for empty stack

Therefore

Everything slow is asynchronous.
That's why your code is full of await.

A database call, a file read, a fetch, a timer: all of them are "hand the job to a helper, get called back later". async / await is just the polite way to write that hand-off.

Curious how callbacks became promises became await? Waiting without freezing
The pain that creates the next layer

Updating the page by hand is where it gets miserable.

Here's a todo list with no framework. Add an item. Toggle one. Delete one. It works. Now look at the code that makes it work: every change to the data needs a matching, hand-written change to the DOM. Forget one and the screen lies about the data.

At three features it's annoying. At thirty, with the same data shown in four places, it's the reason your app has bugs you can't reproduce.

Watch the counter of "DOM operations you wrote by hand" climb.

raw DOM todo
    0 items · 0 donemanual DOM ops: 0
    // on add: FIVE separate DOM writes, by hand
    const li = document.createElement("li");
    li.textContent = text;                 // 1
    li.append(makeToggle(), makeDelete()); // 2, 3
    list.append(li);                        // 4
    countEl.textContent = todos.length;    // 5
    // ...and "done" count, and empty-state, and...
    Two ways up

    This pain is the reason React exists.

    React's whole pitch: stop writing DOM updates. Describe what the page should look like for the current data, and let React work out the edits.