Agustin Barrientos
All posts
The Senior EyeSep 14, 2026 - 18 min read

How to Choose a Frontend Technology for the Long Term

Want more of these in your Google results?

In most framework selection meetings, someone builds the same screen in the two or three candidates, and the vote goes to whichever one felt best to write, but that only captures the feel of the first week and not the long-term costs and commitments. A frontend technology keeps spreading long after that vote, into routing and forms, into how you load data, run tests, and render pages on the server. It eventually gets into who you can hire and what the onboarding docs have to cover. Five years later, the cost of replacing it depends on how far it has spread, and you can estimate that cost before you choose.

Lit, a library created by Google, has been my primary UI library for years, across design systems and product work, and I’ll use it as the example in this article. I picked it for the commitment it asks for, because with Lit you depend on one small library, the component boundaries are made of browser standards, the internals stay sealed off from the page by default, and you adopt it one component at a time. That choice comes at the cost of a smaller ecosystem, fewer ready-made answers for the application layer, and more integration decisions I have to make myself. The rest of this piece is the evaluation I wish someone had walked me through before my first selection meeting.

How much a UI library takes over

A UI library takes over rendering, reactivity (redrawing the screen when data changes), and lifecycle handling (the code that runs when a component appears, updates, and leaves), and it brings a component model and conventions for all of it. Lit only brings reactive properties, templates written in JavaScript’s own backtick strings, scoped styles, and a few lifecycle hooks. Whichever library you pick, you depend on it for years and take on its conventions, and you live with the limits of its ecosystem, hire from the pool of developers who know it, and eventually migrate off it.

What another app touches

Before I commit to a library, I check whether apps built on other frameworks could use the components I build with it and whether I could later replace it one component at a time. Every Lit component is a standard custom element, usable, as Lit’s own docs put it, “in any HTML environment, with any framework or none at all,” and that portability is one of the main reasons I picked Lit. Whether a component stays that portable depends on how you build it.

The public surface is everything another app touches, and it includes the tag that app writes in its markup, the attributes and properties it sets, the events it listens for, and the content it passes between your tags. As long as that surface is made of browser standards, anyone can use the component, and the library behind it stays replaceable. Every app that uses the component inherits the library along with it once the library itself shows up in that surface.

Say the component is a quantity stepper for an order row, and it’s just two buttons, a count, and a label. Its public surface is small enough that the contract every consumer types against fits in one short interface file. The stepper’s only properties are value, which another app reads and sets, and the min and max that bound it. When the count changes, the stepper fires its one event, quantity-change, and that event carries the new value. No type can express whether setting value from code fires it too, so the contract has to say that in words. Lit’s own event guidance follows the platform and recommends firing it for user interaction and staying silent when code sets a property. The interface extends HTMLElement to keep the file portable, because with Lit’s own base class every consumer would need Lit installed just to compile against it. A <button> takes its text as content between its tags, and the stepper gets its label the same way, through what the platform calls a slot. Content passed that way never shows up in the types. Theming is one CSS custom property, a style value the outside page can set. Every piece of that surface belongs to the platform, and Lit appears nowhere in the contract.

A page with no framework reaches the element with querySelector and addEventListener, while a Lit app binds to it with Lit’s template syntax. React 19 sets the properties and receives the event natively and passes every test on Custom Elements Everywhere, the site that scores each framework on how it handles custom elements. React versions before 19 passed data to a custom element as string attributes and didn’t hear its custom events. In those versions, consuming one meant holding a ref and attaching listeners by hand. The wrapper components that package that plumbing are one of the reasons established design systems ship React bindings. Plan for that wrapper if some of your consumers still run React 18. A consumer never imports the Lit class, because the import that registers the tag is the only line that ever names the implementation:

ts
import 'quantity-stepper';        // before the swap: the Lit implementation
import 'quantity-stepper-plain';  // after the swap: a plain HTMLElement class

