koala73/worldmonitor / Chapter 2

Programming /

E01_Why_Vanilla_Won

# Why Vanilla TypeScript Beat React at Map Scale > Vanilla TypeScript is not asceticism. For a 56-layer interactive map with 104 panel subclasses, no virtual DOM is the only architecture that survives the bundle, the WebView, and the discriminated-union workload. I want to start with a snippet. This is the shape of the `Panel` base class from `src/components/Panel.ts` (paraphrased — the real implementation goes deeper into lifecycle hooks and DOM sanitization helpers): ```typescript class Panel { content: HTMLElement; // stable outer container, never replaced render(): void { /* subclass */ } // subclass fills in setContent(html: string): void { clearTimeout(this._t); this._t = setTimeout(() => { this.replaceContentSafely(html); // sanitized + 150ms debounce }, 150); } destroy(): void { /* subclass */ } } ``` That is the foundation under 104 panel subclasses — earthquakes, AIS vessels, ACLED protests, USNI fleet, market signals, country instability scores, hotspot escalation, prediction markets, infrastructure exposure. Every single one of them extends `Panel`, calls `setContent(html)` whenever state changes, and relies on event delegation from a stable outer container. There is no React. There is no Vue. There is no Svelte. There is no Solid, no Preact used as a hidden runtime, no adapter. The architecture document states this as a deliberate, defended choice — and after tracing the codebase for a week, I now think the framework rejection is not aesthetic minimalism. It is load-bearing engineering. I came to this analysis believing that React's component model — declarative state, virtual DOM diffing, hooks, the entire mental model — would be essential at this scale. The reasoning seemed obvious: a map with 56 layer types, hundreds of markers per layer, real-time updates every 10 seconds, and a panel grid that re-renders constantly. Surely you need a framework. Surely hand-rolled DOM management would collapse under the weight. The evidence from `src/components/` and the architecture document pushed me hard in the other direction. Vanilla TypeScript is the right default for a system where the bundle weight is competing with intelligence data, the render model is wholesale subtree replacement rather than fine-grained reactive updates, and the runtime target includes WebKitGTK on Linux Tauri builds that have idiosyncratic behavior around drag-and-drop and autoplay. Let me show you what the architecture is actually doing. ```mermaid flowchart LR A[Panel subclass] -->|setContent html| B[debounced 150ms] B --> C[sanitized content replace] C --> D[event delegation<br/>on this.content] D -->|closest selector| E[handler] F[AppContext] -->|centralized mutable state| A A -->|URL state| G[urlState.ts<br/>250ms debounce] H[SmartPollLoop] -->|visible? in-viewport? tab-hidden?| A I[hydrate from /api/bootstrap] -->|getHydratedData| A ``` The diagram looks mundane until you understand what each piece is doing *instead of* what a framework would do. `setContent(html)` is not a render function — it is a 150-millisecond debounced sanitized content replacement. The panel is not diffing against a previous virtual tree. It is replacing the entire subtree. Event delegation works because the *outer* container survives the replacement; only its children change. Handlers attach to `this.content` (stable) and use `event.target.closest('.selector')` to find the intended element. The cost of "diffing" is zero, because the diff is: replace the entire subtree, let the browser rebuild layout. The cost of the framework — VDOM diffing, hook dependency tracking, memoization invalidation, re-render scheduling — is also zero, because none of it exists. This pattern is enforced project-wide. Every panel does this. E2E tests have to re-query the DOM after each render cycle, because element references go stale when the subtree is replaced — and the architecture document calls this out explicitly. The constraint is documented as a feature, not a bug. Now let me show you why this matters at scale. Imagine you have a React app. You render 56 layer toggles, each with its own subscription to a WebSocket, each with its own panel state. Every state change triggers a re-render of the parent, which re-renders the children, which diff against the previous tree. For a small app this is invisible. For a dashboard with 500+ news items, 4,000 vessels in the AIS stream, and 31 country instability scores updating every five minutes, the framework tax becomes visible. React's runtime alone weighs more than the *entire* WorldMonitor application shell. The architecture document makes the claim with characteristic bluntness: "the entire application shell (panel system, routing, state management) compiles to less JavaScript than React's runtime alone". The reason this matters is not bundle size puritanism. It is that the bundle is competing with the actual intelligence. The page loads dozens of data layers, two map renderers, ML models, and live video streams. Every kilobyte of framework overhead is a kilobyte the user does not get for a map ti

Chapter 2 of 5 9m Article Audio Video Learning path

Why Vanilla TypeScript Beat React at Map Scale

Vanilla TypeScript is not asceticism. For a 56-layer interactive map with 104 panel subclasses, no virtual DOM is the only architecture that survives the bundle, the WebView, and the discriminated-union workload.

I want to start with a snippet. This is the shape of the Panel base class from src/components/Panel.ts (paraphrased — the real implementation goes deeper into lifecycle hooks and DOM sanitization helpers):

