LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The Browser as a Platform

frontendbrowserjavascriptweb-platformweb-developmentperformance

If you write backends, you already have a rich mental model of your runtime: processes, threads, the heap, the scheduler, system calls. The browser is also a runtime — arguably a more complex one — but backend engineers often treat it as an opaque box that “renders HTML.” This post fills in that box. It is not a tutorial on writing frontend code; it is a tour of the platform your code runs on, so that frontend performance, race conditions, and security behavior stop being surprising. If you understand the rendering pipeline, the event loop, and the security model, the rest of the frontend is detail.


The DOM is a tree, and it is the API

When the browser receives your HTML, it parses it into the Document Object Model (DOM) — a tree of nodes representing the document. Every element is a node; text is a node; the whole page is one big tree rooted at document. The DOM is not the HTML text — it is the live, in-memory object graph that JavaScript manipulates and the browser renders.

Parallel to the DOM, the browser parses CSS into the CSSOM (CSS Object Model), a tree of style rules. The two combine to decide what actually appears.

The crucial backend-relevant insight: the DOM is an API boundary, and crossing it is expensive. JavaScript and the rendering engine are tightly coupled but distinct; every time your JS reads or writes the DOM, it crosses into the rendering engine’s world. Touch it carelessly in a loop and you pay for it — which is the root of most frontend performance problems, as we will see.


The rendering pipeline: pixels from a tree

Turning the DOM + CSSOM into pixels on screen is a pipeline with distinct stages. Knowing the stages is what lets you reason about why some changes are cheap and others janky.

  HTML ──parse──► DOM  ┐
                       ├──► ① Style ──► ② Layout ──► ③ Paint ──► ④ Composite ──► pixels
  CSS  ──parse──► CSSOM┘     (compute    (geometry:    (fill in     (assemble
                             styles for   where & how    pixels for   layers on
                             each node)    big is each    each box)    the GPU)
                                           box?)
  1. Style — compute the final CSS for every node (which rules win, what the resolved values are).
  2. Layout (also called reflow) — compute the geometry: the position and size of every box. This is global-ish: changing one element’s width can shift everything after it.
  3. Paint — fill in the actual pixels for each box (text, colors, borders, shadows) into layers.
  4. Composite — assemble the painted layers, often on the GPU, into the final image. Transforms and opacity can be handled here without re-running earlier stages.

The cost hierarchy is the whole point:

  • Changing a property that affects geometry (width, height, top, margin, font-size) triggers layout → paint → composite — the full, expensive chain.
  • Changing a property that affects appearance only (color, background) skips layout: paint → composite.
  • Changing transform or opacity can often be done at composite alone, on the GPU, skipping layout and paint entirely.

This is why “animate with transform, not top/left” is gospel: transform is a compositor-only change (smooth, GPU, 60fps), while animating top forces a layout on every frame (janky). When a backend engineer asks “why is this animation stuttering,” the answer is almost always “it is triggering layout every frame.”

Reflow and repaint, and why batching matters

Reflow (layout) and repaint are triggered by DOM/style changes. The trap is the forced synchronous layout (a.k.a. layout thrashing): if you write to the DOM and then read a geometric property (like offsetHeight), the browser must run layout immediately to give you a correct answer — even though it would otherwise have batched the work. Do that in a loop and you force dozens of synchronous layouts:

1
2
3
4
5
6
7
8
// BAD: read-write-read-write forces a layout every iteration
for (const el of items) {
  el.style.width = el.offsetWidth + 10 + 'px';  // write then read → forced layout
}

// GOOD: batch all reads, then all writes
const widths = items.map(el => el.offsetWidth);   // all reads (one layout)
items.forEach((el, i) => el.style.width = widths[i] + 10 + 'px'); // all writes

The browser wants to batch layout and do it once before the next frame. Your job is to not defeat that by interleaving reads and writes. This single concept explains a large fraction of real-world frontend jank.


The event loop: one thread, cooperative scheduling

Here is the concept backend engineers most need to internalize: the browser runs your JavaScript on a single thread, and that same thread also does layout, paint, and event handling. There is no preemption. A long-running piece of JS blocks everything — the page cannot respond to clicks, cannot scroll, cannot animate — until it yields. This is completely unlike a multi-threaded server that can handle other requests while one is busy.

The event loop is how a single thread juggles many things cooperatively:

  ┌─────────────────────────────────────────────────────┐
  │  Event loop (one turn = one "task")                  │
  │                                                       │
  │  1. Run one TASK from the macrotask queue            │
  │     (a click handler, a setTimeout callback, ...)     │
  │                                                       │
  │  2. Drain the ENTIRE microtask queue                 │
  │     (Promise .then callbacks, queueMicrotask)        │
  │                                                       │
  │  3. If it's time: render (style→layout→paint→comp.)  │
  │                                                       │
  │  └── back to 1                                        │
  └─────────────────────────────────────────────────────┘

