Published on

Pick Your Frontend by What It Costs to Remove

Authors
  • avatar
    Name
    Mamun Rashid

Most of the frontend decisions I've watched get made were settled in about a week. Someone builds a prototype, it feels good, and that feeling quietly becomes the architecture. Nobody asks what it will cost to take the thing back out.

Agustín Barrientos asks exactly that in How to Choose a Frontend Technology for the Long Term. His test is to judge a library by what it would cost to remove in five years, not by how nice it felt in week one. That cost depends almost entirely on how far the library spreads past your component boundaries.

His analogy is the wiring in a building. You can swap a light fixture whenever you like, as long as the outlets and switches follow the standard. You're stuck when the fixture brand also decides what your wall sockets look like.

I read it while building a voxel engine, and it described a decision I'd already made without having good words for it. So this post is partly a tour of the debate around that idea, and partly what I did with it.

The contract is the risk

For a web component, Barrientos spells out the public contract: the tag name, properties and attributes, events, slots, and the CSS custom properties used for theming. If all of those are plain browser standards, the library behind them stays replaceable.

He shows it with a quantity stepper. The Lit version gets swapped for a plain HTMLElement class, and the only thing consumers change is an import line. Nothing that used the component ever knew Lit was there.

A web component's public contract sits between its consumers and its implementation. A React app, a plain HTML page and a server template use only the contract: the tag, attributes and properties, events, slots and CSS custom properties. Underneath, a Lit class and a plain HTMLElement class can be swapped, and consumers change one import line.

From there he rules a technology out if any of these is true:

  • Consumers have to install it just to use one of your components.
  • You can't adopt it, or remove it, one component at a time.
  • Migrating away would reach into routing, data loading and the rest of the app.

He also wants the accepted costs written down before the first component ships (a thin ecosystem, a smaller hiring pool, who controls governance), next to a plan for replacing it. And he says the contract file deserves a closer review than the render code. That's the line I'd underline.

His verdict is fair. Lit and web components suit design systems, embeddable widgets and the slow modernization of an old app. React with Next.js fits better when you need server rendering out of the box, a big ecosystem or fast hiring, because Lit's routing and server rendering are still experimental. The package for the latter, @lit-labs/ssr, is still labelled pre-release.

Not everyone buys it

The most useful pushback I found is Adam Silver's Do web components make your design system framework-agnostic? A web component only covers the JavaScript enhancement. The markup, the styling hooks and the data still come from whatever templating stack you already use, so "framework-agnostic" promises more than it delivers. Coordinating data and events between components still needs a library or code of your own.

Nolan Lawson, who likes web components, is just as direct in Use web components for what they're good at. They shine as self-contained widgets like editors and color pickers, for migrating an app piece by piece, and for design systems shared across frameworks. They struggle with server rendering, accessibility and slots. His observation that most web component problems are really shadow DOM problems has stayed with me.

Rich Harris's Why I don't use web components is older and more combative. He argues about opportunity cost, polyfills and how much complexity each new platform feature adds. The replies underneath are half the value.

Then there's what teams actually did. GitHub wrote about building on web components with its own Catalyst library in 2021, and has since leaned much harder on React. The reasons people repeat are developer experience and hiring. I couldn't find an official write-up, so treat that as secondhand. Still, it's a good reminder that the hiring pool line in a decision doc is a real cost.

Shoelace is the case I keep coming back to. It's a standards-based library built to be framework-agnostic, and it was renamed Web Awesome, with the tag prefix reportedly changing from sl- to wa-. Every consumer gets to touch every tag. Standards protect you from your library's framework. They can't protect you from your library renaming its own contract.

What I did with Cubit

Cubit is the voxel engine I've been building for Qarya, a browser game. From the start, the goal was that a game written for one player in a browser tab could later run behind a server with many players, without rewriting how its state works.

That goal settled the contract before anything about rendering mattered.

The engine is split into packages. @cubit/core holds blocks, chunks, entities, physics, the simulation and serialization. @cubit/three draws the world with three.js. @cubit/input turns keyboard, mouse and touch into abstract actions. Only one of them knows three.js exists.

The rule that matters most is that core never imports three.js and never touches a browser global, because a server will import it one day. A rule like that erodes quietly, so there's a test for it. It fails if core's package.json depends on three, if any source file imports it, if anything reaches for window, document, localStorage, performance or requestAnimationFrame, or if core's TypeScript config ever picks up the DOM library. In wiring terms, the renderer is the light fixture, and the test is the electrician who won't let it run cables into the walls.

The contract itself is two shapes of plain data. Input goes in as commands. Everything comes back out as messages: chunk contents, block changes, and the entity list for each tick. The client rebuilds its own copy of the world from those messages alone, and the renderer draws that copy, never the simulation's.

Cubit's boundary. @cubit/input sends commands through a JSON round trip to LocalHost inside @cubit/core, which queues them on the Simulation. The Simulation validates and applies them at 60 ticks a second and returns events. LocalHost turns those into messages that go through another JSON round trip to ClientWorld, the client's copy, which the @cubit/three renderer draws. Core has no three.js, no browser globals and no DOM types, and an isolation test enforces it.

The cheapest safeguard in the whole design is this one. In single player, the local host still pushes every message through JSON.stringify and JSON.parse in both directions, the way a network would. Anything that can't survive the trip breaks in a single tab, long before there's a server to find it.

A couple of the engine's rules exist because of exactly that kind of break. Block ids depend on the order blocks are defined, so they never leave the process, and chunks serialize with a palette of block names instead. And a typed array doesn't round-trip through JSON at all: JSON.stringify turns a Uint16Array into an object keyed "0", "1", "2", and you never get the array back.

The same idea showed up one level higher, at the package boundary. Qarya started out using Cubit from a sibling folder on my machine. Later the engine moved to a private package registry. Qarya imports @cubit/core and the rest through npm aliases, so the move touched package.json, the lockfile and a few lines of build config, and not one line of the game itself. Same outlet, different supplier.

Qarya's game code imports @cubit/core and the move didn't change it. In package.json the alias went from a folder on my machine, link:../../cubit/packages/core, to a private registry, npm:@mmncit/cubit-core@^0.1.0. Only package.json, the lockfile and a few lines of build config changed.

Two honest caveats. I haven't swapped the renderer for something else yet, so the claim that I could is still a claim. And the multiplayer half isn't built: the architecture doc lists a WebSocket server, interest management and client-side prediction as not done. What I can say is that the publish script runs the tests, the isolation test included, before every release, so the boundary can't drift without someone noticing.

A checklist I'd actually use

  1. Write the contract before the component: tag, attributes and properties, events, slots, CSS custom properties. Then ask whether anything in it needs your library to work.

  2. Run the swap test once. Rewrite one component as a plain HTMLElement class and see what consumers have to change. If it's more than an import line, the library has leaked.

  3. Version the contract, not just the package. A Custom Elements Manifest describes the public API, so you can diff it against the last release in CI and catch a removed attribute or a renamed event before a minor release ships it. I couldn't find a maintained tool for the diff itself, so expect to write a small script.

  4. Pick a tag prefix you'll never rename, and guard registration. Two copies of a library defining the same tag will throw.

    if (!customElements.get('mm-stepper')) {
        customElements.define('mm-stepper', Stepper);
    }
    
  5. Theme through CSS custom properties and ::part, and document them as part of the contract. Styling and customization topped the list of consumer pain points in the State of HTML 2023 survey.

  6. Skip shadow DOM for leaf components that don't need slots. You give up <slot> and dodge most of the problems Lawson describes, including ARIA references that can't cross a shadow boundary.

  7. Know what your framework does with custom elements. React 19 passes Custom Elements Everywhere and wires on* props to custom events. During server rendering it only writes primitive props as attributes and drops objects and functions, so anything that has to be in the server HTML should be an attribute. React 18 and earlier need wrappers.

  8. Keep an eye on Reference Target. It lets aria-labelledby and label for point into a shadow root. Chromium posted an intent to ship in June 2026, so check the other engines before you rely on it.

  9. If you're replacing an old app, go feature by feature with the strangler fig pattern: new features as web components inside the old shell. It's the one job nearly everyone agrees web components are good at.

  10. Prototype the riskiest assumption, not the happy path. One real server-rendered route, your hardest dependency, and the new piece dropped into the existing app.

  11. Write down what you're accepting and how you'd leave. Dan McKinley's Choose Boring Technology treats novelty as a small budget of innovation tokens, and Glyph's Against Innovation Tokens argues the real cost is operations rather than novelty. Either way, the exit plan belongs in the decision doc, right next to the costs.

  12. Check the track record. How long did deprecated APIs live? Did upgrades come with codemods? Who controls governance? Lit moving to the OpenJS Foundation is the kind of answer you want to find.

What I took from it

The line I keep returning to isn't from Barrientos. It's from tef's old essay, Write code that is easy to delete, not easy to extend: every new consumer of an API is more code you'd have to rewrite to change your mind.

It's the same point from the other end. You can't avoid committing to something. What you can do is keep the commitment in the interface, make that interface boring and standard, and put a test on the boundary so it can't quietly grow. Then changing your mind later is a project instead of a rewrite.

Enjoyed this post?

Subscribe to get notified about new posts and updates. No spam, unsubscribe anytime.

By subscribing, you agree to our Privacy Policy. You can unsubscribe at any time.

Discussion

Loading comments...

This website is still under development. If you encounter any issues, please contact me.