class Panel {
  content: HTMLElement;             // stable outer container, never replaced
  render(): void { /* subclass */ } // subclass fills in
  setContent(html: string): void {
    clearTimeout(this._t);
    this._t = setTimeout(() => {
      this.replaceContentSafely(html);  // sanitized + 150ms debounce
    }, 150);
  }
  destroy(): void { /* subclass */ }
}

That is the foundation under 104 panel subclasses — earthquakes, AIS vessels, ACLED protests, USNI fleet, market signals, country instability scores, hotspot escalation, prediction markets, infrastructure exposure. Every single one of them extends Panel, calls setContent(html) whenever state changes, and relies on event delegation from a stable outer container. There is no React. There is no Vue. There is no Svelte. There is no Solid, no Preact used as a hidden runtime, no adapter. The architecture document states this as a deliberate, defended choice — and after tracing the codebase for a week, I now think the framework rejection is not aesthetic minimalism. It is load-bearing engineering.

I came to this analysis believing that React's component model — declarative state, virtual DOM diffing, hooks, the entire mental model — would be essential at this scale. The reasoning seemed obvious: a map with 56 layer types, hundreds of markers per layer, real-time updates every 10 seconds, and a panel grid that re-renders constantly. Surely you need a framework. Surely hand-rolled DOM management would collapse under the weight. The evidence from src/components/ and the architecture document pushed me hard in the other direction. Vanilla TypeScript is the right default for a system where the bundle weight is competing with intelligence data, the render model is wholesale subtree replacement rather than fine-grained reactive updates, and the runtime target includes WebKitGTK on Linux Tauri builds that have idiosyncratic behavior around drag-and-drop and autoplay.

Let me show you what the architecture is actually doing.

flowchart LR
  A[Panel subclass] -->|setContent html| B[debounced 150ms]
  B --> C[sanitized content replace]
  C --> D[event delegation<br/>on this.content]
  D -->|closest selector| E[handler]
  F[AppContext] -->|centralized mutable state| A
  A -->|URL state| G[urlState.ts<br/>250ms debounce]
  H[SmartPollLoop] -->|visible? in-viewport? tab-hidden?| A
  I[hydrate from /api/bootstrap] -->|getHydratedData| A

The diagram looks mundane until you understand what each piece is doing *instead of* what a framework would do. setContent(html) is not a render function — it is a 150-millisecond debounced sanitized content replacement. The panel is not diffing against a previous virtual tree. It is replacing the entire subtree. Event delegation works because the *outer* container survives the replacement; only its children change. Handlers attach to this.content (stable) and use event.target.closest('.selector') to find the intended element. The cost of "diffing" is zero, because the diff is: replace the entire subtree, let the browser rebuild layout. The cost of the framework — VDOM diffing, hook dependency tracking, memoization invalidation, re-render scheduling — is also zero, because none of it exists.

This pattern is enforced project-wide. Every panel does this. E2E tests have to re-query the DOM after each render cycle, because element references go stale when the subtree is replaced — and the architecture document calls this out explicitly. The constraint is documented as a feature, not a bug.

Now let me show you why this matters at scale.

Imagine you have a React app. You render 56 layer toggles, each with its own subscription to a WebSocket, each with its own panel state. Every state change triggers a re-render of the parent, which re-renders the children, which diff against the previous tree. For a small app this is invisible. For a dashboard with 500+ news items, 4,000 vessels in the AIS stream, and 31 country instability scores updating every five minutes, the framework tax becomes visible. React's runtime alone weighs more than the *entire* WorldMonitor application shell. The architecture document makes the claim with characteristic bluntness: "the entire application shell (panel system, routing, state management) compiles to less JavaScript than React's runtime alone".

The reason this matters is not bundle size puritanism. It is that the bundle is competing with the actual intelligence. The page loads dozens of data layers, two map renderers, ML models, and live video streams. Every kilobyte of framework overhead is a kilobyte the user does not get for a map tile, an AIS position update, or an embeddings model. WorldMonitor ships no virtual DOM, no adapter library, no framework version upgrades. The codebase depends on browser standards — DOM, Web Workers, IndexedDB, Intersection Observer, ResizeObserver — that are stable across engine updates.

The second reason vanilla TypeScript wins here is that the _kind discriminated-union pattern — the one I keep coming back to — depends on it. Look at how markers are represented:

type MapMarker =
  | { _kind: 'conflict'; lat: number; lon: number; severity: string; ... }
  | { _kind: 'flight'; lat: number; lon: number; callsign: string; ... }
  | { _kind: 'vessel'; lat: number; lon: number; mmsi: number; ... }
  | { _kind: 'protest'; lat: number; lon: number; crowd_size: number; ... }
  // ... 15+ additional marker kinds

There is no class hierarchy. Each marker is a plain TypeScript object with a literal _kind field. This means:

