DEV Community

Cover image for Runtime Is Not the Problem
Viktor Lázár
Viktor Lázár

Posted on

Runtime Is Not the Problem

The most popular story about modern UI frameworks is wonderfully clean. Svelte is small because it is compiled. React is large because it ships a runtime. One moves work to build time; the other carries a machine into the browser. If the question is why a small Svelte app often starts smaller than a small React app, that story is not wrong.

It is only too small.

The important distinction is not compiled vs runtime. The important distinction is specialized output vs packaged capability. A compiler can specialize the program because it sees the component. A runtime can be small if it is packaged as a set of capabilities the application actually uses. The waste appears when a runtime is distributed as a single old monolith: one root API makes the app pay for the whole engine, including paths that only matter to applications much more complex than the one being shipped.

That is not the inevitable cost of React's model. It is the cost of React's packaging shape.

React is not large because runtime frameworks must be large. React is large because the browser-facing React we install today is still assembled like a general-purpose engine rather than a capability graph. If that graph were exposed to bundlers and compilers as static structure, dead-code elimination and tree shaking could do much more of the work people currently credit only to compiled frameworks.

The compiler is not magic. The runtime is not the enemy. The question is where the framework pays for generality, and whether the application is allowed to decline the parts of that generality it does not use.

The Clean Story

Svelte's pitch has always been easy to understand. The official site describes Svelte as a framework that uses a compiler so components do minimal work in the browser. Older Svelte copy made the contrast even sharper: move as much work as possible out of the browser and into the build step. That is a powerful architectural statement because the browser receives code shaped around the application, not a general interpreter for a component model.

React's browser story is different. A React app calls createRoot or hydrateRoot from react-dom/client, and from that moment React owns the tree. The application ships a runtime because the runtime is the thing that keeps React's programming model true after the JavaScript has loaded.

At the scale of a counter, the contrast is almost unfair.

A compiler can look at a tiny counter and emit code that changes the text when the number changes. A runtime framework has to make even that counter an instance of a broader component language. That language is the source of React's power, but it also means the smallest program starts by importing a model built for much larger programs.

This is why small examples make compiled frameworks look so good. They are not paying for the general case before the general case appears.

But the small example also distorts the argument. A real application is not one counter. It is product pressure accumulated over time. Local decisions become global constraints. Dependencies surround the framework. Behavior that began in one place starts to matter somewhere else. At that point, "compiled vs runtime" stops being a binary and becomes a curve.

The Size Curve

The size curve begins with the floor: the amount of JavaScript you ship before the application has done anything interesting. React's floor is visible because react and react-dom are real packages with real client runtime code. Svelte's floor is lower because more of the component model has been consumed by the compiler before the browser ever sees it.

But the floor is only the beginning. The slope matters just as much, because the application does not stay at its starter size. A framework that starts tiny can grow quickly if its compiled output repeats itself. A framework with a higher floor can become relatively cheaper if its runtime amortizes shared behavior well.

Compiled frameworks often have a low floor because they emit specialized code. But specialized code can repeat. If every component carries its own little version of a pattern, the app may pay the same idea many times. Good compilers avoid this by sharing helpers and by lowering common patterns into reusable runtime pieces, which is another way of saying that compiled frameworks also have runtimes. The difference is that those runtimes have already been filtered through the compiler's view of the app.

Runtime frameworks often have a higher floor because they ship the general machine once. But after that first payment, repeated components can be cheap because they are data for the same machine. The runtime does not need a new implementation of the update model for every component. The application describes the tree; the runtime interprets the description.

So the size question should not be "which framework is smaller?" That question hides the shape of the payment. A framework has an initial fixed cost, and it has a growth rate as features are added. The architecture is healthy when the route mostly pays for what it uses. Repeated behavior should be amortized. Absent capability should remain absent from the output.

Tiny islands usually favor the compiler. Larger applications make the answer depend on the curve rather than the category. Once product dependencies dominate the bundle, the framework tax may look smaller in percentage terms, but it has become more architectural: it follows the application into every place that wanted to stay small.

The useful measurement is not the total size of node_modules. It is not even the total output directory. It is the JavaScript on the critical path for a user action. The important bytes are the ones that stand between the user and the first interactive route. After that, the question is whether new code arrives only when the user moves into new behavior, or whether the framework has forced unrelated capability onto the path.

That last question is where React becomes interesting.

React's Monolith Problem

React's public model is beautifully small. Its user-facing idea is compact enough that it survived a decade of ecosystem churn: components make UI feel like ordinary program structure.

The shipped runtime is not compact in the same way.

That is not a criticism of the React team's engineering discipline. React carries a lot because React does a lot. It has to keep a very broad rendering contract true across browsers, across rendering modes, and across years of ecosystem assumptions. The weight is not accidental. The question is whether all of that weight belongs on every route.

The problem is that the browser package is arranged around the general product, not around the current application's capability set.

A simple client-rendered widget does not ask the same question as a server-rendered application that hydrates, streams, recovers from errors, and coordinates work across a large tree. A tiny island with one click handler should be allowed to stay tiny. A component that only needs local interaction should not inherit the full mental weight of an application root.