Two queues with different priorities:

  • Macrotasks (the “task” queue): one runs per loop turn — event handlers, setTimeout, message events, I/O callbacks.
  • Microtasks: resolved Promise callbacks and queueMicrotask. The entire microtask queue is drained after each macrotask, before rendering. This is why a Promise.then always runs before the next setTimeout(0), and why an infinite microtask loop can starve rendering completely.

Practical consequences:

  • Rendering only happens between tasks. If your task takes 300ms, the user sees a 300ms freeze. The browser cannot paint mid-task.
  • Long work must be chunked or offloaded. Break it across tasks (yield with setTimeout/scheduler.yield()), or move it off the main thread entirely with a Web Worker (a real separate thread, communicating by message passing — no shared DOM access).
  • This is the mechanism behind the INP responsiveness metric (covered in the performance post): a slow event handler delays the next paint, and the user feels the lag.

Web Platform APIs worth knowing

The browser is not just a renderer; it is a sprawling platform of APIs. The ones a backend engineer should recognize:

API What it does Backend analogue
fetch HTTP requests, promise-based, streaming bodies Your HTTP client
Storage (localStorage, IndexedDB, Cache API) Client-side persistence — key/value and a real async DB A tiny local datastore
Web Workers True background threads (message-passing, no DOM) Worker threads / goroutines
WebSockets Persistent bidirectional connection Your WS server’s other end
Server-Sent Events One-way server→client streaming over HTTP A long-lived SSE stream
Service Workers A programmable proxy between page and network; offline, caching, push A sidecar/proxy you control
History API Manipulate the URL without a full navigation How SPAs do routing

Two of these reshape architecture. Web Workers are the only way to do genuine parallelism in the browser — CPU-heavy work (parsing, crypto, image processing) belongs there so it does not block the main thread. Service Workers sit between your page and the network as a scriptable proxy, enabling offline-capable apps (PWAs), advanced caching, and push notifications; they are the backbone of installable web apps.


The security model: same-origin, CORS, CSP

The browser runs untrusted code from every site you visit, so its security model is strict and worth understanding precisely — most “why is my API call blocked?” confusion lives here.

Same-Origin Policy (SOP)

The foundation. An origin is the triple (scheme, host, port)https://app.example.com:443. By default, script from one origin cannot read responses from a different origin. This is what stops evil.com from reading your bank.com session via your logged-in cookies. SOP isolates origins from each other.

CORS: the controlled exception

The Same-Origin Policy would make legitimate cross-origin APIs impossible, so Cross-Origin Resource Sharing (CORS) is the opt-in mechanism by which a server tells the browser “these other origins are allowed to read my responses.” Critically, CORS is enforced by the browser and granted by the server — it is not a server-side firewall, it is the browser refusing to expose a response to JS unless the server’s Access-Control-Allow-Origin header permits it.

  app.example.com  ──fetch──►  api.other.com
                                    │
                  Browser sends an OPTIONS "preflight" first for
                  non-simple requests, asking: am I allowed?
                                    │
       api.other.com responds:  Access-Control-Allow-Origin: https://app.example.com
                                Access-Control-Allow-Methods: GET, POST
                                    │
                  Browser: header matches my origin → expose the response to JS
                           header missing/mismatched → block, throw CORS error

The thing that trips up backend engineers: a CORS error does not mean the request failed on the server — the server may have happily processed it. It means the browser refused to hand the response to your JavaScript because the server did not authorize the calling origin. The fix is always on the server (send the right Access-Control-* headers), never a frontend hack.

Content Security Policy (CSP)

Where SOP/CORS govern what you can read, CSP governs what your page can load and execute — a server-sent header (Content-Security-Policy) that whitelists allowed sources for scripts, styles, images, connections, etc. Its primary job is to mitigate cross-site scripting (XSS): even if an attacker injects a <script>, a strict CSP that only permits scripts from your own origin (and forbids inline scripts) stops it from running. CSP is one of the highest-leverage defenses on the web platform and is increasingly expected on any serious site.

These three layers — SOP isolates origins, CORS selectively relaxes reads, CSP constrains what loads — are the spine of browser security. Most frontend security questions resolve to “which of these is involved?”


The mental model, assembled

Put it together and the browser is a coherent system:

  • The DOM/CSSOM are live trees; touching the DOM crosses an expensive boundary.
  • The rendering pipeline (style → layout → paint → composite) has a strict cost hierarchy; geometry changes are expensive, transform/opacity are cheap. Batch reads and writes to avoid forced synchronous layout.
  • The event loop runs everything on one thread; long JS freezes the page; microtasks drain before each render; heavy work goes to Web Workers.
  • The Web Platform APIs give you persistence, threads, streaming, and offline.
  • The security model (same-origin, CORS, CSP) is strict by default and relaxed deliberately; CORS errors are the server’s job to fix.

With this model, frontend performance work (the next post) and frontend bugs both become legible. You are not poking at a black box — you are reasoning about a runtime, the same way you reason about your server.


Sources:

Comments