1. Markers can be serialized to JSON for IndexedDB persistence without custom serialization logic. 2. Markers can be passed through Web Workers via postMessage without structured-clone gymnastics. 3. The renderer's switch (marker._kind) is exhaustively type-checked at compile time. Add a new marker kind and the compiler errors at every unhandled site.

A class-based design — class ConflictMarker extends Marker, class FlightMarker extends Marker, instanceof checks throughout — would force the markers through a serialization boundary every time they crossed into a Worker or out to IndexedDB. It would force the renderer to maintain a parallel type-tag system. The discriminated-union approach uses TypeScript's type system *as* the runtime dispatch — and that only works if the rest of the architecture (the setContent(html) panel system, the direct DOM access, the addEventListener calls) trusts the type system to be the source of truth instead of an implementation detail.

The third reason is WebView compatibility. The Tauri desktop app runs in WKWebView on macOS and WebKitGTK on Linux. Both have idiosyncratic behavior around drag-and-drop, clipboard, autoplay, and memory management. Working around these quirks means dropping into the DOM directly, manipulating window and document in ways that framework abstractions make awkward. Direct DOM manipulation makes it possible to patch the runtime without fighting framework abstractions. The architecture document calls this out specifically, and having debugged enough WebKit issues to know what they are talking about, I believe them.

The fourth reason — and this is the one I underestimated before I read the codebase — is that the reactivity model is *not* what frameworks optimize for. React's hook system is built around fine-grained reactive updates: a single state change, a single re-render, only the affected children diff. WorldMonitor's update model is the opposite: a panel's data refreshes, setContent(html) is called, the entire panel body is replaced. There is no fine-grained reactivity. The replacement is the granularity. A virtual DOM diff would fight this pattern, adding overhead without benefit. The architecture document puts it bluntly: "the dashboard doesn't have the fine-grained reactive state updates that frameworks optimize for."

What fills the framework gap?

  • Component model: Panel base class with lifecycle methods, debounced content updates, event delegation.
  • State management: localStorage for user preferences, CustomEvent dispatch for inter-panel communication (wm:breaking-news, wm:deduct-context, theme-changed, ai-flow-changed), and a centralized AppContext for intelligence state.
  • Routing: URL query parameters parsed at startup, history.pushState for shareable deep links.
  • Reactivity: SmartPollLoop and RefreshScheduler classes with named refresh runners, visibility-aware scheduling, and in-flight deduplication.
  • Virtual scrolling: Custom VirtualList with DOM element pooling and requestAnimationFrame-batched scroll handling.

Each of these is ~50–200 lines of vanilla TypeScript. None of them require a runtime. Each of them is replaceable in isolation if the requirements change. Each of them is testable without a render-driver.

I want to be honest about what this architecture costs. There are real trade-offs.

The first cost is the subtree-replacement model. Every content update is a wholesale DOM rebuild for that panel. For panels with hundreds of items — the news panel, the AIS vessel list — this is a real performance concern, which is why the codebase includes a custom VirtualList class with DOM element pooling and 3-item overscan. The Web Worker that does clustering and correlation runs in a separate thread so the main thread is not blocked, but the panel that displays the result still does a subtree replacement.

The second cost is the team ramp. New contributors have to learn the panel lifecycle, the event delegation pattern, the _kind discriminator, the URL state machine. None of those are conceptually difficult, but they are project-specific. A React developer joining the team does not get to lean on prior knowledge. This is a real hiring and onboarding cost, and it is the most honest argument against the architecture.

The third cost is the testability of rendering. E2E tests must re-query the DOM after each render. Snapshot testing of rendered HTML is brittle because setContent is asynchronous (debounced 150ms). Visual regression is done with Playwright golden screenshots per variant, which works but is more infrastructure than react-testing-library would be.

I think those costs are worth it. The architecture document is explicit: "the entire application shell compiles to less JavaScript than React's runtime alone". When the bundle is competing with map tiles, ML models, and live data streams, the framework tax is not free.

Here is the broader lesson, and it is the one that I think will stay with you after this chapter. Whenever you reach for a framework by default, ask: *What is the framework doing that the page actually needs?* If the answer is "fine-grained reactive updates to a deeply nested tree", use React. If the answer is "let me write components and have the framework handle the rest", you have not yet identified the real requirement. WorldMonitor's panels replace their entire subtree on every refresh. They do not need a diff. They need a swap. Vanilla TypeScript does that without a runtime, and the resulting architecture is one where the bundle weight, the WebView compatibility, the discriminated-union dispatch, and the team-ramp cost all factor into a decision that is defensible precisely because it was *deliberate*.

The next chapter takes this discriminated-union pattern and applies it to the problem of *scoring* — specifically, the question of how a system scores 31 countries for instability without falling into the trap where "more news headlines = more instability". The architectural pattern that solved the type problem in the renderer turns out to be exactly the pattern that solves the noise problem in the score.

---

References: