Overview
May 2, 2026

The frontend was harder than the runtime

When I describe goldengoose to engineers, the part that raises eyebrows is the runtime: native Rust, session and turn lifecycles as enforced state machines, event fanout with reliability tiers, everything typed across the IPC boundary. The assumption baked into those raised eyebrows is that the Rust is where the hard engineering lives, and the React on top is the easy part, the paint.

It’s the opposite. Over 1,700 commits, the frontend has been the harder half, and I want to lay out why, because I don’t think it’s specific to my app. I think it’s what happens to any frontend the moment more than one agent is streaming into it.

The runtime’s problems have names

None of this is a claim that the backend was easy. It’s a claim that its problems were known-shaped. A session lifecycle is a state machine; the field has fifty years of advice about state machines. Event ordering, idempotent appends, replay after disconnect: these are distributed-systems problems with textbook names, and when I got one wrong, the shape of the fix was findable. Hard work, but work with prior art. That’s precisely why that half could later be extracted into gooselake as a standalone runtime: well-understood problems produce reusable solutions.

The frontend’s core problem does not have a textbook name. It’s something like: render a live, trustworthy view of twenty concurrent event streams, any of which can rewrite its own history, on top of state you are forbidden from owning, while feeling instant. Chat UI patterns don’t cover it. A chat UI is an append-only array fed by one stream, and every assumption in that sentence breaks.

The frontend is a replica node

The founding decision in goldengoose was to make the frontend as dumb as possible: one source of truth in the Rust runtime, generated typed bindings across the boundary, the frontend renders what it’s told and nothing else. I made that decision to simplify the frontend, after years of debugging apps whose state was scattered across both sides and disagreeing with itself.

What I didn’t fully appreciate is what “dumb” costs. A frontend that owns nothing is a replica, a node in a distributed system whose job is to converge on the runtime’s truth from a stream of patches. And replicas have distributed-systems problems, whether or not you used the words when you wrote them.

The timeline is the clearest case. It isn’t an array of messages; it’s a versioned projection with a revision and an epoch, updated by append, update, and remove patches because agents edit their earlier output and providers replay events after crashes. Keeping that projection honest meant accumulating exactly the invariants you’d write for a replicated cache: a stale patch from a previous epoch gets dropped, not applied out of order. A patch that would change an entry’s kind, turning a tool call into a message, fails instead of silently corrupting the view. A row shape the frontend doesn’t recognize renders as an unknown-row fallback instead of crashing, because the Rust side and the TypeScript side evolve at different speeds and the replica has to survive being briefly behind. Every one of those rules exists because I watched the view lie to me without them. The backend never lied this way; it had one copy of the truth. Lying is a replica’s failure mode, and the replica is the part written in React.

Rendering is a scheduling problem

The second thing nobody warns you about: with enough concurrent streams, rendering stops being a display problem and becomes a scheduling problem, and you end up writing something uncomfortably close to an OS scheduler in a webview.

Patches arrive through a batching layer with priorities. The focused session, the one I’m looking at, flushes on animation frames so it feels live. Background sessions drain round-robin on a slower interval, so twenty streaming agents can’t starve the one in front of me. Cold sessions stop retaining bulky tool output in memory entirely, because the durable timeline has it if I click back in; tool deltas live in bounded pages with truncation accounting rather than ever-growing strings, because an agent that dumps a build log into its transcript should cost a bounded amount of memory, not all of it.

None of that logic is “frontend” in the way the word usually means. There’s no design in it, no CSS. It’s resource management, including fairness, starvation, eviction, and backpressure, running in the UI layer because that’s the only place it can run. The runtime can hand the frontend a perfectly ordered stream and it means nothing if the tab locks up applying it. The app expects dozens of sessions, and the invariant I actually care about is brutal: the active one has to feel instant no matter what the rest are doing.

The UI kept escaping into Rust

Here’s the tell that convinced me the frontend was the harder half: its features kept refusing to stay in the webview.

The markdown viewer looks like a UI nicety. But I wanted sub-100ms renders while an agent is actively rewriting the file, which means file watchers streaming revision deltas, and a frontend that detects a gap in revisions and falls back to a clean refetch instead of rendering a corrupted view. So it’s a native renderer with the replica discipline from the timeline applied to a file on disk. The @ file autocomplete looks like a dropdown. Making it instant on big repos meant a native workspace index: gitignore-aware scans, immutable snapshots the UI reads without locking, fuzzy matching off the interactive thread. It was built as an indexing problem, because as a dropdown it was unusable. Even notifications ended up as native NSPanel integration over Swift FFI, because faked webview notifications couldn’t do action buttons properly.

That’s the pattern: every time a frontend feature had a real performance or correctness requirement, the solution left the frontend. What stayed behind in TypeScript was the part with no established playbook at all: the projection, the scheduler, and the judgment calls about what a human needs to see when twenty things are happening at once.

Correctness is provable. Feel is not

The deepest reason the frontend was harder is that I could prove the backend done. There are tests that saturate the event queue on purpose and assert that a streaming delta gets sacrificed while the turn-completion always lands. That’s a property. It holds or it doesn’t.

There is no test for “switching between two streaming agents doesn’t make me lose my place.” No assertion for whether the composer queue, typing follow-ups while an agent works, editing them, reordering them, feels like a superpower or like fighting the app. Startup in under half a second came from an embarrassing amount of transcript-loading and timeline-reconstruction optimization for a number no one else will ever measure. It mattered, because I open this app every morning, and the difference between a tool I tolerate and a tool I enjoy lives entirely in that unprovable territory. The backend’s spec was a state machine. The frontend’s spec was a feeling, and the only way to check a feeling is to live in the app daily and notice everything that’s slightly wrong, forever.

That’s also why the two halves had such different fates. The runtime’s hard-won lessons compressed into a reusable artifact: gooselake exists because backend problems, once solved, stay solved. The frontend’s lessons don’t compress. Nobody will ever extract my timeline scheduler into a library, because it’s welded to a hundred judgments about this app, this workflow, my tolerance for latency in this specific panel. The backend was hard the way a good problem is hard. The frontend was hard the way a product is hard, and that kind doesn’t get easier the second time.