The Sum of Subtractions

How consolidating Zuddl's frontend stack cut developer friction and real-time costs by 50%.

At Zuddl, our frontend code lived in two separate repositories. One held our newer apps in an Nx monorepo. The other held the events app — the big one, the thing attendees actually look at during a live event. Roughly 350,000 LOC, built on tooling we picked years ago and never revisited.

Having two repositories is fine, right up until they need to share common code. Ours did, constantly. Before we could put them together, though, we had to subtract a lot.

Problem

Shared pieces — a registration form, a payment flow, a speakers widget — lived in the monorepo. The events app couldn’t just import them. It had to install them like any third-party library off the internet, even though we wrote them ourselves.

So changing a shared button meant making the change in one repo, publishing a new package version, then updating the other repo and merging again. A one-line change needed two pull requests and two review cycles, and in between, the two repos could disagree about what the button actually was.

Everything else followed from that split.

The two sides drifted apart. Each repo upgraded its libraries on its own schedule, so they ended up on different versions of the same things — different major versions of TanStack Query, different routers, different build tools. Nothing was broken, exactly. But no shared code could safely assume anything about its surroundings.

Nobody could see the whole picture. Searching the codebase meant searching twice. This got noticeably worse once we started using AI coding agents — an agent pointed at one repo simply cannot know that the other repo calls the same endpoint. It’ll confidently do half the job.

And it was costing us money, too. Our studio app (what organizers use to run a live event) rendered inside the events app as an iframe. Both apps needed a Pusher WebSocket connection to stay in sync, but because they were separate apps, each opened its own. That meant maintaining two connections per attendee session.

Solution

Move the events app into the same repository as everything else.

Once it’s all one codebase, sharing stops being a process. A shared component is just a folder you import. One PR, no publishing, no version bumps. And anyone — human or agent — can search one tree and see everything.

The real-time connections fix is my favourite part. The studio code can become a shared library that the events app imports directly, instead of an iframe pretending to be a separate website. One connection instead of two — which cut our Pusher bill roughly in half, just by moving files around.

Cleanup

Before moving anything, we cleaned up the events app. It was still on older versions of most of the dependencies, and every file we kept was a file we’d later have to make work with new build tools, new lint rules, and new compiler settings. The fewer files we carried over, the easier the migration would be.

We used Knip to find unused code, then started deleting. We had two date libraries, two carousels, two rich-text editors, and two ways to parse a URL — leftovers from years of different people solving the same problems with different tools.

Last, we switched the build tool from webpack to Vite while the events app was still on its own. One change at a time — that way, if something broke, it was easier to track down what caused it.

All told, we deleted about 100,000 lines of code — roughly 1,000 TSX and CSS files — and removed 101 dependencies. Between the deletions and the Vite switch, the events app ended up with a 40% smaller bundle and 70% faster deploys.

Migration

The move itself was deliberately boring: adopt the monorepo’s tooling and leave the events app’s actual code alone. The interesting part was deciding which version wins whenever the two repos disagreed.

Our default was to take the newer one. That held for most dependencies. TanStack Query was the exception — the events app was already on v5, the monorepo still on v4. Both had to match before the code could merge, so we upgraded the monorepo first, as its own separate piece of work. I used Claude Fable 5 for the mechanical parts.

TanStack ships an official upgrade codemod, but it stripped out type information we rely on and didn’t understand the way we write our queries. So Fable wrote us one that did, and it rewrote around 870 call sites.

Then there was the rename trap. TanStack Query renames isLoading to isPending on the useMutation hook, but keeps the same isLoading name on useQuery with a subtly different meaning. The word appeared 1,467 times in our code, mostly as ordinary variables with nothing to do with the library.

Fable got through it anyway. It read each occurrence in context to work out where the flag came from, changed only the ones that needed it, and ran the type checker and tests after each batch. Out of 1,467 occurrences, exactly 241 needed to change.

Thoughts

Bun recently rewrote their JavaScript transpiler in Rust, and a line from their post stuck with me:

This Rust rewrite would’ve taken a team of engineers with full-context on the codebase a year of work. With 1 engineer using Fable & closely monitoring Claude Code, we went from start to 100% of the test suite passing on all platforms in 11 days.

One engineer can do a lot more today than a year ago.

That’s roughly how this felt. All of these changes were done by me alone in a separate git worktree on the side, without delaying regular product development.

Nothing about this migration was clever. It was just big. All of it would have taken a whole team months to get through without cutting corners.