Today, those distinctions are mostly semantic. They matter to the developer. They matter to the runtime once the app is running. But they are not exposed as a clean static import graph that a bundler can prune aggressively.

The package boundary says: this is React DOM for the client.

It does not say: this route needs the small DOM-and-state subset, while the machinery for richer rendering modes can stay out of the bundle.

That second sentence is what a capability-shaped React would need to make visible.

The Host Is Part of the Cost

There is another asymmetry hidden inside the usual Svelte-vs-React comparison. Svelte's mainstream target is the web DOM. That focus is part of why the compiler can be so effective. It knows the host it is lowering into. It can turn a component into browser-shaped code because the browser is the world the component is meant to inhabit.

That is not an insult. It is a strength. A compiler gains power when the target is narrow enough to make strong decisions.

React's abstraction boundary is different. React DOM is not React; it is one renderer for React. The component model sits above the host. PDF and canvas renderers make the point clearly: React's component approach is not inherently a DOM approach. Those targets do not make the browser bundle smaller. But they do explain why React wants to be a component model before it is a DOM compiler.

This matters because some of React's weight is the price of that separation. A framework tied closely to the DOM can specialize earlier. A framework that treats the DOM as one host among several has to preserve a more abstract contract. That contract is valuable. It lets the same mental model cross output targets in a way a DOM-first compiler does not naturally promise.

But the conclusion should not be that every DOM app must carry the full cost of host-agnostic generality. The renderer boundary is exactly where capability packaging should help. If an application is only using React as a small DOM island, it should not pay as if it were exercising the entire host-independent model. React's multi-target nature explains the need for abstraction. It does not justify an undifferentiated browser bundle.

Tree Shaking Needs Shape

Tree shaking is often described as if it were a magic vacuum that removes whatever code the app does not use. It is less magical than that. Bundlers need static structure. Webpack's own guide is blunt about the ingredients: ES module syntax, production optimizations, and accurate side-effect information are what let unused exports and whole modules disappear.

This is why library shape matters so much.

If a package exposes independent modules with pure exports, the bundler has something to understand. If a package exposes one entry point whose evaluation may affect the whole runtime, the bundler has to be conservative. In JavaScript, conservatism means bytes. If evaluating a module might matter, the module stays.

React is especially difficult here because many capabilities are not normal userland functions. They are semantics inside the renderer. The app imports the renderer, not a set of isolated implementations the bundler can reason about one by one.

That is the old monolith shape.

Not old because the code is bad. Old because the distribution model assumes that the framework is one coherent runtime and the application either uses that runtime or it does not. That assumption made sense when coarse package boundaries were normal and framework competition was mostly about programming model rather than transferred JavaScript. It makes less sense now that applications are expected to move through finer-grained delivery paths, and when build tools care deeply about static structure.

Dead-code elimination cannot remove a capability it cannot see.

What a Capability Graph Would Mean

Imagine React distributed less like a runtime blob and more like a set of capabilities.

The application root would not mean "give me the whole browser renderer." It would declare a rendering mode, and that mode would imply the runtime capabilities needed to preserve React's semantics for that part of the app.

Hooks would not be one undifferentiated runtime assumption. The common local primitives would form the base layer; coordination primitives would be added only when the app uses them. Some of that machinery would still be shared, and some of it would be impossible to remove in practice because the component model depends on it. But the graph would at least describe the difference between "this app uses local state" and "this app uses the full coordination model React exposes."

The DOM event system would follow the same rule. A form route should not pay for event families it never uses. A static island with one button should not inherit the same event surface as a canvas-heavy editor.

Hydration would be a capability, not a tax hidden behind the same import shape as client rendering. The richer runtime features would be visible in the graph instead of being treated as ambient facts of the renderer. Development diagnostics would remain development-only with a boundary that production bundlers can see without heroic inference.

The compiler would participate, but it would not replace the runtime. JSX compilation and React Compiler output could describe what the app actually uses. Framework and bundler layers could then carry that information into the package graph. This is the shape of the missing information: the app already contains the answer, but the framework does not package itself in a way that lets the build pipeline use the answer fully.

In that world, React would still be a runtime framework. It would still provide the live component semantics people choose React for. But a small app would no longer pay for the whole semantic universe before it had earned it.

That is the part the compiled-vs-runtime argument misses. A runtime can be tree-shaken if it is designed as something tree-shakable.

Compilation Is a Form of Packaging

The word "compiled" makes Svelte sound like it lives in a different category, but compilation is also a packaging strategy.

The compiler looks at the application and decides what code to emit. Its real advantage is that it filters the runtime surface through what the program can prove at build time. The result is not "no runtime." The result is a runtime surface that has already been filtered through the program.

That filtering is the real advantage.

A compiler gets to ask: what does this component actually do?

A capability-shaped runtime gets to ask almost the same question: what capabilities does this application actually use?

