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 hard
A serious SPA has a lot going on at once. It should feel instant, deal with async work, validate half-finished input, recover from failure, and deal with a lot of state! A backend API or a database usally operates in a request/response cycle. Data in -> Process -> Data out. UI software is a lot more complex and SPAs have to deal with the network far more than most native software. Good SPAs also have optimistic updates, caching, background refresh, undo, deep links, permissions, and performance constraints. None of those things are unusual on their own. Together, its a lot of complexity to tame.
In my experience, keeping the logic coherent is usually harder than building the UI. As the app grows, the logic starts leaking all over the place. 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 was meant to stay small and somehow became business logic.
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 anywhere.
You can put business rules in components, validation in hooks, state transitions in event handlers, and derived data in selectors. You can update local UI state while also updating global state and not really know which one is supposed to be in charge. You can fire network requests from components. Before long you have three versions of the same thing: 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.
An experiment I've been working on to help solve this problem is using Rust for all my business logic and redicing TS to a dumb rendering shell (and effects runner).
Architecture
Rust owns canonical state. TypeScript owns rendering, message passing, and browser integration.
TS Components have a strict contract - they receive input props and an onEvent handler. input is a slice of the View Model that is owned by Rust but mirrored in TS. The data flow is:
- Component emits an event
- Dispatcher forawrds it to the Rust engine
- Rust engine runs business logic, updates state and produces a patch to the ViewModel and optionally a set of effects
- If the Rust engine emits effects, these are run by a TS effect runner (network requests, timers, etc) - effects simply produce more events which are procssed by the Rust engine in the sawm way as component events.
- ViewModel patches are applied in TS and React (or Preact) updates the UI
A template for this is here: engine-shell. And an example using the shell is here: rust-tetris.
Taming TS
This archiecture still allows very expressive UI's - but the discipline of moving all business logic out of the main thread, forces you to model all
events semantically. It forces you to think about the data model holistically rather than just updating some state in a component, or adding an if condition in a selector.
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 updates local state, writes to storage, fires a network request, updates a query plan, or does nothing.
It receives input. It emits onEvent.
My React components emit semantic events typed from Rust. They cross the boundary, and the main thread applies a view-model patch. Usually that patch only touches a slice of the view model.
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. Part of that state is the ViewModel, i.e. the data that our components need in order to render the UI.
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 Wasm and Worker boundaries without any performacne penalty.
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.
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." It also much more explcit and mmuch easier to reason about than having Apollo Client or SWR or RTK Query.
When Rust is too slow
For most operations Rust compiled to WASM till be equivalent or faster than JS, however there are some operations where access to the native browsser APIs is needed for speed. An example of this is the Canvas API.
rust-weather-spiral is a weather visualision app where I hit this problem. I naively assumed that drawing in WASM would be as fast, however benchmarking with tiny-skia showed that it was much slower than using native Canvas APIs.
This didn't require an architecture change however, I rather added the capability for Rust to emit presentation effects for the TS worker to drain these effects, sending the result to the main thread. The Rust engine owns layout for thousands of spiral segments and emits a compact geometry wire plus a renderSpiral effect. 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.
I "think" that this will scale. Essentially Rust still owns the ViewModel, but a slice of that ViewModel needs to be processed in TS before being shipped to the main thread. Imporantly its very simple logic - there is no business logic at all - that all stays in Rust.
All meaningful state changes still enter through typed events. Presentation draining is just how you hook up APIs the kernel cannot call directly. A result of this approach is that we have 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.
Agents need this
Point a coding agent at a complex SPA and they will help you fix bugs and add features, BUT they are likely to excaerbate the problem I mentioend at the start - scatting business logic all over the place. At the moment coding agents are very much goal orientated and in a well tested system with strong types and red-green developemnt, their aim is to make the tests, linter and typecheck pass. Even with a strong AGENTS.md that clearly lists out architectural rules, they will over time ignore it. I actually find Anthropic models worse for this, but they are all guilty, even the best models, at making the linter pass by adding an ignore line.
This architecture has fewer footguns. Yes the agent can stil write terrible rust, but in my experience bad rust code is an order of magnitude better than bad TS code. And by keeping effects out of Rust tests stay fast and deterministic. I find that Clippy plus the Rust compiler help prevent agent generated code from being too bad. And the benefit of Rust's strong type systems is that refactors are usaually safe and easy to do (it will take an agent a while, BUT the end result will be fully working code which is not the case with TS!)
Mac Agent Cockpit
mac-agent-cockpit is a Mac Tauri app for running Cursor agents through ACP. I'm building it because the feedback loop for running agents locally is still a lot better than running in the cloud, BUT I'm tired of my system grinding to a halt because 3 agents are running the typechcker. The goal is: super fast UI, CPU limited for agents and any processes they trigger and a better UI to manage multiple agents. It follows the archiecture described in this article, but without WASM. The kernel is native Rust in-process. The UI loop remains 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. This is a work in progress, but I'm happy so far.
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?
This is not the case - the boundary crossing is almost never the problem, its bad architecture and inefficent state and compoent updates that cause sluggishness.
This example shows that with this archticture we can even maanage a text input box state in Rust. While this is a small example, the nice thing with this archteciture is that this scales - no matter the size of the SPA the event emitted from a component will work the same as this small example, as will the view model patch, as will the selector.
As you can see, end-end is typically under 10ms - under the 16ms threshold to keep things buttery smooth.
Now sometimes it is neccceary to do optimistic updates, and again in the case of an input box, local state may well be preferred, but the demo abouve shows that you may well get away with having absolutely zero state in React.
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 - this alone can help animations and interaction stay smoth.
MVP, lean and performance
I feel that the push to MVP and lean has led to too little focus on performance and efficinecy.
Yes for a startup the most important thing is buidling an MVP and finding product market fit. But having an eye for performance and efficiency is important and unless it is addiing needless complexity should be considered from the beginning.
The one area that I see too little attantion too is the size and shape of data crossing various boundaries. Whether it's an API response or data sent to a worker thread. A tool that I reach for nowadays is CBOR. In V8, using cbor-x encoding is twice as fast as JSON stringify. However the real benefit comes when you either have large numbers of similar repeated objects, where with CBOR you can get 4x performance on decode; or if you work with numbers or binary data and used TypedArrays. Decoding a TypedArray with cbor-x is a zero-copy operation and is therefore insanely fast.
Why sweat the milliseconds? Well partly because as software engineers I think we should stop wasting CPU cycles! But also by making that 10ms operation take 5ms, even though it is imperceptible to an end-user, it means as you add more complexity to your app, perforamnce can stay fast.
What about Hot Reload?
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.
However when you are working on UI - where hot reload is super helpful, the story is actually better. Because far less TS code is being written and your components have zero state - hot reload is very very fast!
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.
Elm is elegant, but adopting Elm wholesale means stepping outside the React/TypeScript stack you already have. My Rust/TS 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 and SIMD-friendly computation. 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-events-tracing — keystroke boundary tracing on the same scaffold. A text input whose value lives in Rust; the demo times encode, worker, Wasm, decode, patch apply, and re-render per key. 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 shows that this approach is not tied to web apps.
Closing
Complexity has to live somewhere. This archticture doesn't need Rust/WASM or even worker threads, but those constraints help keep the architecture sane. I think this approach gives coding agents much needed guardrails.