Chapter 38: The Browser-Native Application Loop
We can now assemble the architecture.
A browser-native application isn’t a pile of components. It isn’t a render tree with everything else hung off it. It’s a loop — a closed-shape sequence of interactions, events, commands, modules, and state, with the browser’s platform as the substrate the loop runs on top of.
Part II showed us what the platform provides. Part III’s previous chapters named the architectural pieces that sit above the platform: components describe intent, boundaries supply context, capabilities handle consequences, events are facts, commands are requests. This chapter puts the pieces together. The result is the architecture the rest of the book builds and ships.
The Loop, in One Picture
Section titled “The Loop, in One Picture”The architecture, as a single diagram:
┌─────────────────────────────────────────────────────┐ │ │ ▼ │ User interacts with native UI │ │ │ ▼ │ Component handles local interaction │ (focus, hover, the keystroke, the click) │ │ │ ▼ │ Component declares meaning │ (meta-event or meta-command in markup, │ or programmatic emit/dispatch) │ │ │ ▼ │ Boundary supplies context │ (surface, feature, entity, route, mode) │ │ │ ▼ │ Runtime routes │ (event: to all subscribers) │ (command: to single handler) │ │ │ ▼ │ Modules respond │ (analytics, audit, storage, permissions, │ notifications, observability, more) │ │ │ ▼ │ Providers expose stable services │ (user identity, locale, theme, router state) │ │ │ ▼ │ State changes │ (in browser storage — Chapter 25) │ │ │ ▼ │ Storage event propagates │ (across components, across tabs) │ │ │ ▼ │ UI updates │ (declaratively, via reactive properties or │ via CSS reacting to state) │ │ │ ▼ │ Diagnostics record the chain │ │ │ └────────────────► back to top of loop ───────────┘This is the architecture. Each piece does one thing. The composition is a closed loop — user interaction feeds the runtime, the runtime distributes to modules, modules update state, state propagates to the UI, the UI is ready for the next interaction. The loop runs continuously while the application is open.
The browser isn’t bypassed at any step. The platform’s primitives — DOM events, attributes, custom elements, forms, storage, CSS, the accessibility tree — are what the architecture is composed from. The runtime adds coordination on top of them, but everything underneath is what the browser already provides.
The Pieces and Their Roles
Section titled “The Pieces and Their Roles”A short tour of each piece in the loop, with what it contributes:
The user is the loop’s source of change. Every action — clicks, keystrokes, form submissions, navigation, scroll, hover — is a potential entry point. The browser receives the input directly; the application’s architecture observes what happens next.
The component handles local interaction. A <kit-button> knows it’s been clicked. A <kit-input> knows its value changed. A <kit-dialog> knows the user requested to close it. The component is responsible for the platform-level behavior (focus, keyboard, native semantics) and for declaring the application-level meaning of the interaction.
The markup carries the meaning. meta-event="profile.saved". meta-command="dialog.open". meta-prop-target="help-dialog". The attributes are the protocol surface. The component declares; the runtime interprets.
The boundary supplies the surrounding context. Surface, feature, entity, route, mode. The boundary is declared once for a region and applies to everything inside. The DOM’s tree structure is the inheritance mechanism.
The runtime routes. An event with subscribers goes to all of them. A command with a handler goes to that one handler. The runtime ensures the rules — many observers for events, single handler for commands — and records what happened for diagnostics.
The modules respond. Analytics observes events and reports to the analytics provider. Audit records events. Storage handles command requests to persist values. Notifications observes events and shows toasts. Each module is a small unit of capability that does one thing well.
The providers expose stable services. The current user’s identity. The active locale. The current theme. The router’s current state. Providers are injected into modules; they’re the things the application has rather than things the application does.
The storage tier holds durable state (Chapter 25). The platform’s localStorage, sessionStorage, IndexedDB, and OPFS are the substrate. The runtime treats storage as the source of truth for any state that should survive the page lifetime.
Storage events propagate change. When a module updates storage, the platform fires a storage event (for cross-tab synchronization) and the runtime fires a synthetic event (for same-tab reactivity). Components observing the relevant keys re-render automatically.
The UI updates. The components’ reactive properties (Lit’s @property, or any equivalent mechanism) re-render when their dependencies change. CSS responds to state via attribute selectors, :has(), container queries, custom property inheritance. The platform’s runtime (Chapter 27) does the visible work.
Diagnostics record every event and command. The recorded trace is queryable, exportable, and useful for debugging, performance work, and audit-after-the-fact.
The loop closes back at the user, ready for the next interaction.
A Concrete Walkthrough
Section titled “A Concrete Walkthrough”A specific user action, traced through the loop.
The page is a profile editor. The user has typed a new display name and clicks Save. The markup, simplified:
<kit-boundary surface="settings-page" feature="preferences"> <kit-boundary surface="profile-form" entity-type="profile" entity-id="me"> <form> <kit-input name="displayName" value="Jeremy"></kit-input> <kit-button meta-event="profile.save_requested">Save</kit-button> </form> </kit-boundary></kit-boundary>The user clicks Save. The trace:
-
The browser fires a
clickevent on the<kit-button>. The button’s internal logic handles native button behavior — focus, keyboard, the click itself. The button doesn’t decide consequences; it just was activated. -
The click bubbles up. The outer
<kit-boundary>has a delegated listener attached. The listener observes the click, walks up fromevent.targetto find the nearest element withmeta-eventormeta-command. It finds the<kit-button>withmeta-event="profile.save_requested". -
The listener walks further up the tree to collect boundary context. It reads
surface="profile-form",entity-type="profile",entity-id="me"from the inner boundary, andsurface="settings-page",feature="preferences"from the outer one. The combined context becomes part of the event. -
The listener constructs the enriched event:
{type: 'profile.save_requested',context: {surfaces: ['settings-page', 'profile-form'],surface: 'profile-form',feature: 'preferences',entity: { type: 'profile', id: 'me' }},payload: { /* form data from the surrounding form */ }} -
The runtime receives the event. The diagnostics module records it. The runtime distributes the event to subscribed modules.
-
The save-orchestrator module is subscribed to
profile.save_requested. The module reads the form data, dispatches aform.validatecommand (the runtime sends it to the form-validation module, which returns{ valid: true }), and then dispatches aprofile.savecommand (which the runtime sends to the profile-save module, which makes the API call and returns{ saved: true, profile: {...} }). -
With the save successful, the save-orchestrator module emits
profile.savedas an event:{type: 'profile.saved',context: { /* same as above */ },payload: { profile: { ... } }} -
The runtime distributes
profile.savedto its subscribers:- The analytics module sends Profile Saved to the analytics provider with the context attached.
- The audit module records the change in the audit log.
- The draft-cleanup module clears the user’s draft from
localStorage. - The notification module dispatches a
notification.showcommand for a success toast. - The observability module adds a breadcrumb to the trace.
-
The draft-cleanup module’s
localStorage.removeItemcall fires a synthetic storage event. Components observing the draft key see the change and update (in this case, the draft indicator disappears). -
The notification module’s
notification.showcommand is routed to its handler. The handler creates a toast element, attaches it to a toast container, and starts the toast’s auto-dismiss timer. The browser renders the toast. -
The diagnostic record now contains the full sequence — the original
profile.save_requestedevent, theform.validateandprofile.savecommands and their results, theprofile.savedevent and all five handlers’ responses, thenotification.showcommand. The application’s behavior over the past few hundred milliseconds is fully traceable.
The loop is complete. The user sees the success toast and is ready for the next interaction. The button, throughout the entire flow, did one thing: declared that it represented profile.save_requested. Everything else was the architecture’s responsibility, distributed across modules that each did one small piece of work.
Why the Loop Works on the Browser
Section titled “Why the Loop Works on the Browser”The loop works particularly well on the browser because the platform already provides the substrate.
DOM containment gives boundary inheritance. The DOM’s tree structure is the inheritance mechanism for surface, feature, entity, route, mode. No application code is required to maintain the inheritance — the browser does it.
Event bubbling gives event delegation. One listener at a boundary observes every event inside. The browser propagates the event up the tree; the listener intercepts at the right level. Custom events with composed: true cross shadow boundaries cleanly.
Attributes as protocol gives the metadata layer (Chapter 22). Components declare meaning in markup. The runtime reads the meaning at event time. The protocol is inspectable by browser dev tools, by tests, by AI tooling, by anything that can read HTML.
Forms as transactions gives the application’s commit boundaries (Chapter 24). A form is the smallest unit at which user intent commits to a structured payload. The architecture leans on this.
Custom elements give portable components. A <kit-button> is a real element. The platform manages its lifecycle. The component is queryable, styleable, testable through standard tools.
Storage as state layer (Chapter 25) gives durable, cross-tab-consistent state. The application doesn’t need an in-memory store; the platform’s storage tier is the store.
CSS as runtime (Chapter 27) gives reactive presentation. Custom properties, cascade layers, :has(), container queries — the styling responds to state changes without JavaScript involvement.
Accessibility (Chapter 29) gives the architecture’s structural integrity for the full range of users. Native semantics carry the accessibility contract; the runtime preserves it.
The platform is doing most of the work. The runtime is adding the small coordination layer that turns the platform’s primitives into an application’s architecture.
What’s Not in the Loop
Section titled “What’s Not in the Loop”The chapter should be honest about what the loop doesn’t contain.
A renderer is not in the loop. Lit, React, Vue, server-rendered HTML — any of these can produce the markup the loop operates on. The renderer is the producer of the DOM the runtime observes; it isn’t the architecture’s center. A team using the loop can pick whichever renderer fits its needs and keep the rest of the architecture stable.
Routing is not in the loop’s center. As Chapter 26 argued, routing belongs to the server. A router adapter establishes a boundary at the document root with the current route context, and the rest of the architecture inherits that context. The loop doesn’t need a client-side router; it needs route context, which the boundary protocol supplies.
Data fetching is not in the loop’s center. Modules that need to talk to a server use the platform’s fetch directly, usually wrapped in repository objects (Part IV introduces the pattern). The data layer is a set of modules and providers, not a separate framework.
State management is not in the loop’s center. The storage tier (Chapter 25) is the state substrate. Modules read from storage, write to storage, observe storage events. There’s no separate state library because the platform’s storage layer is the state library.
The loop is just the coordination kernel. It composes with renderers, routing, data, state, and other concerns; it doesn’t try to be the answer for all of them.
The Closed-Loop Guarantee
Section titled “The Closed-Loop Guarantee”A specific architectural property the loop provides is worth naming directly.
Every meaningful action produces a traceable record. The combination of events, commands, and diagnostics means that the application’s behavior over any period of time can be reconstructed. The trace shows what happened in what order, with what data, with what results.
Components don’t import the modules that consume their actions. The button declares an event; the analytics module subscribes to the event. Adding a new analytics provider doesn’t require changing the button. Removing analytics doesn’t require changing the button.
Modules can be installed and uninstalled at runtime. Production might have audit, analytics, and observability. Development might have a debug overlay and a slower analytics provider for verification. Tests might have neither. The same components produce the same events; the modules subscribed are different in each environment.
The DOM is the integration substrate. Anything that produces compatible markup — server templates, Lit components, React, AI-generated HTML — participates in the architecture without further integration work. The boundary, metadata, and event mechanisms are platform-native; they don’t require framework-specific bridges.
The closed loop is what gives the architecture its predictability, debuggability, and openness. The chapters that follow build the loop in code.
Bridge to Part IV
Section titled “Bridge to Part IV”Part III named the architecture’s principles. Part IV builds the architecture by hand — a small TypeScript runtime, a shell that hosts it, a boundary system, a metadata boundary, a diagnostics surface. The point of building it by hand is to make every piece visible. The Part IV implementation isn’t meant to ship; it’s meant to teach. After Part IV, you’ll have written each piece of the architecture in plain TypeScript, and the maintained version in Part VI will read as a more polished version of what you’ve already built.
Part V wraps the architecture in web components (using Lit). Part VI ships it as Kitsune, the maintained reference implementation with the polish, the testing, the documentation, and the integration adapters real applications would need. Part VII addresses production concerns. Part VIII engages with the future — AI-generated UIs, cross-device delivery, what comes after the web we have now.
The architecture from Chapters 34–38 is the substrate every subsequent chapter builds on. Each piece will appear again — as code in Part IV, as components in Part V, as a maintained framework in Part VI, as integration in Part VII, as the substrate for the AI-future arguments in Part VIII.
Exercise: Implement the Loop Manually
Section titled “Exercise: Implement the Loop Manually”Build a page with this markup:
<section data-surface="settings-page" data-feature="preferences"> <section data-surface="profile-form" data-entity-type="profile" data-entity-id="me"> <button data-meta-event="profile.saved">Save</button> </section> <section data-surface="help-section"> <button data-meta-command="notification.show" data-meta-prop-message="Help opened"> Help </button> </section></section>Implement, in plain TypeScript:
- A metadata parser that reads
data-meta-*attributes from a given element. - A context collector that walks up the DOM tree from an element, collecting
data-surface,data-feature,data-entity-type, anddata-entity-idvalues into a context object. - A small event bus with
on(type, handler)andemit(event). - A small command bus with
handleCommand(type, handler)andcommand(command). - A delegated click listener attached to the outer section, which uses the parser and collector to construct events or commands and dispatch them into the buses.
- A notification command handler that logs the message to the console (and ideally renders a real toast).
- A diagnostic logger that records every event and command with timestamps.
Click both buttons. Watch the diagnostic log produce a complete trace of what happened.
Reflect on:
- Which piece belonged to the browser? (DOM event firing, attribute reading, ancestor traversal.)
- Which piece belonged to the runtime? (The parser, the collector, the buses, the listener.)
- Which piece belonged to a module? (The notification handler.)
- Where would a component library fit? (Wrapping the buttons and sections as
<kit-button>and<kit-boundary>custom elements that handle the same protocol more ergonomically.) - What changes if you add a second event subscriber — say, a console-logger that logs every event? (One new
runtime.on('*', ...)call.) - What changes if you replace the notification command handler with one that uses the platform’s
<dialog>element instead of a toast? (One module’s implementation; the markup doesn’t change.)
The exercise is the architecture in miniature. Part IV builds the same architecture at production scale. The shape is the same. The principles from Part III’s previous chapters are what each piece embodies.
Part III ends here. The architecture has a name, a shape, and a closed loop. The implementation follows.