04 — JavaScript — The Behavior Layer That Makes a Page React
"The thing that makes buttons clickable" was my JavaScript summary, and it undersold the whole layer. The model that stuck: *JavaScript is the behavior layer. Where HTML is meaning and CSS is looks, JS is what the page does.* [1]
In the last two posts I had the static half of the picture. HTML declares what each piece of content is; CSS decides how it looks. The result is a page you can read but not affect — it sits there, fixed, the same for everyone. JavaScript is what turns that into something that reacts: a button that counts, a form that validates, a list that loads more as you scroll. It runs in the same browser that built the DOM from the HTML, and its core job is to read and change that tree in response to events.
The part that straightened everything else out is the shape of how it runs. JS is single-threaded — one instruction at a time, on one call stack — and it clears that stack on an event loop [2]. That single fact is why JS feels different from most languages I'd met: it's a language of values, scopes, and async, organized entirely around not blocking that one thread.
Values and types — the stuff JS works in
JavaScript has a small set of primitive types — string, number, boolean, null, undefined, symbol, bigint — and one structural type: object [1]. The primitives are passed by value; objects (including arrays and functions) are passed by reference. I used to trip on this constantly — mutating an object inside a function and being surprised the caller saw the change. It stops being surprising once you accept that a variable holding an object holds a reference to it, not the thing itself.
Two quirks worth carrying in your head, because they eat debugging time:
- Dynamic typing. A variable can hold a string now and a number next line. There's no compile-time type gate; typeof x is the runtime check. TypeScript adds that gate as a separate layer, but plain JS trusts you to know what's in the box.
- Type coercion. == compares after converting types (0 == '' is true); === compares without. I default to === everywhere and let == alone, because its rules are a noise source, not a feature.
The DOM bridge — where JS meets the page
The browser already parsed the HTML into a tree of nodes — the DOM, the same tree I leaned on for semantics and accessibility in the HTML post. JavaScript's bridge to the page is that it can read and mutate that tree [3]. document.querySelector finds a node; node.textContent changes its text; node.classList.add toggles a class; document.createElement makes a new node and parent.appendChild wires it in.
The thing I had to internalize: mutating the DOM is not the same as re-rendering. The browser keeps the live tree; I'm editing nodes in place. Frameworks like React hide this behind a virtual tree and a diff — but underneath, the same DOM is being updated node by node. The DOM is the shared substrate that all frontend code, framework or vanilla, ultimately touches.
Events — how the page learns something happened
A static page doesn't need to know anything. A reactive page does — that a button was clicked, a key was pressed, a form submitted, data arrived. The browser is the one that knows; JS registers handlers and the browser calls them [4]:
button.addEventListener('click', () => {
counter.textContent = String(count++);
});The model here is important: I don't write code that polls for clicks. I hand the browser a function and say "call this when X happens." The browser maintains an internal queue of events; when the stack is empty, the event loop pulls the next event off the queue and runs its handler. That's the whole interaction model — register, then wait. It's also the entry point for everything async.
Async — callbacks, promises, and async/await
Here's where the single thread bites. If my handler does something slow — fetches from a server, reads a large file — the whole page freezes until it returns, because nothing else can run on that one thread. So JS offloads waiting to the environment (the browser's network stack, timers) and gives me ways to say "do this, and when it's done, run this."
That evolved in three layers, and the layers are still all there:
- Callbacks — pass a function to be called later. Works, but nesting them produces the "pyramid of doom," and error handling is ad hoc.
- Promises — an object representing a value that will arrive later, with .then() chaining and a single .catch() for the whole chain [5]. Composable in a way callbacks aren't.
- async/await — syntactic sugar over promises that lets me write async code that reads top to bottom, with try/catch for errors [5]. Underneath it's still promises; the sugar just flattens it.
// same operation, two surfaces
fetch('/api/likes').then(r => r.json()).then(data => render(data));
async function loadLikes() {
const r = await fetch('/api/likes');
const data = await r.json();
render(data);
}The second reads like synchronous code, but every await is a yield point — control returns to the event loop, other handlers run, and when the awaited promise settles, this function resumes. That's the trick that keeps a slow network request from locking the page.
The event loop — the one picture that made it click
The payoff of all the above is one diagram. The event loop is the scheduler that ties values, the DOM, events, and async together — and seeing it as a picture is what made async stop feeling like magic:
The loop's rule is dead simple: if the call stack is empty, take the next callback from the queue and run it. [2] That's why setTimeout(fn, 0) doesn't run fn immediately — it queues it, and the loop only picks it up once the current stack finishes. Once this picture is in your head, a lot of "why did this run in the wrong order?" bugs just dissolve: the answer is always "because the stack wasn't empty yet."
How I use this
The habit these notes left me with is one question whenever a UI feels janky or a callback fires late: what's on the stack, and what's in the queue? A slow for loop is on the stack and blocks everything; a fetch is off the stack and in the web-API lane, freeing the loop to keep the page responsive. Reaching for the async, non-blocking version first — fetch over a giant synchronous parse, await over a manual callback chain — is the practical shape of "don't block the single thread."
That's also why the behavior layer felt arbitrary before I had the model. I was treating JS as "HTML plus some snippets," and async in particular read like a pile of special cases. Seen as one thread clearing a stack on an event loop, with promises and await as the syntactic shape of yielding to that loop — it's one idea, consistently applied.
References
[1] I. Kantor and the javascript.info team, "The Modern JavaScript Tutorial," javascript.info, 2024. [Online]. Available: https://javascript.info/
[2] Mozilla, "Concurrency model and the event loop," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
[3] Mozilla, "Introduction to the DOM," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction
[4] Mozilla, "Introduction to events," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events
[5] Mozilla, "Using promises," MDN Web Docs, 2024. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises
[6] W. Bos, "JavaScript30 — Build 30 things in 30 days with vanilla JS," javascript30.com, 2024. [Online]. Available: https://javascript30.com/
Knowledge check · Question 1 of 5
In the three-layer way of thinking, what is JavaScript's job on a web page?
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!