Those two approaches are closer than the marketing categories suggest. The best future is probably not compiled frameworks on one side and runtime frameworks on the other. It is compiler-assisted runtimes with small, explicit capability graphs. It is frameworks whose core semantics can remain general while their shipped code becomes specific.

Svelte starts from specialization and adds shared machinery when specialization would repeat too much. React starts from shared machinery and could recover specialization by making the machinery divisible. The direction is different. The destination is similar: the browser should receive the smallest faithful implementation of the app's semantics.

The App Size Conversation We Should Have

When people compare Svelte and React bundle sizes, they often compare starter apps. Starter apps are useful because they reveal the floor. They are also dangerous because they make the floor feel like the whole building.

A better comparison would walk the same frameworks through a growth path. It would start with a tiny island and keep adding the kinds of pressure real products accumulate. The point would not be to make the examples impressive. The point would be to see how the framework's fixed cost behaves as the application stops being a toy.

For each one, the measurement should separate framework cost from app cost and route cost from total build output. A framework that looks expensive at the beginning may disappear behind the product later. A framework that looks tiny at the beginning may duplicate enough specialized output to make the curve less obvious. A framework that lazy-loads well may win on the first route even if its total app output is larger.

The point of this exercise is not to crown a universal winner. The point is to see the shape of payment.

Svelte's bet is that many UI programs are better served by paying at build time and shipping specialized code. React's bet has been that many UI programs are better served by a stable runtime model that can express a very wide range of behavior. Both bets are legitimate. The problem is when React's bet is implemented as if every page needs the full runtime model up front.

That is where React's size becomes less philosophical and more mechanical.

The Smaller React That Could Exist

There is a smaller React hiding inside React.

Not Preact. Not a compatibility clone. Not a new framework with React-like syntax. React itself, if its distribution model matched the way modern applications are built.

That React would have a small root for client-only islands. Server-rendered roots would opt into hydration as a visible capability. More advanced rendering behavior would be added by use, not smuggled in as part of the default client entry point. The important change would be static structure: bundlers would be able to remove entire subtrees without understanding React's internals, and framework adapters could declare the rendering mode they need per route.

This would not be easy. React's internal semantics are deeply connected. Splitting a renderer after years of integrated design is harder than designing a small library from scratch. Some capabilities that look optional from the outside may share invariants that make them hard to separate safely. The compatibility contract is enormous, and every new boundary is another place where bugs can hide.

But difficulty is not impossibility, and it is not a rebuttal to the architectural point.

The React programming model does not require the browser bundle to be a monolith. It requires a runtime capable of preserving React's semantics for the capabilities the application uses. Those are different requirements. One is historical packaging. The other is the actual product.

If React were designed today for the way applications are delivered now, it is hard to imagine it would expose the same coarse client runtime as the only normal path. It would be capability-first from the beginning because the web now punishes undifferentiated JavaScript more visibly than it did when React's package shape hardened.

Runtime Is Still Valuable

It is tempting, after all of this, to conclude that runtimes are a regrettable compromise. They are not.

Runtimes buy consistency. They make dynamic behavior composable across the lifetime of an app and across code-splitting boundaries. They can also see more of the live application than any one compiled component can, which makes the runtime behavior richer than a pile of emitted code.

Those are real advantages. They are why React won so much mindshare in the first place.

The mistake is treating runtime value and runtime size as inseparable. A runtime is not a single object by nature. It can be layered, declared, and assisted by a compiler that proves which layers are needed. A framework can keep a high-level programming model without forcing every route to ship every lower-level mechanism.

The right criticism of React is not "React has a runtime."

The right criticism is "React's runtime is not packaged according to the capabilities of the app."

That is a much more useful criticism because it points toward a better React rather than toward a world where every framework has to become Svelte.

The Real Divide

The real divide is not compiled vs runtime.

The real divide is specific vs undifferentiated.

Specific means the browser receives code shaped around the current program. For a compiled framework, that shape comes from emitted output. For a runtime framework, it has to come from capability boundaries. Undifferentiated means the framework ships its generality before the app has asked for it.

This is the lens that makes the argument clearer.

Svelte is not small because compilers are holy. It is small because the compiler gives the package manager and bundler a more app-shaped output. React is not large because runtimes are doomed. It is large because the output is still too framework-shaped.

The browser does not care whether a byte came from a compiler or a runtime package. It cares whether the byte is necessary for the current experience. Users do not reward architectural purity. They reward pages that load quickly, become interactive quickly, and stay responsive under real product pressure.

So the question for any framework should be simple:

Can the smallest app receive the smallest faithful version of your model?

If the answer is yes, the framework scales downward. It can start small without being a toy. If the answer is no, the framework may still be powerful, mature, and worth choosing, but its size problem is not a law of nature. It is a distribution problem.

React could be small. Not by becoming Svelte. Not by abandoning runtime semantics. By admitting, in its package shape, that applications do not use frameworks all at once.

They use capabilities.

And capabilities are exactly the kind of thing a modern build pipeline can remove when they are absent, if only the framework is built to let them be absent.

Top comments (0)