I’ve run that swap for real, rewriting the same interface as a bare HTMLElement class with no library at all, and the consumers didn’t change by a byte while every line of the implementation did. Most of the rewrite was Lit’s chores done by hand, with me building the HTML, reading the attributes, and re-rendering on every change. If the surface holds, changing the one import line is the whole consumer-side migration. The radius of that migration grows from one component toward the whole application when a Lit template object leaks into a public property, when a directive (a Lit-only template helper) turns into public API, or when basic use starts requiring Lit’s context system.

After the swap, a consumer that no longer needs Lit still depends on everything the contract published, so the tag name, the way properties and attributes behave, the event’s name and payload, the slot, and the theming custom property all have to keep working. Those surfaces now need the versioning, compatibility policy, and deprecation plan that a framework’s maintainers carried while their API was the public one. Their API changes on the framework’s schedule, but I own this contract and can keep a commitment that’s written in one reviewable file. Nothing in Lit enforces any of this, so a clean surface is review discipline the team owns.

What Lit costs

Runtime size

Lit’s runtime size is around 5 KB minified and compressed, and it’s the number that gets quoted first in framework comparisons. When something changes, Lit updates only the dynamic parts of a template and keeps no virtual copy of the page to rebuild and compare. The size matters in fewer places than it gets quoted, mostly for components that travel, like a design system consumed by teams you don’t control, widgets embedded in pages that never invited your framework, or a page where several teams’ components have to coexist without each one shipping its own runtime.

Application code, data fetching, images, and third-party scripts usually do more to performance than bundle size does when a single team ships the whole app as one bundle. A synthetic race between component models measures the benchmark’s own conditions more than any product’s, so I didn’t build a benchmark for this piece. If a performance claim is going to carry your decision, measure it on your product.

Shadow DOM by default

Lit renders into shadow DOM by default, and shadow DOM gives each component its own tree of markup and styles, scoped away from the document around it. Outside selectors can’t restyle the component’s internals, and outside scripts can’t reach in by accident with querySelector. The scoping also lets components built by different teams share a page without coordinating class names. The boundary is only about scoping, though, since Lit’s default shadow root is open and code holding the host element can still reach inside on purpose through its shadowRoot property.

Shadow DOM doesn’t scope tag names. A tag defined on the document’s custom-element registry owns that name for the page’s lifetime, so if two independently loaded versions of this component both call customElements.define( 'quantity-stepper', ... ), the second call throws. The collision happens on pages assembled from pieces that several teams ship on their own schedules, and Lit gets recommended for pages like that. Scoped custom-element registries give each scope its own definition of a name. Some browsers have begun shipping them while others keep them behind a flag. Collecting registrations in one module only works when a single application owns every define call, so a component that ships into pages it doesn’t control needs a registration and versioning strategy until scoped registries solve the collision.

Your design system’s global stylesheet stops at the shadow root, so you have to design how a component gets themed. Outside CSS can theme only the custom properties and part names a component chooses to expose, and a part name is a label that lets outside CSS style a chosen internal element. Third-party libraries that expect one flat document, like an analytics script measuring text or an older date picker attaching to inputs, may need adapters.

Accessibility across shadow roots takes planning, because an IDREF association like aria-labelledby, one element naming another by its id, can’t point across them. A component works around that by naming its controls from a label inside its own shadow root. This part of the platform is still changing, so re-check it the week you decide. The ariaLabelledByElements property takes element references instead of IDREFs, which lets a control inside the shadow root name a label outside it. Its ElementInternals version does the same for the custom element itself. Both have been Baseline newly available since April 2025, meaning the latest version of every major browser supports them. The Reference Target proposal goes the other way and lets an outside for or aria-labelledby reach a designated element inside a shadow root. Chrome has an implementation heading into an origin trial, a sign-up test for individual sites rather than a general release. The schedule has already slipped, so check where it stands before you count on it.

You’ll test and debug across shadow roots with different tools than you’d use on a flat DOM. Someone on your team will do all of that shadow DOM integration work. Lit lets a component opt out by overriding its render root, and its docs are blunt about the cost. An element rendering into the light DOM loses style and DOM scoping, and it can no longer compose children through a slot.

The smaller ecosystem

