I've worked across distributed systems, microservices, low-level kernels, and large SPAs. In my opinion, SPAs are the hardest.
There are so many possible states, so many events, so many opportunities for bugs. I've seen so many SPAs, even ones that start with good architecture and good separation of concerns, end up in a buggy mess. I've been writing SPAs since jQuery and was a big CoffeeScript/BackboneJS fanboy back in the day. React was great. But the state question was never solved. Flux, Redux, Zustand, hooks. It's hard.
SPAs are harder than we admit
A serious SPA is trying to do several difficult things at once. It has to respond instantly, juggle async work, validate half-complete input, preserve local intent, recover from failure, and stay understandable after months of product changes. Then you add optimistic updates, caching, background refresh, undo, deep links, permissions, and performance constraints. None of that is unusual by itself. In combination it gets nasty fast.
Keeping the logic coherent is usually harder than the UI. As the product grows, logic leaks everywhere. A bit in the component. A bit in a hook. A bit in a selector. A bit in a reducer. A bit in some helper that started life as a convenience and slowly became business logic.
The symptoms are familiar:
- state duplicated because nobody quite trusts the source of truth
- effects hidden in hooks
- selectors that stop selecting and start deciding
- reducers that drift into a second domain model
- bugs that only show up after a very particular sequence of clicks, requests, and retries
Once you hit that point, you are not dealing with a simple frontend problem anymore. You are building an interactive stateful system in the browser, with a UI attached.
That is why I think serious SPAs need a stronger application boundary than TypeScript usually gives us.
The real problem: TypeScript is too flexible
TypeScript is a huge improvement over JavaScript. But large SPAs still have a structural problem: the language lets you put logic almost anywhere.
You can put business rules in components.
You can put validation in hooks.
You can put state transitions in event handlers.
You can put derived data in selectors.
You can mutate local UI state while also updating global state.
You can fire network requests from components.
You can create three versions of the same concept: one in the API model, one in the store, and one in the component props.
A disciplined team can avoid some of this. But discipline is not an architecture.
TypeScript does not give you a hard application boundary. Serious SPAs need one.
Make TypeScript boring.
Deliberately constrained. Not worse, just thinner.
The proposal in one screen
Here is the whole architecture in one pass:
UI emits typed events
-> worker forwards event
-> Rust/Wasm engine applies transition
-> engine derives next view model
-> engine emits patches + effect commands
-> TypeScript shell applies patches and runs effects
-> effect results come back as events
Rust owns canonical state. TypeScript owns rendering, message passing, and browser integration. Components receive input and emit onEvent. That is the contract.
I have been building this pattern in the open. engine-shell is the shared scaffold: worker client, CBOR wire encoding, view-model store, patch application, and an effect registry on the main thread. rust-tetris is a full app on top of it. You can play the demo in the browser. Tetris is a useful stress test because the game loop is fast, the state transitions are fiddly, and the UI still needs to stay responsive.
Make the UI shell dumb on purpose
I use "dumb" to mean constrained. The UI can still be expressive. What gets stricter is the boundary.
The UI layer should look like this:
type SidebarItemProps = {
input: SidebarItemInput;
onEvent: (event: SidebarItemEvent) => void;
};
function SidebarItem({ input, onEvent }: SidebarItemProps) {
return (
<button
aria-selected={input.selected}
onClick={() =>
onEvent({
type: "ui.itemSelected",
itemId: input.item.id,
})
}
>
{input.item.label}
</button>
);
}
That component has no idea how selection works. It does not know where state lives. It does not know whether the event will update local state, write to storage, fire a network request, update a query plan, or do nothing.
It receives input. It emits onEvent. That is all.
My React components emit semantic events. Those events are typed from Rust. They cross the boundary, and the main thread applies a view-model patch. It is rare that your whole view model needs to change. Oftentimes it is a slice. That is the right granularity.
The rules are simple:
Components render.
Components emit events.
Components do not own domain logic.
Components do not mutate canonical state.
Components do not perform hidden effects.
The event type is not hand-written in TypeScript. It is generated from Rust:
#[derive(Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export)]
pub enum AppEvent {
UiItemSelected { item_id: ItemId },
UiInputChanged { field: FieldId, value: String },
SystemEffectCompleted { effect_id: EffectId, result: EffectResult },
SystemEffectFailed { effect_id: EffectId, error: EffectError },
}
The Rust side is the source of truth. TypeScript imports the generated event types. Components can narrow the event union to the subset they are allowed to emit, but they do not invent new domain events.
A typo in an event name should not be a runtime bug. A removed event should break the build. A component should not be able to smuggle arbitrary state transitions into the system.
Rust as the application kernel
Inside the worker, the Rust/Wasm engine owns the canonical state. The real state, not a copy or a cache.
pub struct Engine {
state: AppState,
current_view_model: ViewModel,
}
impl Engine {
pub fn handle_input(&mut self, input: EngineInput) -> EngineOutput {
let previous_view_model = self.current_view_model.clone();
let transition = self.reduce(input);
let next_view_model = select_view_model(&self.state);
let patches = diff_view_model(&previous_view_model, &next_view_model);
self.current_view_model = next_view_model;
EngineOutput {
patches,
effects: transition.effects,
diagnostics: transition.diagnostics,
}
}
}
From the outside, this behaves like a deterministic state machine:
previous state + input event
-> next state
-> view-model patch
-> effect commands
Three concepts matter here:
AppState is rich, private, and authoritative. Indexes, caches, entity graphs, undo history, query plans, validation state, correlation IDs, memo tables, internal IDs. Whatever the engine needs.
ViewModel is minimal, serializable, and render-oriented. The UI does not need the application's full internal model. It needs enough data to render.
EngineOutput contains patches, effects, and diagnostics.
The boundary is:
AppState
-> select_view_model(AppState)
-> diff previous ViewModel vs next ViewModel
-> send ViewModelPatch[]
rather than serializing everything and duplicating it on the main thread.
When Rust runs its reducers it emits a patch, normally a pretty small one, and that can cross the boundary without drama.
The main thread owns a small view-model store. It does not own canonical state. It caches the latest ViewModel and applies patches:
function applyPatchBatch(patches: ViewModelPatch[]) {
if (patches.length === 0) return;
snapshot = applyPatches(snapshot, patches);
listeners.forEach((listener) => listener());
}
Components subscribe through typed selectors. A wiring component selects the data a presentational component needs and forwards events. The presentational component stays pure. The wiring layer knows about selectors and dispatch. The Rust engine knows about state transitions. The boundaries are visible.
Effects are explicit commands
The second critical boundary: the Rust layer has no async logic. No async calls. Any effect is emitted as an effect command, run through an effect runner, which produces its own events that go back into the system.
A click and a fetch-progress update are both events.
The Rust engine should not call fetch. It should not touch browser storage. It should not read the clock. It should not generate random IDs from ambient global state. It should not call browser APIs directly.
Instead, it emits effect commands:
type EffectCommand =
| {
type: "http.request";
id: EffectId;
method: "GET" | "POST";
url: string;
body?: unknown;
}
| {
type: "storage.read";
id: EffectId;
key: string;
}
| {
type: "storage.write";
id: EffectId;
key: string;
value: unknown;
};
The TypeScript shell executes those commands. When an effect completes, the result comes back into the engine as another event:
{
type: "system.effectCompleted",
effectId,
result
}
This keeps the engine synchronous and testable. Async work becomes two deterministic transitions:
User clicked refresh
-> engine marks request pending
-> engine emits http.request effect
-> UI shows loading
HTTP request completes
-> result returns as event
-> engine updates canonical state
-> UI receives patch
That is a very different shape from "component calls fetch inside a hook and updates some local state when it returns."
Not every effect round-trips back into the engine. Timers and fetches complete as events. Presentation effects are terminal: they produce output for the UI and stop.
rust-weather-spiral is where I hit this. The Rust engine owns layout for thousands of spiral segments and emits a compact geometry wire plus a renderSpiral effect. Wasm cannot call the Canvas API. Something on the JavaScript side still has to execute those draw commands.
So the worker shell drains presentation effects before the response reaches the main thread. It decodes the CBOR from Wasm, strips renderSpiral from the effect list, runs the geometry wire through OffscreenCanvas 2D in the worker, and attaches the resulting ImageBitmap as a sidecar. The main thread only blits the bitmap to a visible canvas. Rust still owns the geometry. TypeScript still owns the browser API. The drain is the seam between them, and it stays in the worker so the main thread never parses thousands of individual draw ops.
All meaningful state changes still enter through typed events. Presentation draining is just how you hook up APIs the kernel cannot call directly. That gives you replay, event logs, deterministic tests, a place to put validation, and a place to reason about the system.
Those two boundaries (TypeScript with no business logic, Rust with no async) are what make the system easy to reason about and keep the heavy work off the main thread.
Why this is especially good for agents
I have tried keeping some of this within TypeScript. It drifts. Coding agents make that worse. Human developers do too, just more slowly. The separation keeps everyone on the rails.
I care less about how powerful the agent is and more about what kind of codebase keeps it there. Agents are good at smearing logic into convenient places. A good agent-friendly codebase narrows the surfaces where code can be "creative."
In this architecture, the agent can work on a presentational component without inventing domain behaviour. If the agent emits an invalid event, TypeScript should catch it. If the event exists but violates a domain rule, Rust should reject it or turn it into a diagnostic. If the component needs new data, the view model has to change deliberately. If new side effects are needed, they become explicit effect commands.
The agent cannot quietly add state in a hook, call a browser API in a component, and patch over the bug with another effect.
Rust holds the domain model, events, transitions, effect commands, and view model. TypeScript renders input, forwards events, applies patches, and runs effects. The compiler enforces exhaustiveness at the boundary.
I get it. If you had a human team working across TypeScript and Rust, would that take longer? Probably. But we are mostly talking about agent teams now, and the architecture was already worth it for humans.
Mac Agent Cockpit
The browser demos are useful. The app I actually wanted this architecture for is heavier.
mac-agent-cockpit is a Mac Tauri app for running Cursor agents through ACP. No Wasm worker. The kernel is native Rust in-process. The UI loop is the same:
Preact UI event -> AppEvent -> Engine::handle_input -> ViewModelPatch[] -> applyPatches -> render
ACP / SQLite / process / file event -> system AppEvent -> same loop
app-core owns AppState, events, view-model projection, and effect planning. The TypeScript side is a patch cache and presentational Preact components. dispatchAppEvent is a Tauri invoke. bootEngine listens for engine://patches and applies diffs. That is most of the frontend orchestration.
Effects are where native I/O lives: spawn agent acp, write to SQLite, load directory previews, sample process CPU, watch workspace dirty state, run git diffs, manage local preview servers. The Tauri bridge executes them and pumps completion back as typed system events. Rust plans. The shell performs.
This is what I meant when I said agentic workflows belong in this architecture. Conversations, permissions, streaming ACP messages, process budgets, git overlays, preview lifecycle: all state machine problems. I do not want that logic spread across Preact hooks while agents are also trying to help.
There is no CBOR wire format and no worker drain for canvas here. The boundary is Tauri IPC instead of postMessage. The shape is the same: boring TypeScript, authoritative Rust, explicit effects.
Where this architecture pays off
Small CRUD interfaces do not need this. The complexity has to justify the boundary.
For serious SPAs it pays off in analytics, query builders, local-first computation, offline state, large datasets, multi-step workflows, agentic workflows, security-sensitive actions, and complex validation. These are state machine problems disguised as frontend work.
I like using web technologies because they are fast, cross-platform, and I can do beautiful styling. A lot of this would apply to a standard native app too. SPAs are hard because they change quickly. They accumulate state, edge cases, user flows, async interactions, partial failures, and performance constraints. They are often where product complexity becomes visible, and where business rules quietly leak because it is convenient to put them near the button, form, modal, or table that needs them.
Trade-offs and objections
The worker/Wasm boundary
The obvious objection is latency. If every click has to cross from the main thread to a worker, into Wasm, then back again, surely that makes the UI feel worse?
Look. JavaScript is not slow. Many web apps feel sluggish anyway, and people blame Rust/Wasm boundary crossing. But main thread to worker to Wasm is tiny overhead unless you are shuffling huge payloads. For clicks and keystrokes, the boundary is rarely the bottleneck.
Micro-optimizations are usually not the real problem. Architecture and orchestration are. This architecture at least forces the right shape: events in, patches out.
The expensive parts of SPAs are usually unnecessary rendering, object churn, JSON parsing, duplicated computation, layout work, network latency, excessive effects, and state updates that invalidate too much UI. A worker boundary can help because it forces batching and explicitness. The complex logic stays off the main thread, so animations and interaction stay buttery.
Sweating the milliseconds
I do like performance, and there are things you can do when you need them.
A lot of applications pay a real cost on JSON.stringify, JSON.parse, and structured clone across the worker boundary. One option: emit a JSON string from Wasm, transfer the buffer, and JSON.parse on the main thread. I quite like CBOR. Same flexibility as JSON (I do not have to define strict types for every payload variant) but much faster. I output CBOR from Rust, transfer the buffer across the worker boundary, and decode with CborX on the main thread. It decodes faster than JSON.parse. Something has gone wrong if that is taking more than a few milliseconds. In rust-tetris the engine emits CBOR with ciborium; engine-shell decodes it on the main thread and applies patches.
If I want to pass a typed array over the boundary, say a big canvas of chart points, I can still do it in CBOR. CborX gives me a sub-array on the buffer with zero copy.
Why sweat the milliseconds? Because when you add more complexity, it keeps things snappy. You might say: what is the difference between 20ms and 15ms? Yeah, but what happens when multiple events fire at the same time, or the CPU is already busy? That difference matters.
It is rarely needed. But having the option is part of why I like Rust/Wasm as the kernel rather than just conventions in TypeScript.
Tooling and feedback loop
You do not get the same instant hot-reload loop for core application logic that you get in a pure TypeScript SPA. Changing Rust means rebuilding the Wasm module, restarting or refreshing the worker, and regenerating boundary types when contracts change.
I think that trade-off matters less than it used to. In an agent-assisted workflow, the slow part of building complex application logic is increasingly not typing the code and watching it reload every few seconds. It is specifying the behaviour clearly, generating or updating tests, reviewing the state transition, and checking that the system still satisfies its invariants. For that kind of work, a stricter compile/test loop is a feature, not a bug.
The hot-reload story improves because TypeScript has less to do. Styling, layout, visual states, component composition, interaction affordances: ordinary React/Preact, fast hot reload. Presentational components are smaller. The work that benefits most from instant visual feedback still has it. The work that benefits most from stronger correctness guarantees moves into Rust.
This is not for every app
I am not saying every SPA should work this way, or that React and TypeScript are bad, or that Wasm is automatically fast, or that Rust prevents all bugs. I am arguing for clearer separation:
Rust/Wasm:
state, validation, transitions, derived state, effect planning
TypeScript:
rendering, message passing, effect execution, browser integration
Components:
input + onEvent
The boundary pays for itself in determinism, isolation, replay, and a cleaner render loop.
Influences and why not just Elm
This architecture owes an obvious debt to Elm. Elm showed that frontend applications become much easier to reason about when they are structured around a small loop: model, update, view. State transitions are explicit. Events are values. Effects are described rather than performed directly inside components. The UI becomes a projection of state rather than a place where state is invented ad hoc.
The difference is incremental adoption inside a React/TypeScript codebase. Elm is elegant, but adopting Elm wholesale means stepping outside the React/TypeScript stack you already have. This approach keeps Elm's discipline, but moves the application kernel into Rust/Wasm and lets TypeScript become a deliberately constrained rendering shell. You keep your UI stack, design system, routing, browser integrations, and deployment model. The fragile application logic moves into a strongly typed, deterministic kernel.
Rust/Wasm also gives you a wider systems toolbox: compact binary formats, typed arrays, columnar data structures, SIMD-friendly computation, explicit memory layout, and worker isolation. For analytics, parsing, validation, query planning, or large local computations, the state kernel can be more than a reducer. It can be a high-performance application engine.
Working code
If you want to poke at a real implementation rather than the toy demos above:
- engine-shell — the reusable shell. TypeScript side:
createWorkerClient,ViewModelStore,applyPatchBatch, effect registry. Rust side: patch diff/apply primitives inengine-kernel. Wire types cross the boundary as CBOR. - rust-tetris — a complete Preact app with the game rules in Rust/Wasm.
AppEvent,EffectCommand, andViewModelare defined in Rust and exported to TypeScript with ts-rs. Timers and random numbers are effects on the main thread; ticks and input come back as events. Live demo. - rust-weather-spiral — a data-heavy visualisation on the same scaffold. Rust owns spiral layout and emits a compact draw wire; the worker drains
renderSpiraleffects into an OffscreenCanvas before blitting anImageBitmapto the main thread. Live demo. - mac-agent-cockpit — a native Mac app on the same loop without Wasm. Tauri + Preact UI, Rust
app-corekernel, SQLite conversations, Cursor ACP agent sessions, process supervision, workspace/git tooling.
Tetris is not a serious SPA. That is partly why I like it as a reference implementation. If the architecture stays clean when the loop is running at 60fps, it will survive a form with optimistic updates. Weather spiral is the canvas case: Rust does the geometry, the worker does the drawing, the main thread stays thin. Mac Agent Cockpit is the agent case: the whole product is orchestration, and the UI stays dumb on purpose.
Closing
Complexity has to live somewhere. I would rather it live in a Rust engine where transitions are typed, effects are explicit, and the compiler helps, than spread across hooks and components where agents and humans keep adding one more shortcut.
That is the shape I want for complex SPAs. The scaffolding is in engine-shell. Working apps: rust-tetris, rust-weather-spiral, mac-agent-cockpit. Make the frontend boring where it matters.