This is the fourth and last part, furthest from daily work: local-first data and the merge algorithms underneath it, the tooling that keeps a large repository buildable, server-driven UI, microfrontends, the GPU, WebAssembly, and the internals of the browser itself.
Very few people touch all of this in a given year, and several are decisions a team makes once and then lives inside for a long time. Carry away the model and the trade-off each approach buys, rather than hands-on experience with every one.
What unites the list is that each item is machinery sitting underneath something you already use. Local-first is what a collaborative editor looks like when the network is optional. Monorepo orchestration is why changing one package does not rebuild forty. And browser internals quietly explain most of what the first three parts described.
Local-first inverts the usual arrangement. Ordinarily the server holds the truth and the client caches some of it. In a local-first app the device holds a full primary copy and the network is an enhancement. Reads and writes happen locally and immediately, then sync in the background.
Offline parity is the claim that follows: offline is the whole app, not a degraded read-only view. If a feature works on a plane, it works because nothing in the interaction loop was waiting on a server in the first place.
The term comes from an Ink and Switch essay setting out seven ideals — fast because no round trip sits in the interaction loop, work not trapped on one device, network optional, seamless collaboration, data outliving any particular server, privacy by default, ownership with the user.
Most follow from the first architectural decision. The genuinely hard engineering sits in the middle: two devices edited independently have to end up agreeing.
A Conflict-free Replicated Data Type is a structure designed so concurrent edits on different devices merge automatically and deterministically, with no central coordinator. Every replica that has seen the same set of changes ends up in the same state regardless of the order they arrived in.
Two families:
- State-based (CvRDTs) exchange whole states and combine them with a merge function that is commutative, associative and idempotent — so order does not matter and merging twice changes nothing.
- Operation-based (CmRDTs) broadcast individual operations, which must commute so they can be applied in any order. Far lighter on bandwidth, but they require reliable delivery: a lost operation is simply lost.
A grow-only counter is the smallest example that shows why those constraints are what they are:
1// Every replica increments only its own slot, so a merge
2// can compare rather than combine, and nothing is ever lost.
3function merge(a, b) {
4 const merged = { ...a };
5 for (const [replica, count] of Object.entries(b)) {
6 merged[replica] = Math.max(merged[replica] ?? 0, count);
7 }
8 return merged;
9}
10
11const value = (counter) => Object.values(counter).reduce((n, c) => n + c, 0);
12
13merge({ laptop: 3, phone: 1 }, { laptop: 1, phone: 4 });
14// { laptop: 3, phone: 4 } — value 7, in whatever order the merges arrive
Taking the maximum per replica is what makes that merge idempotent and order-independent. The same trick scales up in different shapes:
- OR-Set tags every addition with a unique id, and a removal only retires tags it has actually seen — so a concurrent addition on another device survives instead of being swallowed.
- Sequence CRDTs such as RGA give every character a stable identity, so concurrent insertions at the same position interleave the same way on every replica.
The comparison worth understanding is against Operational Transformation, which Google Docs was historically built on. OT transforms each incoming operation against those already applied, and practical implementations put a server in the middle to order them.
CRDTs need no such authority — which is what makes genuine peer-to-peer collaboration possible — but they pay in metadata: all those unique identifiers, plus tombstones marking deleted items that cannot be discarded while other replicas might still refer to them. Yjs and Automerge are what most teams reach for.
Apply a change locally and immediately, assume it will succeed, propagate and reconcile afterwards. It is the engine underneath local-first sync, and also the everyday optimistic UI pattern.
The model accepts eventual consistency: replicas may disagree for a while and converge once changes propagate. CRDTs are what make that reconciliation safe.
The cost lands on the failure path and is easy to underestimate. You need a rollback that restores the previous state cleanly, a way to surface conflicts a merge cannot silently resolve, and an interface that tolerates a change being undone a second after it appeared. Getting the happy path optimistic is a morning's work; the rest is the actual feature.
A monorepo holds many packages in one repository: shared code without publishing, cross-package changes landing atomically, one source of truth for versions and tooling.
The problem it creates is build performance. Done naively, every change rebuilds everything, and the repository gets slower the more successful it becomes.
Nx, Turborepo and Bazel all attack that with the same three ideas:
- A task graph built from dependency relationships, so tasks run in the correct order and independent ones run in parallel.
- Affected detection — compute which packages a change actually touches, and rebuild only that slice.
- Caching keyed by inputs — if nothing relevant changed, reuse the cached result.
1{
2 "tasks": {
3 "build": {
4 "dependsOn": ["^build"],
5 "inputs": ["src/**", "tsconfig.json"],
6 "outputs": ["dist/**"]
7 },
8 "test": {
9 "dependsOn": ["build"]
10 }
11 }
12}
The caret in ^build means this package's dependencies build first. The inputs list is what the cache key is computed from, and getting it wrong hurts in both directions: too broad and you never get a hit, too narrow and you get a stale one.
Remote caching is the largest single lever. Once the cache is shared across the team and CI, a build somebody else already ran is an instant hit for everybody, and CI stops redoing work.
An Abstract Syntax Tree is the tree representation of source after parsing. Almost the entire modern toolchain is the same three-step cycle: parse code into a tree, manipulate the tree, generate code back out.
- Transpilers — Babel and SWC parse JSX and TypeScript and emit output older targets understand.
- Linters — the same machinery pointed at a different goal. ESLint runs rules across the AST, and an autofix is a rule that rewrites nodes rather than reporting them.
- Codemods — jscodeshift rewriting a deprecated API across thousands of files. Large migrations happen this way rather than by hand.
A Babel plugin is small enough to show whole, which is the fastest way to see there is no magic in it:
1// A plugin is a set of visitors, called once per matching node.
2module.exports = function stripLogs() {
3 return {
4 visitor: {
5 CallExpression(path) {
6 if (path.get('callee').matchesPattern('console.log')) {
7 path.remove();
8 }
9 },
10 },
11 };
12};
Parse to a tree, walk and rewrite, print it back. Once that clicks, most of the toolchain stops looking like a black box.
Occasionally the off-the-shelf configuration is not enough: a bespoke file type, code generated from a schema, a non-standard tool that has to run at the right moment.
The discipline is to do this through the bundler's plugin hooks rather than a separate script running beforehand. Rollup and Vite share a plugin interface:
resolveId decides what an import specifier refers to.
load supplies the contents for a module.
transform rewrites the contents of one that already exists.
Work done through those hooks stays inside the dependency graph, so it participates in caching, watch mode and hot module replacement — none of which a script writing files into your source tree can offer.
1// A generated module, produced on demand rather than checked in.
2const VIRTUAL_ID = 'virtual:routes';
3const RESOLVED_ID = '\0' + VIRTUAL_ID;
4
5export default function routesPlugin() {
6 return {
7 name: 'generated-routes',
8 resolveId(id) {
9 return id === VIRTUAL_ID ? RESOLVED_ID : null;
10 },
11 load(id) {
12 if (id !== RESOLVED_ID) return null;
13 return `export const routes = ${JSON.stringify(scanRoutes())};`;
14 },
15 };
16}
The leading null byte marks the id as belonging to a plugin so others and the filesystem leave it alone. From the application's side, import { routes } from 'virtual:routes' is an ordinary import of a file that does not exist.
SDUI moves the description of the interface from the client to the server. Rather than the client owning layout and the server sending only data, the server sends a structured description — which components, in what arrangement, with what props — and the client renders it from a registry it already ships.
1// What the server returns: a tree of descriptors, not markup.
2const payload = {
3 type: 'Stack',
4 props: { gap: 16 },
5 children: [
6 { type: 'Heading', props: { text: 'Weekend in Lisbon' } },
7 { type: 'ListingCarousel', props: { ids: [12, 84, 91] } },
8 ],
9};
10
11// What the client does with it: look each type up and render.
12const registry = { Stack, Heading, ListingCarousel };
13
14function Node({ node }) {
15 const Component = registry[node.type];
16 if (!Component) return null; // a type this build does not know about
17 return (
18 <Component {...node.props}>
19 {node.children?.map((child, i) => <Node key={i} node={child} />)}
20 </Component>
21 );
22}
Because the server decides the arrangement, the interface changes without a new client build. Experiments, personalisation and seasonal rearrangement ship at server speed rather than app-store speed — which is where most of the value is on mobile. The same definition can drive iOS, Android and web at once, each rendering from its own native registry.
The trade-offs mostly follow from that one null check. A client can only render components it already has, so genuinely new interface still needs a release, and the server must know which client versions understand which types. You take on schema versioning and backward compatibility as an ongoing obligation.
It fits content-driven surfaces that change often — feeds, landing screens, promotions — considerably better than deeply interactive bespoke interfaces, where the indirection buys nothing and costs plenty.
Decompose a large application into pieces developed, owned and deployed independently, then compose them into one experience. The motivation is organisational rather than technical — a way for many teams to ship without coordinating around a single release.
Module Federation is the mechanism most associated with it. It lets a separately built and deployed application expose modules another loads at runtime, and lets both share dependencies so each piece does not bundle its own React.
1// In the remote, which builds and deploys on its own schedule.
2new ModuleFederationPlugin({
3 name: 'checkout',
4 filename: 'remoteEntry.js',
5 exposes: { './Cart': './src/Cart' },
6 shared: { react: { singleton: true, requiredVersion: '^18.2.0' } },
7});
8
9// In the host, once `checkout` is declared as a remote.
10const Cart = React.lazy(() => import('checkout/Cart'));
singleton: true is doing quiet but essential work: two copies of React in one page means two sets of hooks and two context registries, and the failures that follow are baffling until you know to look for them.
Three ways to compose, differing mainly in when:
- Build-time — each piece consumed as an npm package. Simple, but recouples deployments: shipping a change means rebuilding the host.
- Runtime — Module Federation, iframes or web components. Genuine independence.
- Server-side or edge — assemble fragments before the response is sent. Usually best for performance and search engines.
The honest critique: more operational surface, duplicated dependencies inflating the bundle, version conflicts in shared dependencies, and consistency drift as separately owned pieces stop looking like each other. They solve a people-scaling problem rather than a code one, so for a small team they are usually overkill.
Both give a page access to the GPU. The programmable core of both is the shader: a small program run across thousands of cores at once.
- Vertex shader — runs once per vertex, transforming geometry from a model's coordinate space into clip space.
- Fragment shader — runs once per fragment and computes its colour. Lighting, texturing and effects live here.
1// A fragment shader. This code runs once per pixel, in parallel.
2precision mediump float;
3
4varying vec2 vUv; // interpolated across the triangle from the vertices
5uniform float uTime; // the same value for every pixel in this frame
6
7void main() {
8 float pulse = 0.5 + 0.5 * sin(uTime + vUv.x * 6.283);
9 gl_FragColor = vec4(vUv.x, vUv.y, pulse, 1.0);
10}
The mental shift: a shader describes what happens to one vertex or one pixel, and the GPU runs it over all of them simultaneously. There is no loop over pixels because the loop is the hardware. WebGL uses GLSL, WebGPU uses WGSL; the model is the same.
The division of labour is that the CPU prepares data and issues draw calls; the GPU executes shaders across its cores.
Rasterization is the step in between — converting geometry, in practice triangles, into the fragments the fragment shader colours. It is fixed-function rather than programmable, and doing it on dedicated hardware is what makes real-time 3D possible at sixty frames per second. The same word appears in the browser's own pipeline below, doing conceptually the same job for page content.
The modern successor to WebGL. Where WebGL wraps OpenGL ES, WebGPU is modelled on current native APIs — Vulkan, Metal, Direct3D 12 — and has now shipped in the major browsers.
Three things come out of that:
- Lower CPU overhead — work is recorded into command buffers and submitted in batches rather than issued call by call.
- Explicit resource control through pipelines and bind groups set up once and reused: more code up front, less guesswork at runtime.
- Compute shaders — general-purpose GPU computation not tied to drawing. This is what makes in-browser machine learning, physics simulation and heavy data processing realistic.
In practice very little application code targets either API directly. Three.js and Babylon.js cover 3D, PixiJS covers high-performance 2D. Knowing the layer beneath them is still worth it, because the things those libraries ask you to care about — draw call counts, texture sizes, material counts — only make sense once you know what is happening underneath.
A binary instruction format acting as a portable compilation target. Write in C, C++, Rust or Go, compile to WebAssembly, and it runs in the browser inside the same sandbox as JavaScript, close to native speed for the work it suits.
Three things it buys, and the caveat on each:
The web stops being a single-language platform. Mature native libraries can be reused rather than rewritten — video codecs, cryptography, geometry kernels, whole game engines. Figma's rendering core, ffmpeg.wasm and Photoshop in a browser tab are the canonical demonstrations, and none would exist if the only option were a rewrite in JavaScript.
Predictable performance on compute-heavy work. WebAssembly arrives already typed and statically compiled, so the engine is not speculating about object shapes and cannot fall off a de-optimisation cliff. The caveat matters as much as the claim: it is not universally faster than JavaScript. It has no direct DOM access and reaches it through JavaScript, so for DOM-heavy or I/O-bound work there is nothing to win.
A linear memory model — one contiguous, resizable block of bytes, exposed to JavaScript as an ArrayBuffer. Core WebAssembly ships no garbage collector, so lower-level languages manage memory by hand, though a GC extension has since shipped for languages that opt in.
That shared buffer is how data crosses between them:
1const { instance } = await WebAssembly.instantiateStreaming(fetch('/blur.wasm'));
2const { memory, alloc, blur } = instance.exports;
3
4const ptr = alloc(pixels.length);
5// A view onto the module's own memory — the same bytes, not a copy.
6new Uint8Array(memory.buffer, ptr, pixels.length).set(pixels);
7
8blur(ptr, width, height, radius); // one crossing; all the work happens inside
9
10// Growing the memory detaches earlier views, so take a fresh one.
11const out = new Uint8Array(memory.buffer, ptr, pixels.length).slice();
Numbers cross the boundary for free. Everything else — strings, arrays, objects — has to be copied or serialised into linear memory, and that cost is entirely capable of erasing the speed-up if you pay it in a loop. Keep the hot computation wholly inside the module and cross as rarely as you can.
WebAssembly complements JavaScript rather than replacing it: JavaScript orchestrates and owns the DOM, WebAssembly handles the compute-intensive core. Emscripten covers C and C++, wasm-pack and wasm-bindgen cover Rust, and AssemblyScript offers a TypeScript-shaped route in.
Modern browsers are multi-process. Chrome's arrangement is a browser process for interface and coordination, renderer processes for page content at roughly one per site, a GPU process, and a network process.
Stability is the obvious motivation, but the deciding reason is site isolation. Spectre-family attacks can read memory within a process, so the only durable defence is making sure two sites never share an address space. Each site gets its own renderer, so there is nothing of anybody else's in the process to read. The security boundary is why the architecture has the shape it does.
- The main thread parses HTML, calculates style, performs layout, records paint operations, and runs your JavaScript through V8. This is the thread everything competes for — while a long task runs, nothing else on it can happen, including the paint that would show somebody their click registered. That delay is what Interaction to Next Paint captures.
- The compositor and raster threads assemble and rasterize layers. The consequence is that the compositor can handle scrolling and
transform/opacity animations without involving the main thread at all — the deep reason those operations stay smooth while the main thread is busy.
- Parsing — the tokenizer and tree-construction stage build the DOM, while a preload scanner runs ahead to spot resources and start fetching early.
- Style — CSS parsed into the CSSOM, then style recalculation matches selectors to produce each element's computed style.
- Layout — builds the box tree and computes geometry. The expensive, cascading stage.
- Pre-paint and paint — produce paint records (a display list) and decide layerization: which content gets its own compositor layer.
- Raster — turns layers into tiles of actual pixels, usually on raster threads with GPU help.
- Composite — assembles layers into the frame that reaches the screen.
The throughline is what the stages imply about cost:
- Change geometry → layout re-runs, and everything after it.
- Change only a paint property such as colour → layout is skipped.
- Change only
transform or opacity → the compositor handles it alone.
1/* Moving the card by changing geometry: layout runs again,
2 and so does every stage after it. */
3.card { position: relative; }
4.card:hover { top: -4px; }
5
6/* The same movement expressed as a transform: no layout,
7 no paint — the compositor shifts a layer it already has. */
8.card:hover { transform: translateY(-4px); }
V8 compiles rather than merely interprets. Source is parsed into bytecode run by the Ignition interpreter; hot code is handed up through Sparkplug, then Maglev, then TurboFan, each producing faster machine code at greater compilation cost. Those compilers optimise on assumptions about the types flowing through your functions, and when an assumption breaks, deoptimization drops execution back to bytecode.
Two mechanisms explain most of the advice about writing fast JavaScript:
- Hidden classes are the internal shapes V8 assigns to objects. Objects built the same way, with properties added in the same order, share a shape.
- Inline caches build on that by remembering, at each property access site, where the property lived last time — so a site that always sees one shape becomes a direct memory offset rather than a lookup by name.
Construct objects consistently and both work. Add properties in varying orders and the site sees several shapes, the cache degrades, and access falls back to something considerably slower.
Garbage collection is generational, and mostly concurrent and incremental — the Orinoco collector's design goal was precisely to avoid the long stop-the-world pauses that show up as jank.
Part 1 introduced tasks and microtasks. The full picture adds rendering. Each turn:
- Run one task.
- Drain the microtask queue completely.
- If it is time to produce a frame — at most once per display refresh, and only when there is something new to show — run the rendering steps:
requestAnimationFrame callbacks first, before style and layout, then style, layout, paint and composite.
requestIdleCallback picks up the leftovers.
That rAF ordering is exactly why it is the correct place to drive an animation: what you write there is reflected in the frame about to be produced, not the one after it.
JavaScript, rendering and the event loop all share the main thread, and rendering only gets a turn between tasks, after microtasks have drained. That single sentence is the unifying model behind nearly every performance topic in this roadmap: long tasks block frames, microtask loops starve rendering entirely, and work moved to the compositor or a worker escapes the contention altogether.
Four tiers, building on each other more than the split suggests.
Three threads run the whole length:
- The rendering pipeline — DOM and CSSOM to render tree to layout to paint to composite — explains performance from the reflow discussion in Part 2 to the engine internals above.
- The single-threaded event loop, with its ordering of tasks, microtasks and rendering, explains asynchronous behaviour, timers and responsiveness everywhere.
- HTTP caching with content-hashed immutable assets explains both the networking in Part 1 and the deployment in Part 2.
Follow those three across the tiers and what you have is a model of the platform rather than a list of features — which is the difference between recognising a bug and knowing where to look for it.