If your product needs a form library, a data grid, a rich text editor, drag and drop, charts, an auth integration, and a server-rendering framework with conventions, React’s ecosystem has mature, documented options for every one of them, often several. Next.js alone ships settled answers for routing, data loading, and server rendering. With that much already built, a React team gets to its own features sooner. Lit’s routing and server rendering still live in its experimental Labs packages, so the gap there is maturity. Data is the exception, since the stable @lit/task helper runs one component’s async request and tracks its state. That’s a narrower job than what a framework’s data layer does.

Lit’s own list of packages is far shorter, so you fill the gaps from three sources, starting with the platform itself, which keeps absorbing what used to need a library. Framework-independent libraries work as well from a Lit component as from anywhere, and whatever neither of them covers, you write yourself. Some developers want that work, and contributing a missing piece to open source can help their career. When your team is on a deadline, every missing piece you write yourself takes time away from the product.

The sharpest gap is server rendering (SSR), where the server produces a page’s HTML so the reader’s first view doesn’t wait for JavaScript. Lit SSR exists and works, and its packages still live in Lit Labs, the project’s explicitly experimental tier. The docs say it only renders shadow-DOM components, doesn’t support async component work at all, and depends on declarative shadow DOM (a shadow root written into the HTML itself), which a script patches in where a browser lacks it. Those limits can still shrink or grow while the packages are in Labs, so read that page again before you sign.

Hiring

React’s advantage is at its most practical in hiring, where it has a bigger pool of developers with production experience. The courses are everywhere too, and interview loops already know what to ask. If the plan depends on plugging experienced people in quickly, all of that favors the incumbent.

On Lit’s side, getting better at it mostly means getting better at the platform every framework runs on, and that means learning custom elements, properties versus attributes, events, slots, shadow DOM, and how CSS behaves across boundaries. Lit also has APIs of its own, like templates, directives, controllers (reusable behavior objects a component plugs in), decorators, and a reactive update cycle, so writing Lit isn’t the same as writing framework-free JavaScript. When I onboard developers, a strong JavaScript and DOM foundation gets someone productive in Lit quickly, and the Web Components concepts they pick up stay useful outside Lit.

Who controls the project

A common argument for Lit’s longevity is that Google is behind it, and I don’t use that argument. A sponsor can change its mind, so I look at how the project is governed.

In October 2025 the Lit team announced a move to the OpenJS Foundation, and that move transfers the code, documentation, website, and brand to the foundation. The project also gets a technical steering committee drawn from Google, Adobe, and Reddit alongside independent leaders. The foundation’s own project listing shows Lit as an incubation project completing onboarding, while both announcements already call it an Impact Project, so the tier is still settling. Whatever tier Lit ends up in, its leadership is multi-vendor, its decisions are made in public, and no single company owns its assets.

The move to the foundation came years after I chose Lit, but up front you can evaluate who owns the project’s assets and how many organizations hold its leadership seats. You can also look at whether its decisions and design proposals happen in public, how it manages releases and breaking changes, and what would plausibly happen if the founding sponsor walked away. A popular project can still lack governance that lasts, so run those checks on every technology on your shortlist.

Where Lit fits

I often end up choosing Lit, especially for design systems, reusable component libraries, embedded widgets, interfaces that several frameworks will consume, and organizations running more than one frontend framework. Lit doesn’t demand that the application restructure around it, so incremental modernization fits too, replacing pieces of an older frontend one component at a time. So does any component that lives as a guest in someone else’s page, where the runtime has to stay small.

I’d pick something other than Lit when a product leans on integrated, conventional server rendering or its delivery speed depends on ecosystem breadth. The same goes for an organization that already runs a healthy standardized framework or puts hiring familiarity ahead of portability, and for a team that doesn’t want to own assembling the application layer, because with Lit that assembly is the team’s job.

For a content-heavy commerce product that needs integrated server rendering, mature authentication adapters, fast hiring, and settled analytics tooling, I’d choose React with Next.js and take on a wider framework commitment in exchange for application-level conventions and delivery speed. Lit alone and Next.js don’t cover the same jobs, so in the selection meeting, compare Lit plus the application tools you’d assemble around it against React plus an integrated framework.

Making the decision

When I’m choosing for component work like mine, I rule a technology out if other apps would need it installed just to use a component I built with it, or if I couldn’t bring it in and later take it out one component at a time. A component’s public contract also has to survive a rewrite of its internals without that technology, and moving off the technology can’t reach past the components into the rest of the application.

You can knowingly take on a thin ecosystem, a small hiring pool, or a single-vendor governance structure, as long as you write those costs into a decision document when you choose, so nobody discovers them in year three. That document should also say how you’d replace the technology if you had to, because the people who made the choice eventually leave and the next team can only read what got written down.

Every major upgrade brings migration work of its own, so read what the last few required and how long deprecated APIs stayed supported before you sign. Check whether migration guides or automated rewrite scripts shipped alongside the breaking releases, and how many ecosystem packages had to move together.

What to prototype

When you build a prototype in each candidate, aim it at the part of the decision you trust least. The prototype should try to prove the decision wrong, because a run that can only agree with it won’t make that part any more trustworthy. When integrated server rendering carries the decision, build one real server-rendered route and measure it. If you’re counting on ecosystem breadth, integrate the hardest dependency on your list, maybe the data grid, the rich text editor, or the authentication flow, because the easy one was never the risk. You check a portability claim by consuming the candidate’s component from a second environment and then swapping its implementation behind the same contract. A clean starter project hides the friction you’re signing up for, so when incremental adoption matters, drop the candidate into the existing application.

A few ways this evaluation goes wrong

  • Documenting a contract the implementation doesn’t keep. Say the stepper’s TSDoc promises a value bounded on read, while the code clamps once at write time and never rechecks the stored number. Both behave the same until value="30" arrives before max="20" does, in a sequence that never sets min. The write-time clamp had no bound to check yet, so it stored the 30 unchanged, and no later write ever revisits it. An implementation that bounds on read applies whatever bounds it holds at the moment it’s asked. It answers 20 as soon as the 20 lands, and the write-time version still answers 30. Even when the build is green, leave the review comment “the TSDoc says bounded on read and the code bounds on write, so pick one and make the other match.”
  • Deriving the contract from the implementation. Most public surfaces happen by accident, when somebody builds the component and whatever it exposes becomes the API other teams write against. A leaked template property usually gets into the API that way. Write the interface file first, even roughly, so that every public surface is one somebody chose and can defend in review.
  • Reading popularity as proof. A popular technology is easier to hire for and comes with more answered questions and maintained packages. Popularity says nothing about whether the technology fits your constraints.
  • Letting one advocate own the decision. In a design review, a second person should be able to defend the costs the team took on without the advocate in the room. If the advocate is the only one who can explain why those costs were acceptable, and that person resigns, the technology turns into legacy that nobody still on the team agreed to own.

What separates a senior from a junior here

  • They review the contract file harder than the component file. The render function is the natural place to start reading, but most of what sets a migration’s radius is outside it, in what the component exposes. In reviews I keep asking a version of “would anything outside this component need Lit for it to work?” One pass with that question catches the leaked template, the exported directive, and the context dependency together.
  • They test a contract with a second implementation. When the pair disagrees, either the contract never specified the answer or one implementation broke it. Both are cheaper to find here than in a consumer’s bug report. The comparison also turns up problems a working page doesn’t show, like a form button with no type that defaults to submit, or an accent that falls back to a hardcoded blue and lands around 3:1 contrast against a dark surface.
  • They plan the replacement before the first component ships. As long as the surfaces stay clean, each component can be rewritten behind its contract the way the stepper was. Those surfaces get decided when a component is first built, so the replacement plan goes into the same review as the adoption.

How I’d run the selection meeting

Nobody controls whether a technology stays funded, maintained, or fashionable, and a meeting can weigh that risk but never settle it. So I’d spend that part of the meeting on the governance checks from earlier, because they show how the project is built to survive those changes. A team does control how deeply the technology gets into the architecture and what replacing it would cost, and both get set early, in the contract files more than in the meeting itself. I signed my commitment to Lit knowing the costs, and for component work like mine I’d sign it again.

Want more of these in your Google results?