<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Viktor Lázár</title>
    <description>The latest articles on DEV Community by Viktor Lázár (@lazarv).</description>
    <link>https://dev.to/lazarv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1115191%2Fd5ca33e8-94a4-4cdf-9452-400c95556d9c.jpeg</url>
      <title>DEV Community: Viktor Lázár</title>
      <link>https://dev.to/lazarv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lazarv"/>
    <language>en</language>
    <item>
      <title>The Page Root Was the Wrong Unit</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Fri, 22 May 2026 15:37:46 +0000</pubDate>
      <link>https://dev.to/lazarv/the-page-root-was-the-wrong-unit-3088</link>
      <guid>https://dev.to/lazarv/the-page-root-was-the-wrong-unit-3088</guid>
      <description>&lt;p&gt;For a long time, React server rendering came with a quiet bargain. The server would give the browser HTML early, so the user would not stare at a blank page. Then, once JavaScript arrived, React would come back and take ownership of that HTML from the root down.&lt;/p&gt;

&lt;p&gt;That sounded like an implementation detail, but it was really an architectural claim: the page is one thing. It has one root. It becomes interactive as one program.&lt;/p&gt;

&lt;p&gt;For some pages, that is true enough. A dashboard, an editor, or a dense application shell often wants to become one connected client-side system. But the web is full of pages that are not shaped that way. A product page has a buy box, reviews, recommendations, badges, media, a map, a few accordions, maybe a carousel nobody touches. A documentation page has mostly text and a search box. A marketing page has long stretches of server-rendered content with a few points of behavior scattered through it. Hydrating all of that through the same root was convenient for the framework, not necessarily honest about the page.&lt;/p&gt;

&lt;p&gt;This is the thread that connects React 18 selective hydration, TanStack Start deferred hydration, and &lt;code&gt;@lazarv/react-server&lt;/code&gt; hydration islands. They are all reactions to the same old bargain. But they are not the same reaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling the same root
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/reactwg/react-18/discussions/37" rel="noopener noreferrer"&gt;React 18 selective hydration&lt;/a&gt; was the first big break in the waterfall. Before it, SSR in React had an awkward sequence: gather the data, render the HTML, load the JavaScript, hydrate the tree. Each step wanted to finish for the whole app before the next one could really begin. If comments were slow, the shell waited. If the comments bundle was large, the navigation waited. If hydration was expensive, even already-visible controls could feel stuck behind unrelated work.&lt;/p&gt;

&lt;p&gt;Suspense changed the shape of that sequence. Once the server can stream HTML through Suspense boundaries, the ready parts of the page no longer have to wait for the slow parts. Once the client can hydrate through those same boundaries, the ready JavaScript no longer has to wait for every other bundle. And once React can notice that the user clicked inside a still-dry boundary, it can move that boundary to the front of the hydration line.&lt;/p&gt;

&lt;p&gt;That is a real shift. The comments widget can arrive late without preventing the rest of the page from becoming useful. A sidebar can hydrate after a post. A user interaction can pull a boundary forward. Hydration stops being a single uninterrupted march from the root through the entire tree.&lt;/p&gt;

&lt;p&gt;But React did not change who owns the page. It changed how the owner schedules work.&lt;/p&gt;

&lt;p&gt;A Suspense boundary is a scheduling unit inside one React root. It lets React stream, pause, resume, prioritize, and preserve server HTML while code is still loading. It does not mean "this part of the document is no longer React's problem." If the boundary was server-rendered as part of the app, React still expects to reconcile it eventually. If parent state or context changes in a way that makes the preserved HTML stale, React has to protect consistency. If the code arrives and the boundary matters, hydration remains part of the plan.&lt;/p&gt;

&lt;p&gt;That is why &lt;a href="https://github.com/reactwg/react-18/discussions/67" rel="noopener noreferrer"&gt;the old React WG question about stopping hydration for part of the document&lt;/a&gt; is such a useful hinge. The proposed trick was to suspend a boundary forever, mostly to keep static JSX-heavy content from entering the initial client work. The answer was essentially: that is not a stable ownership boundary. Updates can still force React to inspect it, context can still matter, and you may still have downloaded the code. The real direction, React maintainers suggested, was Server Components.&lt;/p&gt;

&lt;p&gt;That answer points at the deeper issue. The desire was not only to hydrate later. It was to stop pretending the entire document had to belong to the client root in the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gate inside the app
&lt;/h2&gt;

&lt;p&gt;TanStack Start's deferred hydration lives in the space just before that deeper shift. It accepts the single-root app model, but gives the developer a way to keep certain server-rendered subtrees out of the initial hydration queue until there is a reason to admit them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hydrate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tanstack/react-start&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;visible&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tanstack/react-start/hydration&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Hydrate&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;400px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Reviews&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Hydrate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing in this example is not that reviews become lazy. They are still in the HTML. The server still rendered them. Users can read them. Crawlers can index them. CSS can style them. What changes is that the client tree does not have to hydrate that boundary during the first pass. The boundary can wait for visibility, idle time, interaction, a media query, a condition, or it can intentionally remain static for the initial document with &lt;code&gt;never()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is more than React selective hydration. React decides the order of hydration work that exists. TanStack Start can decide whether that work should be in the initial queue at all. Its compiler can also split the boundary's children into a deferred chunk, so the browser may avoid fetching that JavaScript until the boundary is close to hydrating. That changes both CPU timing and network timing.&lt;/p&gt;

&lt;p&gt;The subtle part is that the server HTML is not treated like a loading placeholder. It is the thing the user sees while the boundary is waiting. In TanStack Start, &lt;code&gt;fallback&lt;/code&gt; on &lt;code&gt;&amp;lt;Hydrate&amp;gt;&lt;/code&gt; is not the skeleton shown during initial document hydration if server HTML already exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Hydrate&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReviewsSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Reviews&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Hydrate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first page load, if &lt;code&gt;&amp;lt;Reviews /&amp;gt;&lt;/code&gt; was rendered into the document, the user keeps seeing the rendered reviews. The skeleton is for a different situation: the app is already running, a boundary first appears through client-side navigation or conditional rendering, and there is no preserved server HTML for that boundary. The same prop has to serve the post-startup client world, not replace the initial server world.&lt;/p&gt;

&lt;p&gt;That distinction gives TanStack's model its character. Deferred hydration is not "show a spinner until the component wakes up." It is "preserve the server-rendered thing until the client has a reason to own it."&lt;/p&gt;

&lt;p&gt;Still, the root remains the root. TanStack Start is explicit about that. A deferred boundary sits inside one React tree. Parent updates can force it to hydrate earlier if correctness requires it. Nested boundaries hydrate parent-first. Context and state are still part of the surrounding app model. This is not Astro-style island composition, where separate roots are dropped into a mostly static page. It is one React application with doors that can stay closed for a while.&lt;/p&gt;

&lt;p&gt;That is a good model when the page really is one app, but some regions are non-urgent. Reviews below the fold, recommendation carousels, media-heavy embeds, comparison tables, maps, and rarely used panels are all good candidates. They belong to the app. They should be server-rendered. They just do not need to consume startup work before the user gets anywhere near them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real island
&lt;/h2&gt;

&lt;p&gt;The RSC version of the story starts from a more complicated default. Server Components run on the server and their component code does not become part of the client bundle, but the page can still be one React tree on the client. The static output produced by Server Components is still represented through the RSC payload and can still sit under the page root that React hydrates and reconciles. In that model, "server" does not automatically mean "outside the client React tree." It often means "rendered on the server, then represented inside the client-owned page tree."&lt;/p&gt;

&lt;p&gt;That is already useful, because it keeps server-only code and browser code separate. But it does not, by itself, break the old root-level bargain. A static paragraph rendered by a Server Component may not ship its component implementation to the browser, but if it is part of the page root, it still belongs to the client React app's tree shape.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@lazarv/react-server&lt;/code&gt; pushes that further with &lt;a href="https://react-server.dev/features/hydration-islands" rel="noopener noreferrer"&gt;&lt;code&gt;"use hydrate"&lt;/code&gt; islands&lt;/a&gt;. A component can mark a server-rendered subtree as a hydration island:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ReviewsIsland&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use hydrate: visible; rootMargin=600px; id=reviews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Reviews&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime renders the island as normal server HTML, but it also writes an island-specific RSC payload and later calls React hydration for that island as its own root. That is the central point. The island is not a delayed region of a larger client tree. It is a separate React tree.&lt;/p&gt;

&lt;p&gt;The page root does not have to be a client root just because this part of the page eventually becomes interactive. The static content above the island, below it, and around it does not participate in a React app on the client at all. It remains document HTML. There is no root-level React owner waiting to reconcile it, no page-wide hydration pass that has to account for it, and no fiction that the entire document is one client program with a few slow parts.&lt;/p&gt;

&lt;p&gt;The page can have no client components at the root, no root RSC hydration payload, and still contain an island that wakes up later.&lt;/p&gt;

&lt;p&gt;That is not merely deferred work. It is a true island.&lt;/p&gt;

&lt;p&gt;With React selective hydration, a Suspense boundary inside the main root gets scheduled more intelligently. With TanStack Start deferred hydration, a subtree inside the main root waits at a gate. With an &lt;code&gt;@lazarv/react-server&lt;/code&gt; hydration island, a new hydrate root appears inside an otherwise non-React document. Once hydrated, that island may also behave as a local outlet and use primitives such as &lt;code&gt;Link local&lt;/code&gt; and &lt;code&gt;Refresh local&lt;/code&gt;, but that is downstream of the more important fact: it is not owned by the page root.&lt;/p&gt;

&lt;p&gt;This is the part that makes &lt;code&gt;"use hydrate"&lt;/code&gt; more than a trigger syntax. The familiar strategies are there: &lt;code&gt;load&lt;/code&gt;, &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;visible&lt;/code&gt;, &lt;code&gt;interaction&lt;/code&gt;, &lt;code&gt;media&lt;/code&gt;, and &lt;code&gt;never&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AccountMenuIsland&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use hydrate: interaction; events=pointerenter,focusin; id=account_menu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AccountMenu&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the strategy is not the architecture. &lt;code&gt;visible&lt;/code&gt; is just when the door opens. The architectural question is what is behind the door. In TanStack Start, it is a deferred part of the same app root. In &lt;code&gt;@lazarv/react-server&lt;/code&gt;, it is a separate React hydrate root with its own payload, mounted into a document whose surrounding content is not part of the client React tree. That difference matters when you are deciding whether the page itself should become client-owned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallbacks are part of the contract
&lt;/h2&gt;

&lt;p&gt;It also changes how fallback should be understood. React's Suspense fallback is a rendering fallback: what should appear when content is not ready. TanStack Start's &lt;code&gt;Hydrate fallback&lt;/code&gt; is mostly a client-mount fallback: what to show when no initial server HTML exists for a boundary that appears after the app is already running. In &lt;code&gt;@lazarv/react-server&lt;/code&gt; hydration islands, some of the most important fallbacks are operational. If &lt;code&gt;IntersectionObserver&lt;/code&gt; is unavailable for a &lt;code&gt;visible&lt;/code&gt; island, hydrating immediately is safer than leaving visible UI permanently inert. If &lt;code&gt;matchMedia&lt;/code&gt; is unavailable for a &lt;code&gt;media&lt;/code&gt; island, eager hydration is again the better failure mode. If an &lt;code&gt;interaction&lt;/code&gt; island wakes on the first click, you should not assume that exact click will replay into the newly hydrated component; for controls where the first click must perform the action, earlier intent events like &lt;code&gt;pointerenter&lt;/code&gt; or &lt;code&gt;focusin&lt;/code&gt; are better triggers.&lt;/p&gt;

&lt;p&gt;These details are easy to treat as API trivia, but they are where the model becomes real. A user does not care that a section is governed by an elegant hydration strategy if the first click disappears. They do not care that the page saved JavaScript if a visible control never wakes up on their browser. Deferred hydration is only a performance feature when its failure modes preserve trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ownership, not laziness
&lt;/h2&gt;

&lt;p&gt;That is why I think the comparison has to be framed around ownership, not around laziness.&lt;/p&gt;

&lt;p&gt;React selective hydration is not "less hydration." It is hydration with better scheduling. It keeps the single-root app model and makes it far less wasteful.&lt;/p&gt;

&lt;p&gt;TanStack Start deferred hydration is not "Suspense but later." It is an admission gate for preserved server HTML inside one React app. It lets the app say: this part is visible now, but client ownership can wait.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@lazarv/react-server&lt;/code&gt; hydration islands are not just "defer this component." They are a way to keep the page root out of the client React app entirely while giving selected subtrees their own client life. That is the RSC-native answer to the discomfort behind the old forever-suspending Suspense idea.&lt;/p&gt;

&lt;p&gt;Once you see the difference, the design question changes. It is no longer "how do we hydrate the page faster?" Sometimes the right answer is to hydrate sooner. Sometimes it is to hydrate later. Sometimes it is to stop making the page root responsible for hydration in the first place.&lt;/p&gt;

&lt;p&gt;The better question is: which part of this page needs client ownership, and when?&lt;/p&gt;

&lt;p&gt;If the page is an application shell, React's selective hydration is the base layer. Use Suspense boundaries, stream what is ready, code split what is heavy, and let React prioritize the interaction path.&lt;/p&gt;

&lt;p&gt;If the page is one app with non-urgent regions, TanStack Start's deferred hydration is the sharper tool. Keep the HTML. Delay the JavaScript. Let visibility, idle time, media, condition, or intent decide when the boundary becomes interactive.&lt;/p&gt;

&lt;p&gt;If the page is mostly server-owned with a few islands of behavior, an RSC island architecture is the cleaner shape. Do not hydrate the root just to support a filter, a menu, or a counter. Give that subtree its own hydrate root and let the surrounding document remain outside React on the client.&lt;/p&gt;

&lt;p&gt;Hydration used to be described as the tax paid after SSR. That was always too blunt. Hydration is not one tax. It is a set of ownership decisions that happen over time. React 18 made those decisions schedulable. TanStack Start made them deferrable inside the root. &lt;code&gt;@lazarv/react-server&lt;/code&gt; makes them separable enough that the root can sometimes stop being a client React root at all.&lt;/p&gt;

&lt;p&gt;The page root was a useful default. It should not be the only unit we are allowed to think with.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What We Lose When Everything Is a Wrapper</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sun, 17 May 2026 20:27:49 +0000</pubDate>
      <link>https://dev.to/lazarv/what-we-lose-when-everything-is-a-wrapper-42e4</link>
      <guid>https://dev.to/lazarv/what-we-lose-when-everything-is-a-wrapper-42e4</guid>
      <description>&lt;p&gt;&lt;em&gt;What a 14-year-old HTML5 Wolfenstein 3D port taught me about owning the stack&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A recent &lt;a href="https://www.youtube.com/watch?v=Ws-Nc9S8i_Y" rel="noopener noreferrer"&gt;Primeagen video&lt;/a&gt; sent me back into an old feeling: the strange discomfort of realizing how much of modern software is now built around things we do not really own.&lt;/p&gt;

&lt;p&gt;Not in the legal sense. In the practical sense.&lt;/p&gt;

&lt;p&gt;We install a package, which installs other packages, which wrap lower-level packages, which hide runtime behavior behind conventions, build steps, plugins, adapters, loaders, and generated code. The result works. Often it works beautifully. But there is a quiet moment, usually during debugging, where the shape of the thing becomes hard to hold in your head. You are no longer asking, "What does my program do?" You are asking, "Which layer is currently doing this on my behalf?"&lt;/p&gt;

&lt;p&gt;That is not always a problem. Abstraction is one of the reasons software can grow at all. Nobody should rewrite a font renderer to build a settings page. Nobody should reimplement TLS because they want an API endpoint. Most dependency-free manifestos eventually collapse into a kind of performance art where the programmer is very proud, very tired, and still missing Unicode.&lt;/p&gt;

&lt;p&gt;This is not that argument.&lt;/p&gt;

&lt;p&gt;This is not an argument against dependencies. It is an argument for knowing when they are helping you build, and when they are quietly becoming the thing you are building around.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Game With No Dependencies
&lt;/h2&gt;

&lt;p&gt;Fourteen years ago, I ported Wolfenstein 3D to HTML5.&lt;/p&gt;

&lt;p&gt;No engine. No framework. No package manager. No third-party runtime dependencies.&lt;/p&gt;

&lt;p&gt;Just JavaScript, canvas, and a very specific obsession.&lt;/p&gt;

&lt;p&gt;There was a production build step, because the web has always had its rituals. The release build went through Google's old Java-based Closure Compiler. But that was packaging, not architecture. It made the output smaller. It did not define the shape of the program.&lt;/p&gt;

&lt;p&gt;That distinction matters. It was not a small raycasting demo inspired by Wolfenstein. It was an actual browser port of the game, built directly on the platform the browser gave me at the time. This is the same idea I was circling around in &lt;a href="https://dev.to/lazarv/the-browser-was-the-engine-58bg"&gt;The Browser Was the Engine&lt;/a&gt;: for that project, the browser was not just a deployment target. It was the engine.&lt;/p&gt;

&lt;p&gt;I am open-sourcing it now, and the thing that feels most surprising in 2026 is not that it still exists. It is that there is almost nothing underneath it that can have gone stale. There is no abandoned package to replace. No transitive dependency tree to audit. The old minifier is a historical detail, not a living dependency graph. No ecosystem era is trapped in amber around it.&lt;/p&gt;

&lt;p&gt;It is just code.&lt;/p&gt;

&lt;p&gt;That sounds smaller than it is. A complete game is not a toy problem. Even a simple raycasting engine needs a loop, input, collision, map parsing, rendering, textures, sprites, timing, state, audio, UI, and all the little bits of glue that turn a technical demo into something that feels like a game. You do not get to skip complexity by avoiding dependencies. You only choose where the complexity lives.&lt;/p&gt;

&lt;p&gt;In that project, the complexity lived in the project.&lt;/p&gt;

&lt;p&gt;That made the code less general. It was not a reusable game engine. It did not have an extension system, a plugin API, a renderer abstraction, or a roadmap. It was not trying to become a platform. It was trying to be one thing.&lt;/p&gt;

&lt;p&gt;That narrowness was the feature.&lt;/p&gt;

&lt;p&gt;The renderer did not need to serve every possible game. The input layer did not need to become an input library. The map representation did not need to model the universe. Every piece of the program could be shaped around the exact problem in front of it. The code had no ambition beyond the game, and because of that, the game could be completely itself.&lt;/p&gt;

&lt;p&gt;Fourteen years later, that still matters.&lt;/p&gt;

&lt;p&gt;This used to be much more normal. On the C64 and the Amiga, on MS-DOS machines, and especially on old consoles from Nintendo, Sega, and the rest of that era, software was often written as a direct answer to a specific machine. A game was not usually a thin layer over a general engine over a general runtime over a general abstraction of hardware. It was a peculiar little organism fitted to the memory layout, graphics modes, timing behavior, controller shape, and limits of the target. The constraints were brutal, but they made the program concrete. You could feel the machine in the code.&lt;/p&gt;

&lt;p&gt;I do not want to pretend that era was better in every way. It was harder, narrower, less portable, and full of tricks nobody should be forced to rediscover for ordinary application work. But it had one quality that is easy to miss now: the program and the platform had an intimate relationship. The software did not float above the machine. It negotiated with it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shelf Life of Small Surfaces
&lt;/h2&gt;

&lt;p&gt;Software ages in strange ways.&lt;/p&gt;

&lt;p&gt;Sometimes code ages because the problem changes. Sometimes it ages because the platform changes. But a huge amount of modern code ages because the stack around it moves.&lt;/p&gt;

&lt;p&gt;The package manager changes. The bundler changes. The framework changes. The framework's compiler changes. The plugin ecosystem changes. The lockfile format changes. The version of Node you need becomes a small archaeological site. You do not just maintain the application. You maintain the ability to assemble the application.&lt;/p&gt;

&lt;p&gt;There is a real cost hidden there.&lt;/p&gt;

&lt;p&gt;Dependencies do not only add code. They add motion. They add release notes, security advisories, migration guides, incompatible peer ranges, deprecated APIs, and small future obligations that are easy to accept because they arrive wrapped in convenience.&lt;/p&gt;

&lt;p&gt;That convenience is often worth it. But it is not free.&lt;/p&gt;

&lt;p&gt;The dependency-free Wolfenstein source has a different aging profile. It depends on the browser, of course. Every web program does. The production build once depended on Closure Compiler. But the game itself does not depend on a cultural moment in the JavaScript ecosystem. The old build step can be replaced, skipped, or recreated because it was only an output step. The source does not need a 2012 toolchain to remember how to be itself. Its surface area is small enough that the future had fewer places to break it.&lt;/p&gt;

&lt;p&gt;This is one of the underrated virtues of writing something directly: you reduce the number of external clocks your project has to keep time with.&lt;/p&gt;

&lt;p&gt;Every dependency is a clock. Some tick slowly and responsibly. Some tick chaotically. Some stop. Some are taken over by someone else. Some are still correct but no longer fashionable. Some become infrastructure so deeply embedded that nobody remembers choosing them.&lt;/p&gt;

&lt;p&gt;The fewer clocks you attach to a project, the longer it can sit quietly without turning into an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generality Has a Price
&lt;/h2&gt;

&lt;p&gt;A dependency is usually a package of generality.&lt;/p&gt;

&lt;p&gt;That is its value. The author solved a problem broadly enough that you can reuse the solution in a context they did not know about. A good library is a small miracle of compression: years of thought hidden behind a function call.&lt;/p&gt;

&lt;p&gt;But generality has a shape, and that shape enters your program.&lt;/p&gt;

&lt;p&gt;If you use a game engine, your game starts to inherit the engine's idea of a game. If you use a framework, your application inherits the framework's idea of an application. If you use a router, a state manager, an ORM, a component library, a testing framework, and a build system, your program becomes partly an expression of all their assumptions.&lt;/p&gt;

&lt;p&gt;Again, this can be good. Shared assumptions are how teams move. Conventions are how projects become legible. A framework can save you from wasting your life on plumbing that has nothing to do with the thing you are trying to build.&lt;/p&gt;

&lt;p&gt;But every general-purpose layer also asks you to pay for cases that are not yours.&lt;/p&gt;

&lt;p&gt;Sometimes you pay in bytes. Sometimes in indirection. Sometimes in concepts. Sometimes in the strange helplessness of reading code that looks simple while knowing that the real behavior lives somewhere else.&lt;/p&gt;

&lt;p&gt;This is the wrapper feeling.&lt;/p&gt;

&lt;p&gt;Not abstraction itself. Abstraction is a tool. The wrapper feeling is what happens when the abstraction no longer disappears. It stays visible. It stands between you and the material. You can feel yourself programming the wrapper's model of the problem instead of the problem.&lt;/p&gt;

&lt;p&gt;At that point, the dependency may still be doing useful work, but it has also become a design constraint. You are building with it, but you are also building around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Good Kind of Dependency
&lt;/h2&gt;

&lt;p&gt;I should be careful here, because I build dependencies too.&lt;/p&gt;

&lt;p&gt;I work on &lt;a href="https://react-server.dev" rel="noopener noreferrer"&gt;react-server&lt;/a&gt;. At Level 0x40 Labs, I build open-source tools and primitives, including things like &lt;a href="https://virtual-frame.level0x40.com/" rel="noopener noreferrer"&gt;virtual-frame&lt;/a&gt;. Some of my work is explicitly about making reusable layers for other programs. I care about composable runtimes, shared primitives, and the boring miracle of code that lets other code exist more easily.&lt;/p&gt;

&lt;p&gt;So no, I do not believe the purest program is the one that imports nothing.&lt;/p&gt;

&lt;p&gt;The good dependency does something more interesting than save keystrokes. It gives you a stable primitive. It makes a hard boundary understandable. It removes accidental complexity without stealing the shape of the thing you are building.&lt;/p&gt;

&lt;p&gt;The best dependencies feel less like wrappers and more like materials.&lt;/p&gt;

&lt;p&gt;Canvas is a material. SQLite is a material. A focused parser can be a material. A well-designed runtime primitive can be a material. You can build with them while still feeling that the program is yours.&lt;/p&gt;

&lt;p&gt;The dangerous dependency is different. It does not merely provide capability. It brings a worldview. It decides the architecture, the vocabulary, the lifecycle, the extension points, the error model, the deployment model, and the kinds of solutions that feel natural. You can still build something good inside it, but you are no longer only building your thing. You are also participating in its theory of software.&lt;/p&gt;

&lt;p&gt;That may be exactly what you want.&lt;/p&gt;

&lt;p&gt;If I am building a product with a team, I want shared machinery. I want boring defaults. I want the dependency graph to absorb problems that are not central to the product. I want the project to move because not every layer deserves to be handmade.&lt;/p&gt;

&lt;p&gt;But if I am building the core mechanic of a game, or the central abstraction of a runtime, or the part of a system that defines what the system is, I want a different relationship to the code.&lt;/p&gt;

&lt;p&gt;I want ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ownership Is a Design Constraint
&lt;/h2&gt;

&lt;p&gt;Ownership is not the same as authorship.&lt;/p&gt;

&lt;p&gt;You can write every line of a program and still not own it, because the design is copied from a framework you are no longer using. You can also depend on a library and still own your system, because the boundary is clear and the library is just a tool in your hand.&lt;/p&gt;

&lt;p&gt;Ownership means you understand the important consequences of the system's shape.&lt;/p&gt;

&lt;p&gt;You know where time is spent. You know where state lives. You know what happens when input arrives. You know which layer can fail and how failure moves. You know the constraints because you chose them, or at least because you have looked at them directly enough to accept them.&lt;/p&gt;

&lt;p&gt;This is what dependency-heavy development can erode. Not skill in the macho sense. Not the ability to write a linked list on a whiteboard. Something more practical: the habit of tracing behavior all the way down until the system becomes concrete again.&lt;/p&gt;

&lt;p&gt;When everything is a wrapper, the bottom keeps moving away.&lt;/p&gt;

&lt;p&gt;You learn the public API. Then the plugin API. Then the framework convention. Then the generated output. Then the bundler behavior. Then the runtime behavior. Every layer may be reasonable on its own, but the stack as a whole can become a place where nobody quite knows what is happening. The program works because the ecosystem works. Until it does not.&lt;/p&gt;

&lt;p&gt;The cost is not only reliability. It is imagination.&lt;/p&gt;

&lt;p&gt;If you only build inside other people's abstractions, you start to think in the shapes they make convenient. Your ideas arrive pre-filtered by the tools. You ask, "How do I do this in this framework?" before you ask, "What is this thing, actually?"&lt;/p&gt;

&lt;p&gt;That question matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Game
&lt;/h2&gt;

&lt;p&gt;The game I am working on now also uses no third-party code.&lt;/p&gt;

&lt;p&gt;Not because I have become allergic to packages. Not because I think every line must be sacredly handcrafted. It is simpler than that: the game has a specific shape, and a purpose-built implementation fits that shape better than a general engine would.&lt;/p&gt;

&lt;p&gt;A game engine would give me a thousand solved problems. Some of those solutions would be useful. Some would be irrelevant. Some would slowly bend the game toward the engine's strengths. I would move faster at the beginning, and then spend part of that saved time negotiating with decisions made for other games.&lt;/p&gt;

&lt;p&gt;For this project, that trade does not feel right.&lt;/p&gt;

&lt;p&gt;I want the renderer to know exactly what this game is. I want the update loop to carry exactly the concepts the game needs. I want the asset pipeline, the state model, and the interaction rules to be small enough that they can stay in my head. I want the implementation to be shaped by the game, not by the possibility space of all games.&lt;/p&gt;

&lt;p&gt;That is not a universal recommendation. It is a local one.&lt;/p&gt;

&lt;p&gt;The mistake is treating dependency decisions as identity. Dependency-free does not mean serious. Dependency-heavy does not mean sloppy. The useful question is always more specific:&lt;/p&gt;

&lt;p&gt;What part of this system needs to be mine?&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Layer
&lt;/h2&gt;

&lt;p&gt;There are layers where depending on other people is a sign of maturity.&lt;/p&gt;

&lt;p&gt;Cryptography. Databases. Protocol implementations. Image codecs. Font shaping. Accessibility primitives. Hard-won platform knowledge that would be arrogant or irresponsible to casually recreate.&lt;/p&gt;

&lt;p&gt;There are layers where depending on other people is mostly a business decision.&lt;/p&gt;

&lt;p&gt;Admin UI, routing, deployment glue, test runners, analytics, documentation tooling, forms, dashboards, the parts of a product whose correctness matters but whose originality does not.&lt;/p&gt;

&lt;p&gt;And there are layers where depending too early can cost you the thing you are trying to discover.&lt;/p&gt;

&lt;p&gt;The core interaction of a game. The execution model of a runtime. The data model of a creative tool. The editor behavior of a text surface. The small strange thing that made the project worth building in the first place.&lt;/p&gt;

&lt;p&gt;Those layers deserve suspicion. Not because dependencies are bad, but because premature generality can erase the local shape before you have understood it.&lt;/p&gt;

&lt;p&gt;Sometimes the right move is to build the first version directly. Learn the material. Discover the constraints. Let the project teach you what abstractions it actually wants. Then, if a dependency fits, you will know where it fits. You will be choosing it from knowledge rather than reaching for it from reflex.&lt;/p&gt;

&lt;p&gt;That difference is everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Muscle
&lt;/h2&gt;

&lt;p&gt;The reason I still care about from-scratch projects is not nostalgia.&lt;/p&gt;

&lt;p&gt;It is maintenance of a muscle.&lt;/p&gt;

&lt;p&gt;The muscle is the ability to look at a problem without immediately asking which package owns it. The ability to draw the smaller machine inside the larger machine. The ability to build a narrow implementation without apologizing for its lack of generality. The ability to recognize when a dependency is a material, when it is a tool, and when it is slowly becoming the architecture.&lt;/p&gt;

&lt;p&gt;Modern software needs dependencies. It also needs people who remember what the layers are made of.&lt;/p&gt;

&lt;p&gt;That is what the old Wolfenstein project gives me now. It is not impressive because it avoided packages. It is not morally superior because the dependency list is empty. It is useful because it is a reminder that a complete thing can still be built as a complete thing.&lt;/p&gt;

&lt;p&gt;No engine. No framework. No package graph.&lt;/p&gt;

&lt;p&gt;A game.&lt;/p&gt;

&lt;p&gt;That possibility is worth keeping alive.&lt;/p&gt;

&lt;p&gt;Not for every project. Not for every layer. Not as a purity test.&lt;/p&gt;

&lt;p&gt;Because sometimes the only way to know what you are building is to build enough of it yourself that it can answer back.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>javascript</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>The Browser Was the Engine</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sun, 17 May 2026 19:57:20 +0000</pubDate>
      <link>https://dev.to/lazarv/the-browser-was-the-engine-58bg</link>
      <guid>https://dev.to/lazarv/the-browser-was-the-engine-58bg</guid>
      <description>&lt;p&gt;&lt;em&gt;How a Wolfenstein 3D HTML5 port worked in April-May 2012&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In April-May 2012, I ported Wolfenstein 3D to HTML5. The port is still available at &lt;a href="https://wolf3d.wadcmd.com/" rel="noopener noreferrer"&gt;wolf3d.wadcmd.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Not as a small raycasting demo. Not as a framework exercise. Not as a canvas toy that borrowed the feeling of the original. It was a browser port built from the platform that existed at the time: JavaScript, HTML, CSS, Canvas, XHR, audio elements, a little localStorage, and the patience to make those parts behave like a game.&lt;/p&gt;

&lt;p&gt;There was no engine underneath it.&lt;/p&gt;

&lt;p&gt;That sentence sounds simple now, but it is the most important architectural fact about the project. No game engine meant there was no inherited world model. No package manager meant there was no dependency graph quietly deciding what the program wanted to become. No framework meant there was no lifecycle to adapt to, no plugin interface to satisfy, no build-time abstraction standing between the game and the browser.&lt;/p&gt;

&lt;p&gt;The browser was the engine.&lt;/p&gt;

&lt;p&gt;That did not make the implementation easier. A complete game does not become simple because you avoid dependencies. You still need a renderer, resource loading, maps, collision, enemies, animation, input, sound, save games, menus, HUD, transitions, and timing. The complexity does not disappear. It moves closer.&lt;/p&gt;

&lt;p&gt;In this port, that was the point. Every piece of the implementation could be shaped around one problem: make Wolfenstein 3D run in the browser that existed in 2012.&lt;/p&gt;

&lt;p&gt;Internet Explorer 9 mattered. Chrome and Firefox mattered. Current browsers still run it today, but the architecture was formed by the constraints of that earlier browser landscape. Canvas 2D was the stable rendering target. Audio was fragmented. JavaScript engines were fast, but not generous enough to waste work casually. WebGL could not be the foundation if the goal was broad compatibility. The project had to be static, direct, and careful.&lt;/p&gt;

&lt;p&gt;So it became a game made of ordinary browser parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before Browser Ports Felt Normal
&lt;/h2&gt;

&lt;p&gt;It is easy to forget how unusual this felt at the time.&lt;/p&gt;

&lt;p&gt;Today, seeing an old game run in a browser is not surprising. We have WebAssembly, mature WebGL, better audio APIs, faster JavaScript engines, and a decade of examples proving that the browser can host serious ports. In 2012, that expectation was not really there yet.&lt;/p&gt;

&lt;p&gt;Classic game ports to the browser were not a common thing in my memory. I remember a great Lemmings port from that period, but not a long list of comparable projects. The browser was becoming capable, but the idea that you could take the structure of an old game and make it feel at home in HTML5 still had a little bit of improbability around it.&lt;/p&gt;

&lt;p&gt;That was part of the attraction.&lt;/p&gt;

&lt;p&gt;The question was not only "can I draw a raycaster?" The question was whether the browser could carry the whole feeling of a game: the menus, the timing, the input, the sound, the little transitions, the data loading, the save slots, the sense that you were not looking at a web demo but playing something complete.&lt;/p&gt;

&lt;p&gt;That question stayed with me after this port. Since then I have also ported DOOM and Another World, partially ported other smaller games, and I have been occasionally working for years on a Morrowind game port. The projects are different, but the underlying fascination is the same: what does it take to move an existing game into the browser without losing the thing that made it feel like itself?&lt;/p&gt;

&lt;p&gt;Wolfenstein 3D was the project where I first answered that question seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static Files as an Architecture
&lt;/h2&gt;

&lt;p&gt;The entry point is just &lt;code&gt;index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It loads a stylesheet and then a list of JavaScript files with script tags. The order of those tags is the dependency graph. There is no module loader to explain, no bundler contract, no runtime manifest. The files appear in the page in the order the program needs them.&lt;/p&gt;

&lt;p&gt;That may look old-fashioned now, but it was also liberating. The deployment model was almost impossible to simplify further: serve the folder and open the page. The application did not need a server-side process to define itself. It did not need a compilation step before it could be understood. A browser could load it because the browser already knew the primitives it was made from.&lt;/p&gt;

&lt;p&gt;The code is organized around global subsystem objects: &lt;code&gt;game&lt;/code&gt;, &lt;code&gt;raycast&lt;/code&gt;, &lt;code&gt;maps&lt;/code&gt;, &lt;code&gt;resources&lt;/code&gt;, &lt;code&gt;player&lt;/code&gt;, &lt;code&gt;ai&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;, &lt;code&gt;controller&lt;/code&gt;, and a few smaller systems around animation, doors, secrets, scripts, fonts, and menus. Each file adds a part to the whole.&lt;/p&gt;

&lt;p&gt;The pattern is plain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;game&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;newGame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;){&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today, we would usually reach for imports. In 2012, for a static browser game targeting IE9, this was the smallest reliable module system available: objects, files, and load order. It was not elegant in the modern packaging sense, but it was concrete. You could read the HTML and know what the program was made of.&lt;/p&gt;

&lt;p&gt;That concreteness shows up everywhere else.&lt;/p&gt;

&lt;p&gt;Screens are not generated by a UI framework. They are small HTML fragments under &lt;code&gt;html/&lt;/code&gt;. When the game needs the menu, the difficulty screen, the game view, the high score table, the sound settings, or a message dialog, it loads the fragment with &lt;code&gt;XMLHttpRequest&lt;/code&gt;, puts it into the document, converts its text into the game’s bitmap-style text, and fades it in.&lt;/p&gt;

&lt;p&gt;The game shell is therefore static, but not monolithic. It is a set of browser-native pieces, loaded only when the game asks for them.&lt;/p&gt;

&lt;p&gt;That is the kind of architecture that comes from trusting the platform directly. There is no router. There is no component tree. There is no templating runtime. There is an HTML file, a request, a DOM node, and a screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The View Is Layered, Not Abstracted
&lt;/h2&gt;

&lt;p&gt;The game screen is a stack of canvases and DOM elements.&lt;/p&gt;

&lt;p&gt;At the back there is a canvas for the background. Above it there is the main view, where the raycaster draws walls and sprites. Above that is the weapon hand. Another canvas handles the fizzle-fade mask. A transparent control layer catches touch and mouse input. The HUD sits at the front with small canvases for score, floor, lives, face, health, ammo, keys, and weapon state.&lt;/p&gt;

&lt;p&gt;This is not a general renderer. It is a layout for this game.&lt;/p&gt;

&lt;p&gt;The separation matters because not every part of the screen changes for the same reason. The 3D view changes every frame. The HUD changes when a value changes. The weapon canvas changes when the weapon animation advances. The mask changes during transitions. By giving those concerns their own surfaces, the code avoids pretending that the whole screen is one thing.&lt;/p&gt;

&lt;p&gt;That is a recurring theme in the port. There are abstractions, but they are local. They exist where the game has repeated behavior, not where a future engine might want an extension point.&lt;/p&gt;

&lt;p&gt;The raycaster does not know about menus. The HUD does not know about wall rendering. The mask does not know about enemies. The DOM can still be used for layout where layout is the problem, and Canvas can be used where pixels are the problem.&lt;/p&gt;

&lt;p&gt;This was one of the advantages of writing directly against the browser. The browser already had multiple materials. HTML was good for screens and menus. CSS was good for layering and transitions. Canvas was good for the game view. There was no need to force all of it through one abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning Images Into Game Data
&lt;/h2&gt;

&lt;p&gt;The resource loader is small but important.&lt;/p&gt;

&lt;p&gt;Wolfenstein-style rendering wants textures in predictable square cells. The port uses sprite sheets for walls and objects, then cuts them into 128-by-128 images at load time. An offscreen canvas draws one cell from the sheet, turns it into a data URL, and stores it in a lookup table.&lt;/p&gt;

&lt;p&gt;After that, the renderer can ask for things in the shape it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;walls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;textureIndex&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sprites&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;itemIndex&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;frameIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That preprocessing step keeps the inner loop simple. The renderer does not want to think about sprite sheet layout every time it draws a wall column. It wants a texture image, a texture coordinate, and a destination column.&lt;/p&gt;

&lt;p&gt;This is one of those small decisions that tells you what kind of program it is. The code does not build a generic asset pipeline. It prepares the assets into the exact form the renderer wants. Work that can happen once at startup is moved out of the frame loop. The frame loop stays narrow.&lt;/p&gt;

&lt;p&gt;Again, the implementation is direct: generate the pixels, cache the pixels, put the pixels on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bitmap Font Rendering
&lt;/h2&gt;

&lt;p&gt;The text rendering deserves its own place because it is one of those details that makes the port feel like a game instead of a web page.&lt;/p&gt;

&lt;p&gt;The menus and HUD could not simply use browser fonts. A browser font would have been easy, but it would also have been wrong in a very visible way. Font rendering differed between platforms, antialiasing differed between browsers, and the original game had a very specific bitmap character. If the menus used normal DOM text, the illusion would break before the player even reached the first level.&lt;/p&gt;

&lt;p&gt;So the port renders text as images.&lt;/p&gt;

&lt;p&gt;There are two paths in the font system. &lt;code&gt;font.BitmapFont&lt;/code&gt; uses a bitmap image that contains glyphs laid out horizontally. A small map tells the renderer where each character begins and how wide it is. When text is needed, the font renderer draws the matching glyph slices into an offscreen canvas, then turns that canvas into a data URL and inserts it into the DOM as an image.&lt;/p&gt;

&lt;p&gt;The other path, &lt;code&gt;font.Font&lt;/code&gt;, is even more direct. The small and large menu fonts are stored as compact bitmap data in JavaScript. Each glyph is a set of integer rows. Rendering a character means walking those bits, painting colored pixels into an &lt;code&gt;ImageData&lt;/code&gt; buffer, and writing the result to the offscreen canvas.&lt;/p&gt;

&lt;p&gt;That gives the code several useful properties.&lt;/p&gt;

&lt;p&gt;Text can be colored without needing separate image files for every color. The same glyph data can become grey menu text, selected menu text, disabled text, read-this text, or high score text. It also means the game can process ordinary HTML fragments after loading them. A screen can contain readable text in the source, and &lt;code&gt;game.processFont()&lt;/code&gt; replaces that text with generated bitmap images when the screen enters the game.&lt;/p&gt;

&lt;p&gt;The renderer also caches the result by font and text value. Once a phrase has been turned into pixels, the same generated image can be reused. That matters because menus redraw and update, but the words themselves usually do not change. The port pays the text-rendering cost when it has to, not every frame.&lt;/p&gt;

&lt;p&gt;It is a small system, but it captures the spirit of the whole port. The browser is still doing the work. There is still no external text rendering library. But the program refuses to let the browser's default presentation leak into the game. It takes the platform primitive, Canvas, and uses it to produce the exact kind of pixels the game needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the Game Data Into Browser Shape
&lt;/h2&gt;

&lt;p&gt;The browser port also needed the original game data in a form the JavaScript runtime could use.&lt;/p&gt;

&lt;p&gt;That part did not happen by hand. The assets were converted from a Wolfenstein 3D editor format using a custom C# converter utility. The converter extracted the images and maps and turned them into browser-friendly files: PNG sprite sheets and JavaScript map data that the static app could load directly.&lt;/p&gt;

&lt;p&gt;This was an important part of keeping the runtime simple. The browser code did not need to understand the original game data formats. It did not need a binary parser for every source file. It did not need to do expensive conversion work during startup. By the time the browser saw the assets, they were already shaped for the port.&lt;/p&gt;

&lt;p&gt;Images became image files the browser could load normally. Wall textures and sprites became sheets the resource loader could slice into 128-by-128 cells. Maps became JavaScript files that populated &lt;code&gt;wolf3d.maps&lt;/code&gt;, which meant a level could be loaded with a simple XHR request and evaluated into the same data structures the engine already expected.&lt;/p&gt;

&lt;p&gt;That split made the architecture cleaner. The C# converter was an offline tool. The browser runtime was the game. The conversion step absorbed the awkwardness of extraction, while the game stayed static and dependency-free.&lt;/p&gt;

&lt;p&gt;The open-source repository intentionally does not include those extracted game assets. The point of mentioning the converter is architectural: the port was not loading original game files directly in the browser. It used an offline conversion step to transform the data into web-native assets, then kept the runtime focused on playing the game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Raycaster at the Center
&lt;/h2&gt;

&lt;p&gt;The heart of the port is the raycaster.&lt;/p&gt;

&lt;p&gt;Wolfenstein 3D is built on a grid. The world is not arbitrary polygons. It is cells: empty space, walls, doors, secrets, objects, enemies. That makes it a perfect fit for a column renderer. For every vertical column of the screen, the engine casts a ray from the player into the grid, walks cell by cell until it hits something solid, computes the distance, and draws one vertical slice of texture.&lt;/p&gt;

&lt;p&gt;The camera is just two vectors: the direction the player is facing, and the camera plane used to spread rays across the screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;cameraX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rayDirectionX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cameraX&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rayDirectionY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;cameraX&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the beautiful thing about this kind of renderer. Once the math is in place, the world becomes very cheap to describe. A wall is a number in a map array. A ray walks the array. A texture slice becomes a one-pixel crop stretched vertically with &lt;code&gt;drawImage()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The renderer does this hundreds of times per frame, once per screen column. It also records the distance of each wall hit in a z-buffer. That z-buffer becomes the truth for everything drawn after the walls. Sprites are projected into screen space, sorted from far to near, and each visible stripe is tested against the wall distance for that column.&lt;/p&gt;

&lt;p&gt;This is how enemies disappear behind walls. It is also how weapons know what they are aiming at.&lt;/p&gt;

&lt;p&gt;When an enemy sprite is visible in a column, the renderer stores that enemy in the player’s &lt;code&gt;fireBuffer&lt;/code&gt; for that stripe. A hitscan weapon does not need a separate picking engine. It looks near the center of the screen, checks the same visibility information the renderer already computed, and damages the first visible target.&lt;/p&gt;

&lt;p&gt;That reuse is one of my favorite parts of the implementation. The renderer is not only drawing. It is producing spatial knowledge. The weapon system uses that knowledge instead of inventing a parallel world.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doors Are Not Walls
&lt;/h2&gt;

&lt;p&gt;In a simple raycaster, a wall is just the first solid cell the ray hits.&lt;/p&gt;

&lt;p&gt;Wolfenstein doors complicate that. A door is visually inside a grid cell, but it slides open. It can be partly open, fully open, closing, blocked by the player, blocked by an enemy, or locked behind a key. Treating it as a normal wall would be too crude.&lt;/p&gt;

&lt;p&gt;The port gives doors their own state: position, texture, slide amount, action, delay, and optional key. During raycasting, doors encountered before the final wall hit are kept in a buffer. If a door is closer than the wall and its slide amount still blocks the ray, the renderer draws the visible door slice and writes that distance into the z-buffer.&lt;/p&gt;

&lt;p&gt;The door has not moved through the map. Its texture has moved inside the cell.&lt;/p&gt;

&lt;p&gt;Secret walls are the opposite. They really move through the map. When pushed, the starting cell becomes empty. The secret wall advances by a fractional state until it crosses into the next cell, then continues until it has moved two cells or is blocked. The map keeps separate secret-wall state so collision and rendering can both understand the wall while it is in motion.&lt;/p&gt;

&lt;p&gt;This is where a lot of game code lives: not in grand abstractions, but in the difference between two things that look similar until you implement them. A door and a secret wall are both moving obstructions. Architecturally, they are not the same thing at all.&lt;/p&gt;

&lt;p&gt;The implementation lets them be different.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Main Loop Is a Set of Small Clocks
&lt;/h2&gt;

&lt;p&gt;The game loop is driven by &lt;code&gt;requestAnimationFrame&lt;/code&gt;, with vendor-prefixed fallbacks and finally &lt;code&gt;setTimeout&lt;/code&gt;. The target tick is roughly 33 updates per second. Each frame calculates how much time has passed and turns that into a capped &lt;code&gt;timeFactor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cap matters. If the browser stalls for a moment, the game should not respond by letting a guard sprint through the map or a door jump through its entire animation. Time compensation is useful only until it becomes a bug.&lt;/p&gt;

&lt;p&gt;Inside the frame, the code updates the world in a straightforward order. Doors process. Secret walls process. Animations process. AI processes. One-frame script events are cleared. Positional audio events are played at a volume based on distance. Controls are processed. The player HUD updates. The raycaster renders.&lt;/p&gt;

&lt;p&gt;There is no big scheduler.&lt;/p&gt;

&lt;p&gt;There are arrays:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;doors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;
&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;
&lt;span class="nx"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;
&lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;
&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a door is moving, it is in &lt;code&gt;doors.process&lt;/code&gt;. If an animation is active, it is in &lt;code&gt;animation.process&lt;/code&gt;. If a sound should be played from a map position, it is queued in &lt;code&gt;audio.process&lt;/code&gt; until the frame loop turns distance into volume.&lt;/p&gt;

&lt;p&gt;This is the kind of simplicity that is easy to underestimate. A more general engine might have a unified entity lifecycle. This port does not need one. A door is not an enemy. A sound event is not a sprite. An animation is not a map script. Each small system gets the loop it needs.&lt;/p&gt;

&lt;p&gt;That does not make the game less complete. It makes the completeness local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enemies Are Sprites With Intent
&lt;/h2&gt;

&lt;p&gt;The enemy system starts from sprites and adds intent.&lt;/p&gt;

&lt;p&gt;An enemy has a position, a texture resource, an animation set, hit points, a direction, a current action, and a process method. The current action is just a function reference: ready, moving, attack, fire, dying, dead, or a specialized behavior for a boss or projectile.&lt;/p&gt;

&lt;p&gt;That turns each enemy into a small state machine.&lt;/p&gt;

&lt;p&gt;The shared base handles the things every enemy needs: calculating which sprite direction should face the player, testing line of sight, choosing movement directions, walking through the grid, opening doors, taking damage, and advancing the current action.&lt;/p&gt;

&lt;p&gt;The visibility check is not magic. It walks a line through the grid between enemy and player. If a wall or a closed door blocks the line, the player is not visible. If the enemy sees the player, or hears activity in the same floor area, it can wake up and begin attacking.&lt;/p&gt;

&lt;p&gt;Movement is similarly local. Enemies choose chase, dodge, or run directions from nearby cells. They avoid blocked tiles, avoid reversing direction when appropriate, consider doors, and use a bit of randomness. It is not a general pathfinding library. It is the kind of movement model a Wolfenstein 3D enemy needs.&lt;/p&gt;

&lt;p&gt;That is the point. The AI code is not trying to be generally intelligent. It is trying to create the pressure the game needs: guards noticing you, moving through corridors, opening doors, firing when visible, reacting to damage, and becoming part of the scene the raycaster knows how to draw.&lt;/p&gt;

&lt;p&gt;The enemy is both a visual thing and a behavioral thing. It is a sprite with intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Input Is State, Not Events
&lt;/h2&gt;

&lt;p&gt;Keyboard input is translated into game actions and stored as active or inactive key state.&lt;/p&gt;

&lt;p&gt;That distinction matters because games do not usually want to respond to a key event once. They want to know what is currently true. Is the player holding forward? Is run active? Is strafe active? Was fire released? Should left/right mean rotation, or should they mean strafing because Alt is down?&lt;/p&gt;

&lt;p&gt;The controller maps key codes to named actions, then the game loop processes those actions. Some actions are continuous. Some are single-fire. Some cancel each other. Some have release behavior.&lt;/p&gt;

&lt;p&gt;Touch input uses the same idea. The game view contains a transparent table of control regions. Entering or touching a region activates actions like forward, left, right, or combinations. The input surface is crude, but it has one important property: it feeds the same controller state as the keyboard.&lt;/p&gt;

&lt;p&gt;The rest of the game does not need to care where the input came from.&lt;/p&gt;

&lt;p&gt;That is the right kind of abstraction. It does not invent a framework. It simply normalizes the one fact the game needs: which actions are active right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio Had to Be Pragmatic
&lt;/h2&gt;

&lt;p&gt;Browser audio in 2012 was not one clean API.&lt;/p&gt;

&lt;p&gt;The port uses WebKit AudioContext when it is available. In that path, sound effects are requested as array buffers and decoded into audio buffers. If that API is not available, the code falls back to HTML audio elements and checks whether the browser can play MP4 or OGG.&lt;/p&gt;

&lt;p&gt;Music is handled as a looping audio element with multiple sources. Sound effects are loaded lazily and cached. Positional sound is deliberately simple: game systems queue a sound with map coordinates, and the main loop compares that position to the player. The farther away the event is, the lower the volume.&lt;/p&gt;

&lt;p&gt;That is enough.&lt;/p&gt;

&lt;p&gt;The game does not need a full audio engine. It needs doors to sound like they are nearby or far away. It needs enemies to make noise from their position. It needs music to loop. It needs guns, pickups, doors, secrets, and death sounds to happen at the right time.&lt;/p&gt;

&lt;p&gt;The implementation solves exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  State Lives in the Browser
&lt;/h2&gt;

&lt;p&gt;A static game still needs persistence.&lt;/p&gt;

&lt;p&gt;The port uses &lt;code&gt;localStorage&lt;/code&gt; for settings, controls, graphics detail, sound volume, music volume, save slots, highscores, and zoom state. Save games serialize enough information to reconstruct the level: map name, difficulty, player position and direction, health, ammo, lives, score, keys, weapons, map and hit state, sprites removed or added, secrets, doors, and other level progress.&lt;/p&gt;

&lt;p&gt;That choice preserved the static deployment model. No backend. No account. No database. No server session. The browser already had a small persistence layer, and the game used it.&lt;/p&gt;

&lt;p&gt;This is another place where directness helped. The storage format did not have to serve multiple clients, sync across devices, support migrations for a live service, or survive hostile input. It had to remember a local game. The shape of the solution matched the shape of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why No Dependencies Worked
&lt;/h2&gt;

&lt;p&gt;No dependencies worked because the project was narrow.&lt;/p&gt;

&lt;p&gt;It was not trying to create a reusable game engine. It was not trying to support arbitrary maps from arbitrary games. It was not trying to become a framework for browser shooters. It was trying to port one game to the browser, with enough fidelity that the browser disappeared and the game remained.&lt;/p&gt;

&lt;p&gt;That narrowness made the absence of dependencies practical.&lt;/p&gt;

&lt;p&gt;Canvas handled pixels. XHR handled files. HTML handled screens. CSS handled layout and fades. Audio APIs handled sound. localStorage handled persistence. JavaScript objects handled systems. The missing part was not a library; it was the code that made those browser materials behave like Wolfenstein 3D.&lt;/p&gt;

&lt;p&gt;This is the line I still find important.&lt;/p&gt;

&lt;p&gt;Dependencies are often useful when they provide a material or a stable primitive. But the core shape of a game is not a generic problem. The renderer, loop, collision, enemy behavior, resource model, and input feel define the game. If those layers come from a general engine too early, the game begins by negotiating with someone else’s idea of what a game should be.&lt;/p&gt;

&lt;p&gt;Here, the negotiation was with the browser itself.&lt;/p&gt;

&lt;p&gt;That is a very different relationship. The browser gives you primitives, not a game theory. Canvas does not tell you what an enemy is. XHR does not tell you what a map is. localStorage does not tell you how saving should work. Those decisions remain yours.&lt;/p&gt;

&lt;p&gt;The port feels old in some ways because it belongs to its time. But it also feels unusually durable because there is so little around it that can expire. There is no abandoned framework version trapped underneath it. There is no engine upgrade path to follow. There is no transitive dependency tree to audit before the source can be understood.&lt;/p&gt;

&lt;p&gt;It is just the program and the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Preserve
&lt;/h2&gt;

&lt;p&gt;If I were polishing the repository today, I would not rewrite it into modern JavaScript.&lt;/p&gt;

&lt;p&gt;That would make the code look more familiar to current readers, but it would also remove part of what makes the project valuable. The source shows how a complete browser game was structured when the platform had become capable enough, but before the modern frontend stack became the default answer to every problem.&lt;/p&gt;

&lt;p&gt;The globals matter. The script order matters. The direct XHR matters. The vendor-prefixed &lt;code&gt;requestAnimationFrame&lt;/code&gt; fallback matters. The audio fallback matters. The little local process arrays matter. They show the shape of the browser as it was, and the shape of a program built close to it.&lt;/p&gt;

&lt;p&gt;What I would add is explanation around the code, not a new identity inside the code.&lt;/p&gt;

&lt;p&gt;Because the interesting thing is not that the implementation is perfect. It is not. The interesting thing is that the game is complete, understandable, and still runnable as static files. It owns its important layers. It can be opened without resurrecting a vanished ecosystem.&lt;/p&gt;

&lt;p&gt;That is rare now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Use Today
&lt;/h2&gt;

&lt;p&gt;If I were building the same port from scratch today, I would not reproduce the 2012 structure exactly.&lt;/p&gt;

&lt;p&gt;I would still keep the project small. I would still avoid a game engine. I would still avoid a framework. The core of the game would remain mine: the raycaster, the loop, the map representation, the resource model, the input layer, the enemy behavior, the save format. Those are the parts that define the game, and I would still want them shaped by the game rather than by a general-purpose engine.&lt;/p&gt;

&lt;p&gt;But I would use modern packaging where it helps without changing the architecture.&lt;/p&gt;

&lt;p&gt;I would use Vite. Not because the game needs a framework, but because Vite is a good way to run a local development server, use native-style imports, and produce a clean static output. The important distinction is that Vite would be tooling around the source, not the architecture inside the source. The game should still be understandable as browser code. The build tool should make development smoother, not become the thing the program is written for.&lt;/p&gt;

&lt;p&gt;I would use imported modules instead of global objects and script order. In 2012, script tags were the simplest cross-browser module system available for this target. Today, &lt;code&gt;import&lt;/code&gt; is the obvious way to make dependencies explicit. The same subsystems would probably still exist: renderer, resources, maps, player, AI, audio, input, screens. They would just be connected by imports instead of shared globals.&lt;/p&gt;

&lt;p&gt;I would also use a formatter and a linter.&lt;/p&gt;

&lt;p&gt;That was not really part of the workflow when this port was written. The code carries the habits and unevenness of that time: manual formatting, local conventions, and whatever discipline I brought to each file by hand. Today I would not rely on that. A formatter would remove style decisions from the work, and a linter would catch the boring mistakes before they became debugging sessions. That kind of tooling would not change the architecture of the game. It would simply make the source easier to maintain.&lt;/p&gt;

&lt;p&gt;I would not use TypeScript.&lt;/p&gt;

&lt;p&gt;That is not because TypeScript is bad. For many projects, especially larger application codebases, it is valuable. But for this kind of small, self-contained game, I would rather keep the source close to the runtime language. The important correctness boundaries here are not mostly type boundaries. They are behavioral and spatial: does the ray hit the right wall, does the sprite sort correctly, does the door block the player, does the enemy see through a closed door, does the save state restore the map exactly? TypeScript can document some shapes, but it would not be the thing that makes those systems true.&lt;/p&gt;

&lt;p&gt;Plain JavaScript would also keep the source closer to the spirit of the port. The browser is the material. Vite and modules would clean up the development experience, but I would avoid turning the project into a modern stack demonstration.&lt;/p&gt;

&lt;p&gt;The version I would build today would be more modular, easier to navigate, and easier to serve locally. But it would still be a static browser game. It would still be Canvas. It would still have no runtime dependencies. It would still choose ownership over generality at the layers where the game becomes itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Port Still Runs
&lt;/h2&gt;

&lt;p&gt;The open-source release does not include copyrighted game data. The source references images, maps, sounds, and music by relative path, but those files are intentionally excluded from the repository.&lt;/p&gt;

&lt;p&gt;The source is available on GitHub at &lt;a href="https://github.com/lazarv/wolf3d" rel="noopener noreferrer"&gt;&lt;code&gt;lazarv/wolf3d&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For local testing, the folder can still be served with a static server that proxies missing files to the hosted copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx http-server &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080 &lt;span class="nt"&gt;-c-1&lt;/span&gt; &lt;span class="nt"&gt;--proxy&lt;/span&gt; https://wolf3d.wadcmd.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the source boundary clean. The repository contains the browser port. The game data stays outside it.&lt;/p&gt;

&lt;p&gt;And the architecture still works because it was always just static files and browser APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Thing I Still Like About It
&lt;/h2&gt;

&lt;p&gt;Looking back at this port, the thing I like most is not the raycaster, although I still like the raycaster. It is not the no-dependency choice by itself. It is not the fact that the code still runs.&lt;/p&gt;

&lt;p&gt;It is the relationship between the program and its materials.&lt;/p&gt;

&lt;p&gt;The implementation does not float above the browser. It touches it directly. It asks Canvas for pixels, HTML for screens, CSS for layering, XHR for files, audio for sound, localStorage for persistence, and JavaScript for the small machines that hold the game together.&lt;/p&gt;

&lt;p&gt;There is very little pretending.&lt;/p&gt;

&lt;p&gt;That kind of software has a particular feeling. You can follow it down. You can see where time is spent. You can see where state lives. You can see why a door opens, why a wall renders, why an enemy sees you, why a shot hits, why a sound is quiet, why a save game restores the room you left behind.&lt;/p&gt;

&lt;p&gt;Modern software often hides those answers behind tools that are powerful, useful, and sometimes necessary. But there is still value in a program where the answers are close to the surface.&lt;/p&gt;

&lt;p&gt;This port is one of those programs.&lt;/p&gt;

&lt;p&gt;It is a game built from the browser, not from a stack around the browser. It belongs to April-May 2012, but it still says something I believe now: sometimes the best architecture is not the one with the most reusable layers. Sometimes it is the one that lets the thing you are building stay closest to the material it is made from.&lt;/p&gt;

&lt;p&gt;For this port, that material was the browser.&lt;/p&gt;

&lt;p&gt;And the browser was enough.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>Music and Code Ask the Same Thing of Me</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sun, 17 May 2026 15:25:49 +0000</pubDate>
      <link>https://dev.to/lazarv/music-and-code-ask-the-same-thing-of-me-1dh</link>
      <guid>https://dev.to/lazarv/music-and-code-ask-the-same-thing-of-me-1dh</guid>
      <description>&lt;p&gt;I have had this feeling for a long time: music, and especially composition, is much closer to software development than we usually admit.&lt;/p&gt;

&lt;p&gt;The obvious explanation is mathematics. That part is true, of course, but it is also the easiest place to stop: music has numbers behind it, code has numbers behind it, so the two must be related.&lt;/p&gt;

&lt;p&gt;I think that is true, but I also think it is the least interesting version of the truth.&lt;/p&gt;

&lt;p&gt;The deeper connection, for me, is not mathematics. It is the feeling of parts trying to find their place.&lt;/p&gt;

&lt;p&gt;When I write music, I rarely feel like I am simply inventing a song from nothing. It feels more like different pieces slowly reveal what they want to be in relation to each other. A motif begins as a small gesture. A chord progression may feel important for a while, then turn out to be only a passage. A melody can work on its own and still not belong in the center. The song keeps asking where each thing should live.&lt;/p&gt;

&lt;p&gt;I feel almost the same thing when I write code.&lt;/p&gt;

&lt;p&gt;A module begins as a piece of behavior. A function does not yet know where it belongs. An API has not found its natural shape. Sometimes something works technically, but it has not found its role. It is inside the system, but it does not sit there well. Like an instrument playing the right notes in the wrong place.&lt;/p&gt;

&lt;p&gt;One of the most beautiful moments in composition is when a part finally lands where it belongs. Not because it became louder, or more complex, or more impressive. Because suddenly it becomes clear why it is there. The song does not merely contain it. The song needs it. The part is not decoration, not a clever idea, not a little display of skill. It has a role.&lt;/p&gt;

&lt;p&gt;The same thing happens in good software.&lt;/p&gt;

&lt;p&gt;What impresses me in a beautiful system is not that it contains many things. It is that everything has a reason to be there, and the parts are not fighting each other. Names clarify instead of merely labeling. Boundaries feel calm. The system starts to feel proportionate from the inside.&lt;/p&gt;

&lt;p&gt;That sense of proportion is why beautiful code feels musical to me.&lt;/p&gt;

&lt;p&gt;I do not mean that only as a metaphor. There really is such a thing as rhythm in a piece of code. There is breath. There is tension and release. Sometimes a refactor feels exactly like removing an unnecessary voice from a song: the result is not poorer, but clearer. Sometimes an abstraction behaves like a good variation on a theme. It does not hide the original idea. It reveals that the same form has been working in more than one place.&lt;/p&gt;

&lt;p&gt;And there is code that works, but feels musically false.&lt;/p&gt;

&lt;p&gt;It is not necessarily buggy, or slow, or even badly written according to any objective checklist. But something is off. The emphasis is in the wrong place. One part asks for too much attention while another has no room to breathe. The system is full of decisions that can each be justified in isolation, but together they do not sound right.&lt;/p&gt;

&lt;p&gt;This is hard to explain to someone who sees programming only as the production of solutions. In the same way, it is hard to explain why a piece of music is not good merely because every note is "correct." Correctness is the price of entry. The real work begins when something already functions, but is not yet true. When the song can be played, but does not yet say what it is supposed to say. When the code runs, but has not yet found the form in which I can leave it alone.&lt;/p&gt;

&lt;p&gt;I think composition and software development demand the same kind of care from me.&lt;/p&gt;

&lt;p&gt;They ask for a kind of attention that is not satisfied by the fact that something exists. I keep asking whether it exists in the right place, whether it is speaking in the right voice, whether it leaves enough room around itself. And underneath all of that, I am asking whether the whole thing is actually moving somewhere.&lt;/p&gt;

&lt;p&gt;This is why music and code do not feel like separate worlds to me. They feel like two different materials for the same inner work. One uses sound. The other uses running systems. One unfolds through time. The other unfolds through use. But in both, I have to listen for structure, remove what does not belong, and wait for the moment when the form begins to look back at me.&lt;/p&gt;

&lt;p&gt;Maybe this is why bad code can hurt so much.&lt;/p&gt;

&lt;p&gt;Not only because it is hard to work with. Because I can hear the missed music inside it. I can hear that it could be cleaner, quieter, more generous to the next person who has to enter it and keep thinking.&lt;/p&gt;

&lt;p&gt;And maybe this is why a good system can be so joyful.&lt;/p&gt;

&lt;p&gt;Because it is not only useful. It is not only clever. There is a beauty in it that does not come from decoration, but from the fact that its parts finally understand why they are together.&lt;/p&gt;

&lt;p&gt;I think this is why I keep returning to both. In music and in software, I am looking for the same moment: when many separate pieces stop being separate. When the parts begin to serve the same thought. When I no longer have to force the thing to hold together, because the structure has started to carry its own weight.&lt;/p&gt;

&lt;p&gt;I wonder how many other people feel this.&lt;/p&gt;

&lt;p&gt;How many developers are also musicians, somewhere? Maybe only after work, inside unfinished demos and songs that were never released. And how many of them found a home in software for exactly this reason: because the same compositional pleasure was waiting there, made from different material?&lt;/p&gt;

&lt;p&gt;The more I think about it, the less this feels like a coincidence.&lt;/p&gt;

&lt;p&gt;Something in them seems to come from the same place.&lt;/p&gt;

&lt;p&gt;If you feel it too, I would love to know where music and code meet for you.&lt;/p&gt;

</description>
      <category>devjournal</category>
      <category>discuss</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Runtime Is Not the Problem</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Wed, 13 May 2026 12:21:36 +0000</pubDate>
      <link>https://dev.to/lazarv/runtime-is-not-the-problem-1b9</link>
      <guid>https://dev.to/lazarv/runtime-is-not-the-problem-1b9</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It is only too small.&lt;/p&gt;

&lt;p&gt;The important distinction is not compiled vs runtime. The important distinction is &lt;strong&gt;specialized output vs packaged capability&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;That is not the inevitable cost of React's model. It is the cost of React's packaging shape.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Clean Story
&lt;/h2&gt;

&lt;p&gt;Svelte's pitch has always been easy to understand. The &lt;a href="https://svelte.dev/" rel="noopener noreferrer"&gt;official site&lt;/a&gt; describes Svelte as a framework that uses a compiler so components do minimal work in the browser. &lt;a href="https://v4.svelte.dev/" rel="noopener noreferrer"&gt;Older Svelte copy&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;React's browser story is different. A React app calls &lt;code&gt;createRoot&lt;/code&gt; or &lt;code&gt;hydrateRoot&lt;/code&gt; from &lt;a href="https://react.dev/reference/react-dom/client" rel="noopener noreferrer"&gt;&lt;code&gt;react-dom/client&lt;/code&gt;&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;At the scale of a counter, the contrast is almost unfair.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is why small examples make compiled frameworks look so good. They are not paying for the general case before the general case appears.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Size Curve
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The useful measurement is not the total size of &lt;code&gt;node_modules&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;That last question is where React becomes interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  React's Monolith Problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The shipped runtime is not compact in the same way.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The problem is that the browser package is arranged around the general product, not around the current application's capability set.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The package boundary says: this is React DOM for the client.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That second sentence is what a capability-shaped React would need to make visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Host Is Part of the Cost
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is not an insult. It is a strength. A compiler gains power when the target is narrow enough to make strong decisions.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tree Shaking Needs Shape
&lt;/h2&gt;

&lt;p&gt;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. &lt;a href="https://webpack.js.org/guides/tree-shaking/" rel="noopener noreferrer"&gt;Webpack's own guide&lt;/a&gt; is blunt about the ingredients: ES module syntax, production optimizations, and accurate side-effect information are what let unused exports and whole modules disappear.&lt;/p&gt;

&lt;p&gt;This is why library shape matters so much.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is the old monolith shape.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Dead-code elimination cannot remove a capability it cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Capability Graph Would Mean
&lt;/h2&gt;

&lt;p&gt;Imagine React distributed less like a runtime blob and more like a set of capabilities.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The compiler would participate, but it would not replace the runtime. JSX compilation and &lt;a href="https://react.dev/learn/react-compiler" rel="noopener noreferrer"&gt;React Compiler&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is the part the compiled-vs-runtime argument misses. A runtime can be tree-shaken if it is designed as something tree-shakable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compilation Is a Form of Packaging
&lt;/h2&gt;

&lt;p&gt;The word "compiled" makes Svelte sound like it lives in a different category, but compilation is also a packaging strategy.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That filtering is the real advantage.&lt;/p&gt;

&lt;p&gt;A compiler gets to ask: what does this component actually do?&lt;/p&gt;

&lt;p&gt;A capability-shaped runtime gets to ask almost the same question: what capabilities does this application actually use?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The App Size Conversation We Should Have
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The point of this exercise is not to crown a universal winner. The point is to see the shape of payment.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is where React's size becomes less philosophical and more mechanical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smaller React That Could Exist
&lt;/h2&gt;

&lt;p&gt;There is a smaller React hiding inside React.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;But difficulty is not impossibility, and it is not a rebuttal to the architectural point.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime Is Still Valuable
&lt;/h2&gt;

&lt;p&gt;It is tempting, after all of this, to conclude that runtimes are a regrettable compromise. They are not.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Those are real advantages. They are why React won so much mindshare in the first place.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The right criticism of React is not "React has a runtime."&lt;/p&gt;

&lt;p&gt;The right criticism is "React's runtime is not packaged according to the capabilities of the app."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Divide
&lt;/h2&gt;

&lt;p&gt;The real divide is not compiled vs runtime.&lt;/p&gt;

&lt;p&gt;The real divide is &lt;strong&gt;specific vs undifferentiated&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is the lens that makes the argument clearer.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the question for any framework should be simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can the smallest app receive the smallest faithful version of your model?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;They use capabilities.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>react</category>
    </item>
    <item>
      <title>RSC Is Not the Input Boundary</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sat, 09 May 2026 18:57:17 +0000</pubDate>
      <link>https://dev.to/lazarv/rsc-is-not-the-input-boundary-2aao</link>
      <guid>https://dev.to/lazarv/rsc-is-not-the-input-boundary-2aao</guid>
      <description>&lt;p&gt;Every major React Server Components security release seems to trigger the same little ritual. An advisory lands, someone sees the letters RSC, and a few hours later the lesson has already collapsed into: "RSC is bad."&lt;/p&gt;

&lt;p&gt;That lesson is convenient. It is also imprecise.&lt;/p&gt;

&lt;p&gt;The same thing happened around the Next.js security release from May 7, 2026. Vercel shipped fixes for several Next.js and upstream React issues, including a high-severity denial-of-service vulnerability affecting the React Server Components packages. But the interesting part of the advisory was not that rendering a Server Component is inherently dangerous. The interesting part was that specially crafted HTTP requests sent to Server Function endpoints could cause excessive CPU usage or out-of-memory failures while the payload was being processed.&lt;/p&gt;

&lt;p&gt;That distinction is not a footnote. It is the center of the issue.&lt;/p&gt;

&lt;p&gt;A Server Component is not the same attack surface as a Server Function. One sends a representation of a component tree from the server to the client. The other receives a payload from the client, asks the server runtime to deserialize it, and then invokes server-side code. They can both live inside the RSC model. They can both involve the Flight protocol. But from a security perspective, they ask opposite questions.&lt;/p&gt;

&lt;p&gt;The Server Component question is: what are we allowing to leave the server and reach the client?&lt;/p&gt;

&lt;p&gt;The Server Function question is: what are we allowing to enter the server from the client?&lt;/p&gt;

&lt;p&gt;The second one is an input boundary. If that boundary is enforced too late, the failure is not that the RSC model is broken. The failure is that an RPC-shaped input surface was treated as if it were merely a framework ergonomic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Category Error
&lt;/h2&gt;

&lt;p&gt;There is a recurring confusion in RSC discussions. People often talk about "RSC" as one thing, when in practice they are combining several distinct mechanisms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server Components: components that run on the server.&lt;/li&gt;
&lt;li&gt;Client Components: components that run in the browser, while still participating in the same React tree.&lt;/li&gt;
&lt;li&gt;Server References / Server Functions: server-side functions for which the client receives a reference and can later issue a call.&lt;/li&gt;
&lt;li&gt;Flight protocol: the serialization format that carries component payloads, references, and a broader set of values between the server and the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Together, these form an architecture. They do not share the same risk profile.&lt;/p&gt;

&lt;p&gt;When a Server Component renders, the direction of execution is primarily server to client. The server produces a payload. The client consumes it. The usual class of bug is that the server puts something into that payload that it should not have put there. That can be a data leak, a cache-boundary mistake, or a component-level authorization bug.&lt;/p&gt;

&lt;p&gt;When a Server Function runs, the direction reverses. The client sends something to the server. The server runtime has to understand the payload, identify the action, materialize the arguments, and pass them to the handler.&lt;/p&gt;

&lt;p&gt;That is a very different moment. The browser is no longer just a consumer. The browser, or anything capable of sending an HTTP request, is now providing input to the server.&lt;/p&gt;

&lt;p&gt;An endpoint like that cannot be treated as a React composition detail. It is a public RPC surface. It may look like a function call to the developer. TypeScript may make it feel wonderfully local. Over the network, it is still a hostile input boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens in a Server Function Call?
&lt;/h2&gt;

&lt;p&gt;In simplified form, a Server Function request looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client event
  -&amp;gt; POST request
    -&amp;gt; identify action id / server reference
      -&amp;gt; deserialize Flight payload
        -&amp;gt; materialize arguments
          -&amp;gt; invoke server function handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most application code focuses on the last step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the application level, this is better than nothing. The handler is not blindly trusting the input. But for the class of problem described in the 2026 advisory, this is already late.&lt;/p&gt;

&lt;p&gt;By the time &lt;code&gt;schema.parse(input)&lt;/code&gt; runs, the runtime has already done much of the risky work: it has read the request, walked the payload, materialized values, built objects, interpreted references, and potentially dealt with streams, binary values, and nested structures. If the goal of the attack is not to smuggle invalid business data into the handler, but to make deserialization itself consume too much CPU or memory, validation inside the handler does not protect the server from the relevant cost.&lt;/p&gt;

&lt;p&gt;So "validate your input" is not specific enough.&lt;/p&gt;

&lt;p&gt;The question is where.&lt;/p&gt;

&lt;p&gt;If validation happens inside the handler, it protects application invariants.&lt;/p&gt;

&lt;p&gt;If validation happens in the Server Function layer after the request has already been deserialized, it gives the developer a better contract, but it may still leave the decoder cost exposed.&lt;/p&gt;

&lt;p&gt;If validation happens while the protocol payload is being deserialized, the runtime can know during the argument walk what it expects, what it should drop, what it should reject, and when it should stop processing the request.&lt;/p&gt;

&lt;p&gt;That is the difference that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  A WAF Is the Wrong Boundary
&lt;/h2&gt;

&lt;p&gt;One important line in Vercel's release was that these advisories could not be reliably blocked at the WAF layer.&lt;/p&gt;

&lt;p&gt;That should not be surprising.&lt;/p&gt;

&lt;p&gt;A WAF sees HTTP requests. It can inspect headers, size, URLs, known patterns, maybe parts of the body. It does not fully understand the semantics of a Flight payload. It does not know which function a given server reference points to. It does not know how many arguments that function expects. It does not know that slot zero must be a string, slot one must be &lt;code&gt;FormData&lt;/code&gt;, slot two must be a &lt;code&gt;Map&amp;lt;string, number&amp;gt;&lt;/code&gt;, and the file field must be at most five megabytes and either &lt;code&gt;image/png&lt;/code&gt; or &lt;code&gt;image/jpeg&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If the WAF tried to know all of that, it would effectively be reimplementing the framework protocol at the edge. That is brittle, version-dependent, and it puts the responsibility in the wrong place.&lt;/p&gt;

&lt;p&gt;The right boundary is where the runtime already knows which Server Function it is about to call, but has not yet handed materialized input to the handler.&lt;/p&gt;

&lt;p&gt;That is the protocol layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Is Not a TypeScript Problem
&lt;/h2&gt;

&lt;p&gt;Types usually enter the discussion here. A Server Function might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;savePost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The developer experience suggests that &lt;code&gt;post&lt;/code&gt; is a &lt;code&gt;PostInput&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;From the network's point of view, that is only a hope.&lt;/p&gt;

&lt;p&gt;The TypeScript type does not exist in the request. It does not exist in the Flight payload. It will not tell the decoder when to stop. It will not reject an overly deep structure, an oversized string, an oversized binary value, an unexpected &lt;code&gt;FormData&lt;/code&gt; field, or a &lt;code&gt;Map&lt;/code&gt; whose size is itself enough to become a denial-of-service attempt.&lt;/p&gt;

&lt;p&gt;Types are useful documentation for the contract. But if the contract protects a runtime boundary, runtime information has to exist too.&lt;/p&gt;

&lt;p&gt;That is why the Server Function definition needs metadata that does more than narrow the handler's TypeScript type. The metadata has to reach the protocol decoder.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Late Validation Looks Like
&lt;/h2&gt;

&lt;p&gt;Consider an abstract Server Function API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;savePost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServerFn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inputValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a good direction. Validation lives at the definition site, not scattered through the handler body. The handler receives validated &lt;code&gt;data&lt;/code&gt;. TypeScript inference moves together with the runtime schema. An API like this is much healthier than a bare &lt;code&gt;async function (input: Whatever)&lt;/code&gt; and a comment saying "the client calls this."&lt;/p&gt;

&lt;p&gt;TanStack Start is on this side of the line. Its &lt;code&gt;createServerFn&lt;/code&gt; API makes the Server Function explicit, treats the input validator as part of the function contract, and documents that client-side calls become network calls. That is much better than hiding the request-shaped nature of the operation.&lt;/p&gt;

&lt;p&gt;But it is still a different category.&lt;/p&gt;

&lt;p&gt;A TanStack Start Server Function is not an RSC Flight Server Function in the same sense as an RSC server reference. Based on the documented API, validation is part of the Server Function layer: the function receives a &lt;code&gt;data&lt;/code&gt; input, the runtime validates that input, and then the handler runs. That is a good application-level contract. If the runtime has already deserialized the body into a JavaScript value before the validator sees it, then the validator is working in the post-deserialization world.&lt;/p&gt;

&lt;p&gt;This is not a criticism in the sense of "TanStack Start is bad." It is not. A definition-site validator is the right direction for a classic RPC API.&lt;/p&gt;

&lt;p&gt;It is simply not the same protection as giving the Flight decoder the Server Function's argument-slot contract and letting validation happen during the payload walk.&lt;/p&gt;

&lt;p&gt;For an RPC API, the question is how much work the framework's serialization layer has to do before the validator gets control. For an RSC Server Function, the question is even sharper because the Flight payload can carry a richer value space. We are not only talking about JSON objects. The protocol can represent references, form data, binary values, streams, iterables, promises, typed arrays, &lt;code&gt;Map&lt;/code&gt;, and &lt;code&gt;Set&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The richer the wire format, the less satisfying "we parse it at the top of the handler" becomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The react-server Approach
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;@lazarv/react-server&lt;/code&gt; approach is not merely to provide a convenient validation wrapper around the handler.&lt;/p&gt;

&lt;p&gt;The important part is that the Server Function definition attaches metadata to the server reference, and that metadata reaches the Flight decoder.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;createFunction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@lazarv/react-server/function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uploadAvatar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFunction&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;displayName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;displayName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avatar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAvatar&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is not only that &lt;code&gt;form.get("avatar")&lt;/code&gt; becomes nicer inside the handler.&lt;/p&gt;

&lt;p&gt;The more important contract is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the first argument to the Server Function is &lt;code&gt;FormData&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;the allowed fields are known;&lt;/li&gt;
&lt;li&gt;unknown fields can be rejected by default;&lt;/li&gt;
&lt;li&gt;the file has a size limit;&lt;/li&gt;
&lt;li&gt;the MIME allowlist is part of the wire contract;&lt;/li&gt;
&lt;li&gt;the handler only runs if the decoder successfully validates that slot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not business logic. That is an input boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Slot-Walk Validation
&lt;/h2&gt;

&lt;p&gt;The technical shape is roughly this.&lt;/p&gt;

&lt;p&gt;When a Server Function export is wrapped with &lt;code&gt;createFunction(...)&lt;/code&gt;, the parse/validate spec associated with that wrapper is registered as server reference metadata. When a request comes in, the runtime first tries to recover the action id. For header-based action calls, that can come from the &lt;code&gt;react-server-action&lt;/code&gt; header. For progressive-enhancement form submissions, it can be encoded in the submitted &lt;code&gt;FormData&lt;/code&gt;. If the token is encrypted, the runtime decrypts it first so it knows which action the request is trying to call.&lt;/p&gt;

&lt;p&gt;Then comes a small but important step: the action module has to be loaded before decoding.&lt;/p&gt;

&lt;p&gt;That may sound incidental. It is not. The server-function metadata registry is populated by the module's top-level &lt;code&gt;registerServerReference(...)&lt;/code&gt; calls. If the runtime deserialized the payload first and only loaded the action module later, the first call to an action could silently skip validation. So react-server preloads the action module first, then calls &lt;code&gt;decodeReply&lt;/code&gt; with the recovered action id.&lt;/p&gt;

&lt;p&gt;From there, the decoder is no longer walking the argument list blindly. It knows which Server Function it is decoding for. It can look up the associated metadata. It can apply parse and validate slot by slot.&lt;/p&gt;

&lt;p&gt;If the first argument is &lt;code&gt;z.string()&lt;/code&gt;, slot zero has to validate as a string.&lt;/p&gt;

&lt;p&gt;If the second argument is &lt;code&gt;arrayBuffer({ maxBytes: 1024 })&lt;/code&gt;, the decoder can reject an oversized buffer based on byte length.&lt;/p&gt;

&lt;p&gt;If a &lt;code&gt;formData(...)&lt;/code&gt; spec uses &lt;code&gt;unknown: "reject"&lt;/code&gt;, an injected extra field does not reach the handler.&lt;/p&gt;

&lt;p&gt;If a &lt;code&gt;file(...)&lt;/code&gt; spec declares a MIME allowlist and a size limit, the runtime does not wait for application code to decide whether the file is acceptable.&lt;/p&gt;

&lt;p&gt;If a &lt;code&gt;map(...)&lt;/code&gt; or &lt;code&gt;set(...)&lt;/code&gt; spec has a maximum size, the collection cannot grow without bound in the pre-handler world.&lt;/p&gt;

&lt;p&gt;If a stream or async iterable has a maximum chunk count or byte limit, the boundary remains active as the handler consumes it.&lt;/p&gt;

&lt;p&gt;That is the key property: the shape of the Server Function input is not only TypeScript inference. It is a decoder contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Is Structural
&lt;/h2&gt;

&lt;p&gt;A protocol-level validation failure is not a business validation failure.&lt;/p&gt;

&lt;p&gt;It is not the same as a user typing a bad email address into a form and receiving a field error. It means the wire payload did not satisfy the contract under which the server was willing to materialize a Server Function call at all.&lt;/p&gt;

&lt;p&gt;The right response is structural rejection.&lt;/p&gt;

&lt;p&gt;In react-server, a validation failure during the slot walk becomes a &lt;code&gt;DecodeValidationError&lt;/code&gt; and is mapped to a 400 response. The handler does not run. The argument list is not bound. The client does not receive detailed schema diagnostics, because those details can reveal useful shape information to an attacker. The operator log can still keep the useful parts: action id, slot index, and failure reason.&lt;/p&gt;

&lt;p&gt;Again, this is different from application-level validation.&lt;/p&gt;

&lt;p&gt;A form validation error is a user experience concern.&lt;/p&gt;

&lt;p&gt;A decode validation error is a protocol concern.&lt;/p&gt;

&lt;p&gt;If we merge those two paths, we either reveal too much to an attacker or give too little feedback to a real user. They should not be the same path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound Arguments Are Not Call Arguments
&lt;/h2&gt;

&lt;p&gt;There is another detail that is easy to lose in RSC Server Function discussions: call arguments and bound captures are not the same thing.&lt;/p&gt;

&lt;p&gt;A Server Function can be created with bound values. These are values carried by a server-side closure or binding and later associated with the server reference. They should not be treated the same way as runtime arguments sent by the client.&lt;/p&gt;

&lt;p&gt;In the react-server model, bound captures are integrity-protected by the action token. That is a different kind of protection than per-argument validation. They do not need the same schema path as client input, because they are not crossing the same trust boundary.&lt;/p&gt;

&lt;p&gt;Arguments sent by the client are hostile input.&lt;/p&gt;

&lt;p&gt;Bound captures are integrity-protected server-side state representation.&lt;/p&gt;

&lt;p&gt;If both are collapsed into the same "validate the input" bucket, the model becomes muddy again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global Decode Limits
&lt;/h2&gt;

&lt;p&gt;Per-function contracts need a second layer: global resource ceilings.&lt;/p&gt;

&lt;p&gt;There will be unvalidated legacy actions. There will be code in the middle of migration. There will be Server Functions where some slots are intentionally loose. And there are payload characteristics that should not have to be repeated manually on every function.&lt;/p&gt;

&lt;p&gt;So the runtime needs limits such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;maximum payload byte size;&lt;/li&gt;
&lt;li&gt;maximum Flight row count;&lt;/li&gt;
&lt;li&gt;maximum materialization depth;&lt;/li&gt;
&lt;li&gt;maximum number of bound arguments;&lt;/li&gt;
&lt;li&gt;maximum BigInt digit count;&lt;/li&gt;
&lt;li&gt;maximum string length;&lt;/li&gt;
&lt;li&gt;maximum stream chunk count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These do not replace per-function validation. They are the safety floor. The function spec is the precise contract. The global limits are the ceilings that still stop obviously abusive payloads when a function has not yet been declared perfectly.&lt;/p&gt;

&lt;p&gt;Together, they form a more meaningful defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev-Time Strictness
&lt;/h2&gt;

&lt;p&gt;A runtime's behavior is not only what it does in production. It is also what it teaches during development.&lt;/p&gt;

&lt;p&gt;If a &lt;code&gt;"use server"&lt;/code&gt; export can be called from the client without validation, that is an attack surface that may not be visible at the call site. The developer sees a function. The browser sees an endpoint. The reviewer often reads the handler body, not the wire boundary.&lt;/p&gt;

&lt;p&gt;That is why a dev-time warning for bare Server Functions is useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server function ... called without validation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is not that every function needs a complex schema. Some functions have no input. Some migration paths need a temporary escape hatch. But that should be an explicit decision. The default should not be that a publicly callable Server Function has no runtime input contract and nobody notices until a security release makes the boundary visible.&lt;/p&gt;

&lt;p&gt;In that sense, the no-spec &lt;code&gt;createFunction()&lt;/code&gt; form is useful too. It does not add validation, but it records intent. The runtime can tell that the developer has seen the boundary and chosen not to narrow it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Missing Runtime Contract in Next.js
&lt;/h2&gt;

&lt;p&gt;Next.js fixed the affected upstream React issues, and everyone affected should upgrade. That is not optional. After an advisory like this, the first correct move is always to patch.&lt;/p&gt;

&lt;p&gt;But patching is not the same thing as learning the architectural lesson.&lt;/p&gt;

&lt;p&gt;In the current Next.js Server Function model, there is no generally documented framework API that gives the runtime a per-function Flight decode contract. There is &lt;code&gt;"use server"&lt;/code&gt;. There is a server-side handler. You can validate inside that handler. You can build your own helper around it. But that is not the same as attaching argument-slot metadata to the server reference so the decoder knows what it is allowed to materialize before the handler runs.&lt;/p&gt;

&lt;p&gt;That is why I consider the react-server approach stronger here.&lt;/p&gt;

&lt;p&gt;Not because it has "schema validation." Schema validation exists in many places.&lt;/p&gt;

&lt;p&gt;Because the validation happens in a better place.&lt;/p&gt;

&lt;p&gt;Because the function contract appears on the Flight protocol decode path.&lt;/p&gt;

&lt;p&gt;Because malformed payloads can be structurally rejected before handler execution.&lt;/p&gt;

&lt;p&gt;Because the wire-aware specs cover not only application data models, but also the richer value space of the protocol: &lt;code&gt;FormData&lt;/code&gt;, &lt;code&gt;File&lt;/code&gt;, &lt;code&gt;Blob&lt;/code&gt;, &lt;code&gt;ArrayBuffer&lt;/code&gt;, typed arrays, &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt;, streams, iterables, and promises.&lt;/p&gt;

&lt;p&gt;And because this defense is not a WAF rule, not a convention, not "remember to parse at the top of the handler," but a runtime boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Server Function Is a Public Endpoint
&lt;/h2&gt;

&lt;p&gt;The simplest way to say all of this is:&lt;/p&gt;

&lt;p&gt;A Server Function is a public endpoint.&lt;/p&gt;

&lt;p&gt;Not because it looks like a REST route. Not because the developer wrote a URL for it. Because the client can send a request that causes the server to attempt to invoke a function.&lt;/p&gt;

&lt;p&gt;Once we accept that, the security consequences become clearer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every Server Function should have an input contract;&lt;/li&gt;
&lt;li&gt;the contract should live at the definition site, not be scattered through the handler body;&lt;/li&gt;
&lt;li&gt;the runtime should know the contract as early as possible;&lt;/li&gt;
&lt;li&gt;deserialization cost should be bounded;&lt;/li&gt;
&lt;li&gt;unknown fields should not be treated as harmless by default;&lt;/li&gt;
&lt;li&gt;file and blob inputs should have size and MIME constraints;&lt;/li&gt;
&lt;li&gt;authorization should be explicit in the Server Function, not inferred from the surrounding component tree;&lt;/li&gt;
&lt;li&gt;the WAF should be an extra layer, not the primary interpreter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not an anti-RSC position.&lt;/p&gt;

&lt;p&gt;It is the position that takes RSC seriously enough not to treat all of its parts as one mystical box.&lt;/p&gt;

&lt;h2&gt;
  
  
  RSC Is Not the Scapegoat
&lt;/h2&gt;

&lt;p&gt;The interesting thing about RSC is that it gives us a formal boundary between two different execution environments. The server and the client are not the same place. They have different capabilities, different costs, different failure modes, and different security responsibilities.&lt;/p&gt;

&lt;p&gt;That is the strength of the model.&lt;/p&gt;

&lt;p&gt;But a boundary is only useful if we are precise about what crosses it, and in which direction.&lt;/p&gt;

&lt;p&gt;For Server Components, the question is what the server sends to the client.&lt;/p&gt;

&lt;p&gt;For Server Functions, the question is what the server accepts from the client.&lt;/p&gt;

&lt;p&gt;When a Server Function input payload is validated too late, or when the runtime does too much work before it even knows what it expects, the lesson is not that Server Components are a bad idea. The lesson is that an RPC-shaped input surface was treated as framework ergonomics for too long.&lt;/p&gt;

&lt;p&gt;That mistake is fixable. But only if we name it precisely.&lt;/p&gt;

&lt;p&gt;Not "RSC is bad."&lt;/p&gt;

&lt;p&gt;Not "Server Components are insecure."&lt;/p&gt;

&lt;p&gt;This:&lt;/p&gt;

&lt;p&gt;Server Function input payloads need protocol-level validation.&lt;/p&gt;

&lt;p&gt;That sentence is less dramatic. It is also more true.&lt;/p&gt;

&lt;p&gt;And if we want RSC to have a healthy future, that is exactly the kind of sentence we need: less drama around the model, and more attention on the few boundaries where the model actually meets a hostile network.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://vercel.com/changelog/next-js-may-2026-security-release" rel="noopener noreferrer"&gt;Next.js May 2026 security release&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/facebook/react/security/advisories/GHSA-rv78-f8rc-xrxh" rel="noopener noreferrer"&gt;React security advisory: DoS in React Server Components&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://react-server.dev/guide/server-functions#validation" rel="noopener noreferrer"&gt;react-server server functions validation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/server-functions" rel="noopener noreferrer"&gt;TanStack Start server functions&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rsc</category>
      <category>react</category>
      <category>nextjs</category>
      <category>security</category>
    </item>
    <item>
      <title>Dissatisfaction Is a Spark</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sat, 09 May 2026 18:55:43 +0000</pubDate>
      <link>https://dev.to/lazarv/dissatisfaction-is-a-spark-2ejp</link>
      <guid>https://dev.to/lazarv/dissatisfaction-is-a-spark-2ejp</guid>
      <description>&lt;p&gt;I have a particular relationship with dissatisfaction.&lt;/p&gt;

&lt;p&gt;When something does not feel right, I rarely manage to leave it there. A library feels too heavy. A framework hides the thing I want to touch. A tool solves the wrong half of the problem. A program almost understands its own shape, but not quite. A game has a wonderful idea buried under a system that keeps getting in its own way.&lt;/p&gt;

&lt;p&gt;Most sane people, I think, complain for a minute and move on.&lt;/p&gt;

&lt;p&gt;I complain for a minute and open a new project.&lt;/p&gt;

&lt;p&gt;This is not always wise. It is not always efficient. There is a whole graveyard of half-built answers behind that impulse, each one started with the private conviction that the world would be slightly better if this one irritating thing were different. But I have learned not to distrust the impulse too much, because it has carried me toward almost everything I have cared about building.&lt;/p&gt;

&lt;p&gt;For me, dissatisfaction is not only rejection. It is attention becoming specific.&lt;/p&gt;

&lt;p&gt;There is a kind of annoyance that is just noise. Something is broken, ugly, slow, badly named, overdesigned, underdesigned. Fine. The world is full of those. But sometimes the annoyance has a shape. It keeps returning to the same edge. I can feel, before I can explain, that the problem is not accidental. Something in the design is pointing in the wrong direction. Something wants to be inverted, simplified, pulled apart, made composable, made honest.&lt;/p&gt;

&lt;p&gt;That feeling is dangerous in the best way.&lt;/p&gt;

&lt;p&gt;It turns passive criticism into motion. It moves the question from "why is this like this?" to "could I make something better than this?" and then, eventually, "what would it look like if I did?" And once that question becomes vivid enough, building stops feeling like work and starts feeling like a form of thinking. The project is not a product yet. It is an argument I can run.&lt;/p&gt;

&lt;p&gt;I think this is why so many of my projects begin as irritations. Not because I enjoy being annoyed, but because annoyance gives the mind a surface to push against. Pure satisfaction rarely asks anything of me. It closes the loop. Dissatisfaction leaves the loop open, and an open loop is where imagination gets in.&lt;/p&gt;

&lt;p&gt;Over time, though, I have noticed that the most persistent version of this is not even about other people's tools.&lt;/p&gt;

&lt;p&gt;Most of the time, the thing I am dissatisfied with is my own work. I build something, and then I see the compromise inside it. A decision I made too early. A boundary I drew in the wrong place. A design that seemed clean until real use put pressure on it. An implementation that works, but carries the shape of a mistake I had not yet learned how to name.&lt;/p&gt;

&lt;p&gt;That feeling is sharper, because I cannot blame anyone else for it. The flaw is mine. I put it there, in the design or in the implementation, usually for reasons that made sense at the time. But once I can see it, I want to make the whole thing better. Not slightly patched. Better. So I open the project again. Or I start the next one, carrying the correction forward.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://react-server.dev" rel="noopener noreferrer"&gt;&lt;code&gt;@lazarv/react-server&lt;/code&gt;&lt;/a&gt; came from that kind of place. Not from a clean plan, not from market analysis, not from the abstract desire to make a framework. It came from a series of small refusals. I did not want the boundaries to be there. I did not want the conventions to decide so much. I did not want the runtime to feel like a menu when it could feel like a set of primitives. At some point, the refusals became more than complaints. They became a thing I could build.&lt;/p&gt;

&lt;p&gt;That transformation still feels a little mysterious to me. The same emotion that could have become bitterness becomes a prototype. The same frustration that could have ended in a thread becomes a repository. The same little "no" turns, if I stay with it long enough, into a more interesting "what if?"&lt;/p&gt;

&lt;p&gt;Maybe that is the difference that matters. Dissatisfaction by itself is cheap. Everyone can see what is wrong. Everyone has taste when something fails them. The creative part begins when I let the dissatisfaction obligate me. If I really believe the thing could be better, then for a while I have to stop being only its critic. I have to become responsible for an alternative, even a small one, even a flawed one, even one nobody asked for.&lt;/p&gt;

&lt;p&gt;That responsibility is where the energy is.&lt;/p&gt;

&lt;p&gt;I do not think every irritation deserves a project. Life is too short, and most tools are allowed to be imperfect. But I have stopped treating dissatisfaction as a negative state I need to escape from quickly. Sometimes it is the first draft of care. Sometimes it is the mind noticing a possible world and being unable to unsee it.&lt;/p&gt;

&lt;p&gt;When I am not satisfied, something in me starts looking for a door.&lt;/p&gt;

&lt;p&gt;Sometimes the door is real.&lt;/p&gt;

&lt;p&gt;What does dissatisfaction do in you?&lt;/p&gt;

</description>
      <category>developer</category>
      <category>devjournal</category>
      <category>sideprojects</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>The Master Builder, Unleashed</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Thu, 07 May 2026 06:40:36 +0000</pubDate>
      <link>https://dev.to/lazarv/the-master-builder-unleashed-48bf</link>
      <guid>https://dev.to/lazarv/the-master-builder-unleashed-48bf</guid>
      <description>&lt;p&gt;There is a particular kind of pain in software work: sitting in a meeting about a thing you already know how to build.&lt;/p&gt;

&lt;p&gt;Not vaguely. Not optimistically. You can see the first version. You can see the shape of the data, the awkward part of the UI, the one integration that will probably hurt, the test that should exist before anyone trusts it, the part that can be ugly for a week, and the part that must be right from the beginning. The work is not done, but the form is already present in your head.&lt;/p&gt;

&lt;p&gt;Then the meeting continues.&lt;/p&gt;

&lt;p&gt;The discussion moves through alignment, ownership, prioritization, stakeholder expectations, dependency mapping, launch risk, follow-up meetings, and the increasingly ceremonial question of who should "drive" the thing. None of those words are fake. Some of them point at real constraints. But the emotional fact remains: the software could have started existing an hour ago.&lt;/p&gt;

&lt;p&gt;This is not the impatience of someone who does not understand organizations. It is the frustration of someone who understands both the work and the organization well enough to feel the gap between them.&lt;/p&gt;

&lt;p&gt;I have spent most of my career building things that were not supposed to fit where I put them: old game engines in the browser, data protocols in JavaScript, React Server Components outside the frameworks that tried to own them.&lt;/p&gt;

&lt;p&gt;That kind of work teaches you something uncomfortable: the hard part is rarely the first line of code. The hard part is keeping the shape of the thing intact while the world asks you to translate it into smaller, safer pieces.&lt;/p&gt;

&lt;p&gt;This is where AI agents change the equation.&lt;/p&gt;

&lt;p&gt;For a long time, the gap between seeing the shape of the thing and getting it built without losing that shape was just the cost of doing serious software. Big products needed big teams. Big teams needed coordination. Coordination needed meetings. The developer who could see the shape of the thing still needed designers, reviewers, frontend engineers, backend engineers, QA, release managers, platform support, security review, product sign-off, and enough calendar space for all of those people to agree that the thing should become real.&lt;/p&gt;

&lt;p&gt;The company owned execution. The individual owned at most a piece of intent.&lt;/p&gt;

&lt;p&gt;AI agents have started to disturb that bargain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The master builder
&lt;/h2&gt;

&lt;p&gt;The developer I am talking about is not any developer.&lt;/p&gt;

&lt;p&gt;This is not a beginner with a prompt box. It is not a mid-level engineer asking a model to fill in the parts they do not yet understand. It is not the fantasy that software can now be produced by desire alone, where a person describes an app, accepts the first plausible artifact, and calls the result engineering.&lt;/p&gt;

&lt;p&gt;The person at the center of this shift is closer to the old idea of the master builder.&lt;/p&gt;

&lt;p&gt;A master builder does not merely place bricks. They understand the structure before it exists. They know what can be improvised and what cannot. They know which details are cosmetic, which details are load-bearing, and which shortcuts will become expensive only after the room is full of people. They can work with specialists without being dissolved by specialization, because they carry a model of the whole.&lt;/p&gt;

&lt;p&gt;In software, this is the staff-level engineer, the principal engineer, the technical founder, the experienced IC with taste and ownership, the person who has built enough systems to know that implementation is never just implementation. They can read a product problem and see a system. They can read a system and see the product assumptions hiding inside it. They know when a design is under-specified, when an abstraction is premature, when a test suite is giving false comfort, when the happy path is lying, and when a release is safe enough to learn from.&lt;/p&gt;

&lt;p&gt;That kind of developer was already valuable. AI does not create that value. It gives that value a larger surface to act on.&lt;/p&gt;

&lt;p&gt;The agent is not the builder. The agent is a tool in the builder's workshop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Execution used to be scarce
&lt;/h2&gt;

&lt;p&gt;Most software organizations were shaped by a simple historical fact: writing, changing, and maintaining code required human time in large quantities.&lt;/p&gt;

&lt;p&gt;If a roadmap had more work than the current team could do, the answer was usually headcount. More frontend engineers. More backend engineers. More QA. More managers to coordinate the larger group. More process to make sure the larger group did not destroy itself by moving independently. The shape of the organization followed the scarcity of implementation.&lt;/p&gt;

&lt;p&gt;That scarcity made the company powerful. A small team might have a sharper idea, but the large company had the machinery to grind through the implementation. It could assign ten people to a problem, put a manager over them, attach design and product, run research, staff a platform dependency, and push the thing through a release train. The small team could move quickly at the beginning, but the large company could eventually bring mass to bear.&lt;/p&gt;

&lt;p&gt;That is why the old acquisition story made sense. A small company found a shape the market wanted. A large company bought it, copied it, or slowly surrounded it with distribution and resources. The small company had clarity. The large company had execution capacity.&lt;/p&gt;

&lt;p&gt;AI agents do not eliminate the large company's advantages. Distribution still matters. Trust still matters. Compliance, support, procurement, brand, data access, sales channels, regulatory knowledge, and operational maturity still matter. A bank is not replaced by a weekend app. A payments company is not replaced by a clever clone. NASA is not made less capable at space exploration because a web page could be more inspiring.&lt;/p&gt;

&lt;p&gt;But a particular advantage has weakened: the assumption that serious software requires organizational mass before it can be executed.&lt;/p&gt;

&lt;p&gt;That assumption is what &lt;a href="https://www.youtube.com/watch?v=p2aea9dytpE" rel="noopener noreferrer"&gt;Theo was circling in "Software engineering is dead now"&lt;/a&gt;. The provocative title is less interesting than the operational shift underneath it. When code becomes cheaper to produce, the bottleneck moves. The important question stops being "how many engineers can we assign?" and becomes "who understands the problem well enough to direct the work?"&lt;/p&gt;

&lt;p&gt;That is a very different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent changes the unit of leverage
&lt;/h2&gt;

&lt;p&gt;The most important thing about AI coding agents is not that they write code.&lt;/p&gt;

&lt;p&gt;It is that they let one coherent intent remain coherent across more of the work.&lt;/p&gt;

&lt;p&gt;Before agents, even a strong engineer had to break their intent apart to get enough capacity. One person could hold the whole shape, but the work had to be distributed across a team. That meant translation. The product shape became tickets. The tickets became implementation slices. The slices moved through people with different contexts, incentives, calendars, and levels of taste. Review tried to recover coherence after the fact.&lt;/p&gt;

&lt;p&gt;Sometimes that worked beautifully. Good teams are real. Collaboration can improve an idea. A second pair of eyes can catch the thing the builder missed. The point is not that teams are bad.&lt;/p&gt;

&lt;p&gt;The point is that teams are expensive, not only in salary but in semantic loss.&lt;/p&gt;

&lt;p&gt;Every handoff risks changing the idea. Every meeting turns part of the artifact back into language. Every approval step asks the work to justify itself before it has had a chance to become visible. Every person added to the loop increases capacity and coordination at the same time. When implementation was scarce, that trade was often worth it. When implementation becomes cheaper, the cost becomes easier to see.&lt;/p&gt;

&lt;p&gt;An AI agent changes the trade because it adds execution without adding a second will.&lt;/p&gt;

&lt;p&gt;That sentence is dangerous if read carelessly, so it needs the adult version immediately: the agent adds mistakes, hallucinations, overconfidence, style drift, security risk, and an endless appetite for plausible wrongness. It must be constrained, reviewed, tested, and corrected. It does not remove engineering discipline.&lt;/p&gt;

&lt;p&gt;But it also does not need to be aligned in the human sense. It does not need a career path, a meeting, a roadmap narrative, a title, a territory, or a week to build context from office politics. It can be pointed at a narrow part of the system, given constraints, corrected when it drifts, and asked to try again. It is not autonomous in the way a teammate is autonomous. That is precisely why it is useful as leverage.&lt;/p&gt;

&lt;p&gt;For the master builder, this is new. The builder can keep the whole artifact in view while delegating pieces of execution to tools that do not dilute the intent. The work still needs judgment. It needs more judgment, not less. But the distance between judgment and execution shrinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not vibe coding
&lt;/h2&gt;

&lt;p&gt;This distinction matters because the public language around AI-assisted development has been polluted by "vibe coding."&lt;/p&gt;

&lt;p&gt;Vibe coding is useful as a name for a real phenomenon: someone repeatedly prompts an AI system, accepts whatever looks close enough, and moves forward without deeply understanding the result. It can be fun. It can produce charming prototypes. It can help people explore personal software. It can also produce systems nobody should be asked to maintain.&lt;/p&gt;

&lt;p&gt;Syntax has been good on this distinction. In &lt;a href="https://syntax.fm/show/887/vibe-coding-is-a-problem" rel="noopener noreferrer"&gt;"Vibe Coding Is a Problem"&lt;/a&gt;, the problem is not that AI helps write code. The problem is the absence of close review, the willingness to stay at the surface, and the illusion that running software is the same thing as understood software. Their later episode, &lt;a href="https://syntax.fm/show/998/how-to-fix-vibe-coding" rel="noopener noreferrer"&gt;"How to Fix Vibe Coding"&lt;/a&gt;, points in the better direction: deterministic tools, linting, quality analysis, headless browsers, task workflows, observability, and tighter feedback loops.&lt;/p&gt;

&lt;p&gt;That is the line.&lt;/p&gt;

&lt;p&gt;The future worth taking seriously is not vibe coding. It is developer-led AI engineering.&lt;/p&gt;

&lt;p&gt;The developer supplies the intent. The developer supplies the taste. The developer supplies the constraints. The developer decides where the agent is allowed to roam and where it must stay on rails. The developer reads the diff. The developer runs the tests. The developer notices when the solution is locally correct but globally wrong. The developer decides whether the artifact deserves to exist.&lt;/p&gt;

&lt;p&gt;The agent accelerates the loop. It does not own the loop.&lt;/p&gt;

&lt;p&gt;This is why AI does not flatten all developers equally. It amplifies what is already there. A developer without judgment can now produce more code than before, which mostly means they can produce more unresolved consequence than before. A developer with judgment can produce more finished thought than before.&lt;/p&gt;

&lt;p&gt;The difference is not typing speed. The difference is taste under acceleration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality was never guaranteed by size
&lt;/h2&gt;

&lt;p&gt;One of the quiet revelations of this era is that large institutions do not automatically produce better artifacts.&lt;/p&gt;

&lt;p&gt;They can produce extraordinary things. They can coordinate missions, operate infrastructure, satisfy regulators, support millions of users, and preserve knowledge across decades. But the artifact in front of the user is not always where that strength appears.&lt;/p&gt;

&lt;p&gt;NASA's &lt;a href="https://www.nasa.gov/ignition/" rel="noopener noreferrer"&gt;Ignition&lt;/a&gt; page is a useful object to look at for this reason. The underlying subject is enormous: Artemis, commercial lunar transportation, moon base capabilities, lunar terrain vehicles, procurement strategy, timelines, technical ambition. The page itself is largely a resource hub: PDFs, videos, advisories, requests for information, presentations, links. That may be the correct institutional shape for NASA's internal and public obligations. It is not the same thing as a product experience that makes the ambition legible.&lt;/p&gt;

&lt;p&gt;This is not a dunk on NASA. NASA can do things that no web developer can do.&lt;/p&gt;

&lt;p&gt;The point is more specific: institutional seriousness does not automatically become interface quality. A large organization can have the facts, the mission, the budget, the experts, and the public mandate, and still produce a web artifact that feels assembled by process rather than shaped by taste.&lt;/p&gt;

&lt;p&gt;That is exactly the kind of gap an AI-amplified master builder can attack. Not because they know more about lunar transportation than NASA. They do not. Because they can take a pile of material, infer the narrative shape, build an explorable interface, tighten the hierarchy, improve the pacing, test the interactions, and iterate before the institutional process has finished deciding which department owns the page.&lt;/p&gt;

&lt;p&gt;The same pattern shows up in developer tooling. &lt;a href="https://pingdotgg-t3code.mintlify.app/introduction" rel="noopener noreferrer"&gt;T3 Code&lt;/a&gt; is interesting not only as a tool for coding agents, but as an artifact of the new workflow. It is a minimal web GUI around agents like Codex, with sessions, git integration, worktrees, runtime modes, and a developer-facing surface designed around actual agent use. Whether or not that particular product becomes the winner is beside the point. Its existence is a sign of the tempo change. A small team can feel a workflow problem, build directly into it, and ship a tool that makes the new loop more usable.&lt;/p&gt;

&lt;p&gt;The old world made this kind of thing harder. The new world makes it common.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small team becomes dangerous again
&lt;/h2&gt;

&lt;p&gt;The small team always had one advantage: fewer people had to agree before the work moved.&lt;/p&gt;

&lt;p&gt;That advantage used to be balanced by a brutal limitation: fewer people could build. A small team could choose quickly but execute slowly once the surface area grew. A large team could choose slowly but execute with force once the organization aligned.&lt;/p&gt;

&lt;p&gt;AI changes the ratio. It gives the small team, and sometimes the single master builder, access to execution capacity that used to require organizational size. It does not give them the large company's distribution, trust, legal department, customer base, or operational maturity. But for many software products, the first decisive question is not "who has the biggest organization?" It is "who can turn a clear product judgment into a working artifact fastest?"&lt;/p&gt;

&lt;p&gt;That is where the small team becomes dangerous.&lt;/p&gt;

&lt;p&gt;Not because bureaucracy is stupid. Bureaucracy is often memory. It is risk encoded as procedure. It is how large systems avoid repeating failures that individuals would happily rediscover. But bureaucracy becomes pathological when it continues to price execution as scarce after execution has become abundant.&lt;/p&gt;

&lt;p&gt;That is the source of the meeting pain.&lt;/p&gt;

&lt;p&gt;The master builder is not angry because other people exist. They are angry because the organization is still spending days converting intent into permission while the toolchain has made it possible to convert intent into a prototype, a test, a diff, a demo, or a shipped internal version. The old process insists on discussing the work in the abstract because it was designed for a world where making the work concrete was expensive.&lt;/p&gt;

&lt;p&gt;In the new world, concreteness is cheap enough to be part of the conversation.&lt;/p&gt;

&lt;p&gt;Instead of six meetings to decide whether an idea is viable, the builder can return with a working version. Instead of arguing about a flow in a document, they can put the flow in front of users. Instead of writing a speculative architecture proposal for a small feature, they can branch, build, test, measure, and throw it away if it fails. The artifact can arrive earlier in the decision process.&lt;/p&gt;

&lt;p&gt;That should make organizations better. Often it will make them uncomfortable first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still belongs to the team
&lt;/h2&gt;

&lt;p&gt;There is an easy but wrong conclusion here: if agents give execution back to individuals, teams no longer matter.&lt;/p&gt;

&lt;p&gt;Teams still matter. They matter most where reality is wider than the artifact.&lt;/p&gt;

&lt;p&gt;A master builder can build a remarkable first version, but production software lives in obligations. Security matters. Accessibility matters. On-call matters. Data retention matters. Customer migration matters. Billing matters. Support matters. Legal review matters. Incident response matters. The larger the promise a product makes to the world, the more the work extends beyond the person who first saw the shape.&lt;/p&gt;

&lt;p&gt;The mistake is not having a team. The mistake is using the team as a substitute for clear intent.&lt;/p&gt;

&lt;p&gt;A healthy team around a master builder should sharpen the artifact, not dissolve it. It should bring constraints into the work at the moment those constraints become real. It should catch risks, improve taste, protect users, and make the result operable. It should not turn every act of building into a negotiation over whether building may begin.&lt;/p&gt;

&lt;p&gt;That is the organizational challenge of AI-assisted engineering. The best teams will learn to let artifacts arrive earlier, then apply discipline around them. The worst teams will keep demanding consensus before concreteness, and they will slowly discover that the builders with the clearest intent have stopped waiting.&lt;/p&gt;

&lt;p&gt;Some will leave to start companies. Some will stay and route around the process. Some will become the people inside large organizations who quietly change the operating model. But the psychological shift is already here: the experienced engineer no longer has to accept that execution belongs somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The work after code gets cheap
&lt;/h2&gt;

&lt;p&gt;When code gets cheap, software does not get easy.&lt;/p&gt;

&lt;p&gt;The hard parts move. Understanding users becomes harder to fake. Taste becomes more visible. QA becomes more important, because the amount of code that can be produced now exceeds the amount of code anyone should trust. Architecture becomes less about preventing people from typing the wrong thing and more about preserving coherence under acceleration. Product judgment becomes load-bearing.&lt;/p&gt;

&lt;p&gt;This is why the master builder matters more, not less.&lt;/p&gt;

&lt;p&gt;The builder is the person who can keep asking the questions the agent cannot answer by itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this the right problem?&lt;/li&gt;
&lt;li&gt;Is this the right shape?&lt;/li&gt;
&lt;li&gt;Did the implementation preserve the intent?&lt;/li&gt;
&lt;li&gt;What did we make harder by making this easy?&lt;/li&gt;
&lt;li&gt;Where is the hidden coupling?&lt;/li&gt;
&lt;li&gt;What would a user misunderstand?&lt;/li&gt;
&lt;li&gt;What will break when the happy path ends?&lt;/li&gt;
&lt;li&gt;Is this good, or merely complete?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions were always part of engineering. AI makes them more central because it makes the lower layers faster. When implementation slows down, weak judgment can hide inside the schedule. When implementation speeds up, weak judgment becomes visible almost immediately.&lt;/p&gt;

&lt;p&gt;That is good news for the kind of developer who has spent years building taste, systems sense, and ownership. It is bad news for organizations that treated those people as interchangeable implementation capacity.&lt;/p&gt;

&lt;p&gt;The master builder was never just a ticket processor. The ticket processor is the part AI threatens most directly. The builder is the person who knows what the tickets should have been, which tickets should not exist, and what artifact the tickets are failing to describe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission was the bottleneck
&lt;/h2&gt;

&lt;p&gt;The deepest change is not that one person can now write more code.&lt;/p&gt;

&lt;p&gt;The deepest change is that one person can now carry an idea farther before asking an organization to believe in it.&lt;/p&gt;

&lt;p&gt;That changes the emotional contract of software work. A developer with a clear idea used to need permission early, because execution required resources. They needed time from other people. They needed a sprint slot. They needed a team. They needed the machinery. The idea had to survive as language long enough to earn the right to become software.&lt;/p&gt;

&lt;p&gt;Now the idea can become software sooner.&lt;/p&gt;

&lt;p&gt;That does not mean it deserves to ship. It does not mean it is correct. It does not mean the builder gets to ignore everyone else. It means the first artifact no longer has to wait for the full social machinery of production software to assemble around it.&lt;/p&gt;

&lt;p&gt;This is the thing many corporate developers feel before they can name it. The meeting hurts because the artifact is now closer than the organization thinks it is. The work is waiting behind a door that used to require a team to open. The builder now has tools in their hands.&lt;/p&gt;

&lt;p&gt;AI agents do not make developers optional. They make engineering judgment more important. They do not remove the need for teams. They remove the automatic advantage of organizational mass. They do not turn software into vibes. They give execution capacity back to the people who can already see the whole thing.&lt;/p&gt;

&lt;p&gt;The master builder is not unleashed because the machine became smart enough to replace them.&lt;/p&gt;

&lt;p&gt;The master builder is unleashed because the machine became useful enough to follow them.&lt;/p&gt;

</description>
      <category>ai</category>
    </item>
    <item>
      <title>A Framework Is Not a Platform</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Wed, 06 May 2026 18:32:23 +0000</pubDate>
      <link>https://dev.to/lazarv/a-framework-is-not-a-platform-33ef</link>
      <guid>https://dev.to/lazarv/a-framework-is-not-a-platform-33ef</guid>
      <description>&lt;p&gt;For most of the time we have been writing web applications, two different teams answered two different questions. The framework team decided what the application looked like. The platform team decided where it ran. The line between the two questions held quietly for thirty years, and it held because nobody seriously challenged it.&lt;/p&gt;

&lt;p&gt;Rails decided how a controller talked to a model. Spring decided how a bean was wired. Express decided what a route handler looked like. None of them decided what database, proxy, cache, message bus, CDN, or regional topology the organization bought.&lt;/p&gt;

&lt;p&gt;That separation was not an accident. It was a property of how those frameworks were built. They produced a process. The process did its job. The infrastructure around the process — the CDN, the cache, the queue, the database, the function runtime, the regional layout — was someone else's job, and that someone else worked on a different review cycle, with different KPIs, accountable to different parts of the org chart.&lt;/p&gt;

&lt;p&gt;The line is being erased, and the cleanest place to see it being erased is Next.js 16. Cache Components did not just change caching. They moved an infrastructure decision into a framework API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handshake we used to have
&lt;/h2&gt;

&lt;p&gt;A Node.js web application running on Kubernetes is a clean handshake. The application produces a request handler. The platform team picks the cluster, the ingress, the CDN, the cache backend, the secrets store, the regional topology, the function runtime if there is one. They pick those things based on cost, security posture, vendor portfolio, contractual obligations, the team's existing operational expertise, and whatever standards the org has already paid down.&lt;/p&gt;

&lt;p&gt;The framework's job, in that handshake, is to be agnostic about all of it. The same code runs behind any reverse proxy. The same code uses whatever cache the platform team chose to put in front of it. The same code can be moved between vendors without changes that touch the application's source — only the deployment surface changes, and the deployment surface is a thin layer the platform team owns end-to-end.&lt;/p&gt;

&lt;p&gt;This is what Incremental Static Regeneration looked like in practice. A Next.js application built with ISR produced HTML files and a small revalidation loop. A CDN sat in front. The CDN served the file. Occasionally, on a stale-while-revalidate window, a function regenerated the file in the background. The shape was familiar to every CDN-fronted Node host. Vercel hosted it; Netlify hosted it; Kubernetes with Cloudflare in front hosted it; a bare VPS with nginx and a cron job hosted a recognizable version of it. The economics were similar everywhere because the architecture was platform-neutral, built from a CDN-and-function shape every platform team already understood.&lt;/p&gt;

&lt;p&gt;That shape is what Cache Components walks away from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What v16 changed
&lt;/h2&gt;

&lt;p&gt;Cache Components, the headline feature of Next.js 16, replaces the route-segment caching model with a directive-based one. A page is dynamic by default. The developer marks regions with &lt;code&gt;'use cache'&lt;/code&gt; to opt those regions into caching. The framework prerenders a static shell where it can, streams the dynamic regions when they resolve, and stitches the response together at request time. Inside the page, the model is elegant. I have written about it from the directive-design angle in &lt;a href="https://dev.to/lazarv/the-cache-belongs-to-the-function-6f5"&gt;The Cache Belongs to the Function&lt;/a&gt; and will not repeat that argument here.&lt;/p&gt;

&lt;p&gt;The argument here is not about what &lt;code&gt;'use cache'&lt;/code&gt; looks like to the developer writing it. It is about what the runtime requires of the infrastructure underneath, once the flag is on.&lt;/p&gt;

&lt;p&gt;A page that uses Cache Components is, mechanically, a page whose response is produced per request by the framework's renderer, with cached fragments spliced in. In the general case, the CDN can no longer serve the full response without invoking the renderer. The static parts of the page exist as cached &lt;em&gt;fragments&lt;/em&gt;, not as cacheable artifacts. The renderer must run, even on a request where every fragment is a hit, because the renderer is what knows how to assemble the fragments into a streamed response.&lt;/p&gt;

&lt;p&gt;This is a small architectural change with large consequences. It moves the unit of caching from "a complete response a CDN can serve" to "a piece of a response the renderer assembles." A CDN is the infrastructure that serves complete responses. It is not the infrastructure that assembles responses from pieces. The framework, in choosing the second model, has chosen to be the assembler — which means the framework has become a piece of infrastructure that did not used to exist between the application and the CDN.&lt;/p&gt;

&lt;p&gt;Once the framework is in the request path on every request, three secondary requirements appear, each of which used to be the platform team's choice and is now the framework's demand. A cache backend has to exist, because the default in-memory cache is per-process; in practice, the framework expects a &lt;code&gt;cacheHandlers&lt;/code&gt; implementation pointing at a real backing store such as Redis. Tag invalidation has to be coordinated across instances, typically by refreshing a local view of shared invalidation state on the request path; in a clustered deployment, that becomes a round trip to shared storage the application did not used to make. The function runtime starts to matter in ways it did not before, because the dynamic-by-default model only amortizes its renderer cost on a platform that multiplexes concurrent requests across warm function invocations; on a platform without that, the cost is paid linearly with traffic.&lt;/p&gt;

&lt;p&gt;None of these requirements are illegitimate as choices. They are illegitimate as &lt;em&gt;framework outputs&lt;/em&gt;. The team did not pick Redis because it wanted Redis; the team did not put a per-request lookup on the request path because it wanted one there; the team did not select a function-runtime billing model because it had a view about how Cache Components should amortize. Redis is not the problem. The problem is when Redis stops being an application choice and becomes part of the framework's performance contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escape hatches that closed
&lt;/h2&gt;

&lt;p&gt;In Next.js 15, the team that wanted to keep the platform-neutral economics had options. Mark a route &lt;code&gt;force-static&lt;/code&gt;. Enable Partial Prerendering per route with &lt;code&gt;experimental_ppr&lt;/code&gt;. Set a route's &lt;code&gt;revalidate&lt;/code&gt; value. Each of those decisions was visible at the route-segment level, and each one was a way for the developer to opt a route into a model the platform team's existing infrastructure already knew how to host.&lt;/p&gt;

&lt;p&gt;In v16, with &lt;code&gt;cacheComponents: true&lt;/code&gt;, those options are gone. The migration guide tells you to delete &lt;code&gt;force-dynamic&lt;/code&gt; and &lt;code&gt;force-static&lt;/code&gt;. The &lt;code&gt;experimental_ppr&lt;/code&gt; segment configuration is removed. The &lt;code&gt;revalidate&lt;/code&gt; and &lt;code&gt;fetchCache&lt;/code&gt; exports are replaced by &lt;code&gt;cacheLife&lt;/code&gt; inside &lt;code&gt;'use cache'&lt;/code&gt; boundaries. The route-segment escape hatches that used to let an application express "this page is static, please serve it as a file" are no longer in the API.&lt;/p&gt;

&lt;p&gt;The flag is opt-in, today. A team that wants the v15 economics can leave it off. But the docs already treat Cache Components as the recommended path, the dedicated PPR test suites in the repository are migrating away from a separate identity, and the trajectory of any flag that the framework team owns and recommends is well-known. Within a release or two, the recommended path becomes the default. Within a release or two after that, the legacy path becomes deprecated. The ability to refuse the new model is on a clock, and the clock is the framework team's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technically portable, economically captive
&lt;/h2&gt;

&lt;p&gt;The runtime is open source. The contract is documented. The adapters work. By the strict definition of vendor lock-in — &lt;em&gt;you cannot leave&lt;/em&gt; — there is no lock-in. Every claim a salesperson would make about the framework's portability is true.&lt;/p&gt;

&lt;p&gt;The honest definition of lock-in is not the strict one. The honest definition is: &lt;em&gt;you can leave, but the cost of leaving is large enough to change the build-vs-buy decision.&lt;/em&gt; Under that definition, Cache Components introduces a soft form of capture that ISR did not have. The runtime runs anywhere; the cost-effectiveness lives on one platform. Off that platform, the same code shape produces a meaningfully worse cost profile, a meaningfully higher operational burden, and a meaningfully lower performance ceiling.&lt;/p&gt;

&lt;p&gt;The performance ceiling is the part that is hardest to recover. On a platform that owns both the proxy and the function runtime, the static shell of a Cache-Components page can be served from the edge before the renderer is even invoked, with the dynamic stream stitched into the same response over a single connection. This is not a standard CDN primitive. It is not the contract a generic CDN signs with the application in front of it — serve a complete response, or proxy through to the origin and serve that. The handoff between a static shell and a function-produced stream, on the same connection, mid-response, is a vendor-aware proxy/runtime product. It can be built; it has not been standardized; and the team that wants it on Kubernetes is not picking it from a menu of CDN features. They are integrating bespoke pieces, or they are accepting a TTFB floor of "pod-reachable plus first render byte" instead of "edge node plus first static byte." The gap is structural, not operational.&lt;/p&gt;

&lt;p&gt;The question is not whether another platform can build the missing machinery. The question is whether an application framework should require that machinery to recover the economics it used to preserve by default.&lt;/p&gt;

&lt;p&gt;None of this is impossible to operate. It is only impossible to operate &lt;em&gt;optimally&lt;/em&gt;, because the optimum has been moved to a place only one vendor lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, beyond Next.js
&lt;/h2&gt;

&lt;p&gt;Next.js is the most aggressive case, but it is not the only framework being pulled in this direction, and the direction is the more interesting story than any one framework.&lt;/p&gt;

&lt;p&gt;Remix and React Router 7 sit at the other end of the spectrum, partly by inheritance and partly by deliberate choice. The cache contract has historically been a &lt;code&gt;headers()&lt;/code&gt; function on a loader returning standard &lt;code&gt;Cache-Control&lt;/code&gt; directives. The CDN does what CDNs do; the framework does not need a backing store, a tag manifest, or a request-time invalidation hook. Whether that posture survives future product pressure is an open question, but today the cache story is platform-neutral by construction.&lt;/p&gt;

&lt;p&gt;SvelteKit and Astro preserve the older bargain through adapters and static-first output. The application produces a generic artifact; the adapter materializes it into a deployment-specific shape only when the application has earned a dynamic runtime. The specifics stay at the deployment seam rather than seeping into the application source.&lt;/p&gt;

&lt;p&gt;Nuxt sits in the middle. Nitro's caching primitives are function-level and storage-pluggable rather than render-coupled, so a Nuxt application can express a cached value without dragging the rendering pipeline into the request path. The framework has caching, but it has not annexed caching as infrastructure.&lt;/p&gt;

&lt;p&gt;TanStack Start sits on a different axis altogether. It is router-and-query first, not renderer-and-cache first. Its primitives — TanStack Router, TanStack Query, server functions, loaders — describe what data should flow where, not what infrastructure should hold the cache. The cache lives with the query, function-level and storage-pluggable, the way TanStack Query has always shipped it. The framework does not need a Redis backing store, a tag manifest, or a request-time invalidation hook to be correct; the application's freshness is a property of its queries, not of the framework's renderer. It is a different architecture from Next.js, not a competing implementation of the same one.&lt;/p&gt;

&lt;p&gt;The structural caution is general, not aimed at any one project: a framework that adopts the renderer-and-cache architecture without the matching platform machinery inherits the hard part without inheriting the economic advantage.&lt;/p&gt;

&lt;p&gt;Some runtimes refuse this trade by construction. That is the line I have tried to hold in &lt;code&gt;@lazarv/react-server&lt;/code&gt; — a cache primitive that lives with the function, a router that is opt-in rather than load-bearing, a deployment story handled at the build seam rather than at the source. Hono, Fastify, Express, the older Node frameworks never had this problem because they never tried to absorb infrastructure decisions in the first place. They stay frameworks because they stay small.&lt;/p&gt;

&lt;p&gt;The point is not that every framework should look like the smaller ones. The point is that there is a spectrum, the spectrum has been visible for years, and the choice each framework makes about where to sit on it shapes the economics of every team that picks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "framework" used to mean
&lt;/h2&gt;

&lt;p&gt;A framework, historically, is a thing you pick up to write an application. The decision is local. The team's senior engineer reads two days of docs, the team's frontend lead does a spike, the team picks one, and the work moves forward. The decision does not require sign-off from security, platform, FinOps, procurement, or an architecture review board. It does not need to, because the framework's blast radius is the application source.&lt;/p&gt;

&lt;p&gt;A platform is a thing you provision. The decision is organizational. It involves vendor risk review, multi-year contracts, integration with the org's authentication and observability, alignment with the org's existing infrastructure, and the long tail of "what happens if this provider gets acquired" thinking. Those reviews exist because the wrong platform decision is hard to walk back, and because the people who feel the consequences are not the same people who made the call.&lt;/p&gt;

&lt;p&gt;When a framework's correctness and performance start to require a specific cache topology, a specific function runtime, a specific proxy behavior, the framework has crossed the category line. Picking it is no longer a local decision. It is a platform decision dressed as a framework decision, and the people who would normally weigh in on a platform decision are not in the room when it is made. The frontend lead picks Next.js because Next.js is what frontend leads pick; the cost of that choice shows up months later, in a Redis bill, in a Lambda invocation count, in a p99 graph that nobody can explain to the CFO without a paragraph of caveats.&lt;/p&gt;

&lt;p&gt;This is the part of the trade that does not recover quickly. Money recovers. A team can switch frameworks; it is painful but bounded. What does not recover is the org's awareness that infrastructure was a thing the org was supposed to choose. The next framework that ships on the same model finds the ground already prepared. Each one normalizes the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line we forgot
&lt;/h2&gt;

&lt;p&gt;A framework is not a platform, and a platform should not pretend to be a framework.&lt;/p&gt;

&lt;p&gt;The honest test for any tool wearing the framework label is the one this article has been circling. &lt;em&gt;What infrastructure does it require us to operate? What is the degraded-mode cost if we don't?&lt;/em&gt; A tool whose answers are "your existing Node host, and roughly the same as before" is a framework. A tool whose answers are "vendor-shaped infrastructure, and meaningfully worse" is something else. It does not have to be a worse thing. It does have to be named for what it is, because the people responsible for the answers to those two questions used to be the ones making the decision.&lt;/p&gt;

&lt;p&gt;The dev/ops handshake we used to have was not nostalgia. It was a real division of labor that let frameworks evolve without dragging infrastructure along, and let platforms evolve without rewriting applications. It let teams stay in motion. It let small projects stay small. It let large projects choose where they ran on the basis of their own constraints, not the framework's.&lt;/p&gt;

&lt;p&gt;We are losing that division of labor one framework choice at a time, mostly without noticing, and the cost is showing up in places — bills, latency floors, operational complexity, vendor leverage — that nobody connected to the original decision back when it was just "what should we use to build the app."&lt;/p&gt;

&lt;p&gt;A framework should be replaceable without replacing the infrastructure underneath it. Infrastructure should not become a consequence of the framework. When those two roles invert, the team has stopped owning the most important architectural surface in the system, and the framework's authors have started.&lt;/p&gt;

&lt;p&gt;A framework is not a platform. The two have always known what they were. We are the ones who forgot.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>redis</category>
      <category>architecture</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Time to Yield</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Sun, 03 May 2026 12:10:32 +0000</pubDate>
      <link>https://dev.to/lazarv/time-to-yield-20m8</link>
      <guid>https://dev.to/lazarv/time-to-yield-20m8</guid>
      <description>&lt;p&gt;&lt;em&gt;An SSG benchmark across five React frameworks, from one thousand&lt;br&gt;
pages to half a million.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You're building a marketplace. Or a documentation site. A wiki,&lt;br&gt;
a generated archive, any of a dozen things that ship a static&lt;br&gt;
catalogue at scale. Your CMS has a hundred thousand entries.&lt;br&gt;
You've picked your SSG. You run the build.&lt;/p&gt;

&lt;p&gt;Five minutes. Ten. Twenty. Maybe an hour. Maybe a stack trace.&lt;/p&gt;

&lt;p&gt;You don't know in advance — and the public benchmarks won't tell&lt;br&gt;
you. Most stop at a thousand pages, where most real catalogues&lt;br&gt;
start. The gap between what gets measured and what gets shipped&lt;br&gt;
is where the unpleasant surprises live, and the engineer who has&lt;br&gt;
to ship into that gap usually finds out which side of it their&lt;br&gt;
tool was designed for at deploy time.&lt;/p&gt;

&lt;p&gt;So I built a &lt;a href="https://github.com/lazarv/ssg-bench" rel="noopener noreferrer"&gt;benchmark&lt;/a&gt; for the gap.&lt;/p&gt;


&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;Five frameworks in a pnpm workspace, each rendering one dynamic&lt;br&gt;
route &lt;code&gt;/posts/[id]&lt;/code&gt; from a shared deterministic data source. Same&lt;br&gt;
content, same shape, idiomatic config per tool. The output has to&lt;br&gt;
be pure deployable static HTML — no Node runtime is allowed at&lt;br&gt;
request time, which is the whole point of SSG. The harness sweeps&lt;br&gt;
&lt;code&gt;PAGE_COUNT&lt;/code&gt; across &lt;code&gt;1k → 10k → 100k → 200k → 300k → 400k → 500k&lt;/code&gt;,&lt;br&gt;
measures wall time, time-to-first-page (TTFP), peak RSS, output&lt;br&gt;
size, and validates a sample of generated HTML actually contains&lt;br&gt;
the right &lt;code&gt;Post #N&lt;/code&gt; content. It's all in&lt;br&gt;
&lt;a href="https://github.com/lazarv/ssg-bench/blob/main/bench" rel="noopener noreferrer"&gt;&lt;code&gt;bench/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The contestants
&lt;/h2&gt;

&lt;p&gt;Five different bets on what static-site generation should look&lt;br&gt;
like in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js (&lt;code&gt;apps/next&lt;/code&gt;)&lt;/strong&gt; — Vercel's framework, version 16, App&lt;br&gt;
Router and Turbopack. The most-deployed React tool in the world&lt;br&gt;
and the default reference point for any tooling comparison. Its&lt;br&gt;
strengths are well documented elsewhere; what this benchmark&lt;br&gt;
exercises is one of its many output modes — &lt;code&gt;output: "export"&lt;/code&gt;,&lt;br&gt;
the fully static path with no Node runtime at request time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TanStack Start (&lt;code&gt;apps/tanstack&lt;/code&gt;)&lt;/strong&gt; — the youngest entry, from&lt;br&gt;
the team behind TanStack Router and Query. Vite plus a Nitro-&lt;br&gt;
backed prerender plugin, file-system routing, currently in the&lt;br&gt;
1.x line and rapidly evolving. Prerendering takes a materialized&lt;br&gt;
&lt;code&gt;pages&lt;/code&gt; array of paths declared inside the Vite config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gatsby (&lt;code&gt;apps/gatsby&lt;/code&gt;)&lt;/strong&gt; — the old guard. GraphQL-driven by&lt;br&gt;
default, Redux-backed build cache, a sprawling plugin ecosystem,&lt;br&gt;
now maintained by Netlify after acquisition. It pre-dates every&lt;br&gt;
other entry here by years and has a distinct mental model:&lt;br&gt;
imperative &lt;code&gt;createPage&lt;/code&gt; calls inside a &lt;code&gt;gatsby-node.mjs&lt;/code&gt;&lt;br&gt;
lifecycle hook. People left it for Next.js partly because Gatsby&lt;br&gt;
builds were slow at scale; it's interesting to find out whether&lt;br&gt;
that's still the relevant fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Astro (&lt;code&gt;apps/astro&lt;/code&gt;)&lt;/strong&gt; — a static-first multi-framework site&lt;br&gt;
builder. Strictly speaking it isn't running React in this&lt;br&gt;
benchmark; pages are written in Astro's own &lt;code&gt;.astro&lt;/code&gt; template&lt;br&gt;
language with a fast static optimizer. It's included as the&lt;br&gt;
ceiling — the answer to "how fast can a non-React SSG go?" —&lt;br&gt;
against which the React-runtime entries can be measured fairly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@lazarv/react-server" rel="noopener noreferrer"&gt;@lazarv/react-server&lt;/a&gt; (&lt;code&gt;apps/react-server&lt;/code&gt;)&lt;/strong&gt; —&lt;br&gt;
an open React Server Components runtime built on Vite 8's&lt;br&gt;
Environment API with Rolldown as the production bundler.&lt;br&gt;
Disclosure: I wrote it. It's in this comparison because it's the&lt;br&gt;
only React-runtime entry whose static-export pipeline accepts a&lt;br&gt;
streaming path source — which, as the rest of this article will&lt;br&gt;
show, turns out to be the decisive design choice.&lt;/p&gt;
&lt;h2&gt;
  
  
  The headline
&lt;/h2&gt;

&lt;p&gt;At a thousand pages, every modern tool finishes in seconds and&lt;br&gt;
the table is a wash. At ten thousand, the leaders pull a small&lt;br&gt;
lead. The interesting story starts at a hundred thousand. The&lt;br&gt;
decisive story starts above two hundred thousand.&lt;/p&gt;

&lt;p&gt;I'll give you the whole thing chart by chart, but here's the&lt;br&gt;
spoiler. At 100,000 pages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;wall&lt;/th&gt;
&lt;th&gt;ttfp&lt;/th&gt;
&lt;th&gt;output bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Astro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;22.6s&lt;/td&gt;
&lt;td&gt;2.18s&lt;/td&gt;
&lt;td&gt;47 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;26.1s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.63s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;83 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TanStack Start&lt;/td&gt;
&lt;td&gt;36.9s&lt;/td&gt;
&lt;td&gt;2.65s&lt;/td&gt;
&lt;td&gt;172 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gatsby&lt;/td&gt;
&lt;td&gt;62.1s&lt;/td&gt;
&lt;td&gt;7.91s&lt;/td&gt;
&lt;td&gt;189 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;264.5s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;124s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.84 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 200,000 pages, Next.js's build crashes — exit 1, no HTML.&lt;/p&gt;


&lt;h2&gt;
  
  
  The chart that broke the pattern
&lt;/h2&gt;

&lt;p&gt;Most benchmark charts are roughly parallel lines: the same&lt;br&gt;
ranking from one page count to the next, gaps roughly constant,&lt;br&gt;
nothing that asks you to stop and look. This one isn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fllrtu2lp4i13ao3vz6ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fllrtu2lp4i13ao3vz6ii.png" alt="Time to first page" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;react-server's TTFP is a flat line. From a thousand pages to half&lt;br&gt;
a million, the time between "I started the build" and "the first&lt;br&gt;
HTML file appeared on disk" stays between 1.4 and 3.2 seconds.&lt;br&gt;
Astro and TanStack Start curve gently upward. Gatsby's curve&lt;br&gt;
starts mid-air at 5 seconds and climbs to over a hundred. Next.js&lt;br&gt;
sits between them within its working range, climbing from 2.9s at&lt;br&gt;
1k pages to 124s at 100k.&lt;/p&gt;

&lt;p&gt;What you're looking at is a single architectural decision, made&lt;br&gt;
once, repeated through every layer of each pipeline. One framework&lt;br&gt;
streams its work. The others batch it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Yield, don't return
&lt;/h2&gt;

&lt;p&gt;When you tell an SSG to render &lt;code&gt;/posts/[id]&lt;/code&gt; for many IDs, it has&lt;br&gt;
to ask you for the list. The shape of that question — the API your&lt;br&gt;
config file uses — turns out to determine almost everything else.&lt;/p&gt;

&lt;p&gt;Most frameworks ask you for an array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js — apps/next/app/posts/[id]/page.jsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;allIds&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
// Astro — apps/astro/src/pages/posts/[id].astro
export async function getStaticPaths() {
  return allIds().map((id) =&amp;gt; ({ params: { id: String(id) } }));
}
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TanStack Start — apps/tanstack/vite.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;allIds&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prerender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/index.html`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape is identical: build an array, return an array. The&lt;br&gt;
runtime then has to materialize that array — all hundred thousand&lt;br&gt;
elements of it — before any rendering can start. The first page&lt;br&gt;
of HTML cannot be written before the last entry of the path list&lt;br&gt;
has been allocated.&lt;/p&gt;

&lt;p&gt;react-server asks the same question differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// react-server — apps/react-server/src/pages/posts/[id].static.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;idStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@ssg-test/shared&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;idStream&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's an async generator. The router pulls one descriptor at a&lt;br&gt;
time, when a render worker is free. The path list is never in&lt;br&gt;
memory all at once; peak memory of the path source is &lt;code&gt;O(1)&lt;/code&gt;,&lt;br&gt;
regardless of N. As soon as the first descriptor is yielded, the&lt;br&gt;
first page can render. As soon as the first page renders, it lands&lt;br&gt;
on disk. The rest of the build is just keeping the workers fed.&lt;/p&gt;

&lt;p&gt;The runtime documents this contract explicitly at&lt;br&gt;
&lt;a href="https://react-server.dev/router/static#streaming-static-paths" rel="noopener noreferrer"&gt;react-server.dev/router/static#streaming-static-paths&lt;/a&gt;&lt;br&gt;
— and the detection is by &lt;strong&gt;function kind&lt;/strong&gt;: write &lt;code&gt;async&lt;br&gt;
function*&lt;/code&gt; directly as the default export, or fall back to the&lt;br&gt;
legacy array contract. There's no opt-in flag. The shape of your&lt;br&gt;
function is the shape of the build.&lt;/p&gt;

&lt;p&gt;You can chain the same idea at the config level, which is what the&lt;br&gt;
benchmark does to skip RSC payload sidecars (the other frameworks&lt;br&gt;
emit HTML only; we want the bytes column to compare like with like):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// react-server — apps/react-server/react-server.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/pages&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="k"&gt;export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rsc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;async function*&lt;/code&gt; shapes — one in the route, one in the&lt;br&gt;
config. The whole streaming property of the build comes from&lt;br&gt;
those two declarations. Look at the TTFP chart again with this in&lt;br&gt;
mind: react-server is renderer-bound; everyone else is array-bound.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things start to fall apart at a hundred thousand
&lt;/h2&gt;

&lt;p&gt;If TTFP is the early-warning signal, total wall time is where the&lt;br&gt;
architecture pays its real bill.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ircrhbjop5gj1za5jeu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ircrhbjop5gj1za5jeu.png" alt="Build wall time" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At a thousand pages, every framework here finishes in single-digit&lt;br&gt;
seconds and you'd struggle to feel the difference in a CI log. The&lt;br&gt;
slope of the curves is what matters, and the slope diverges hard&lt;br&gt;
above ten thousand.&lt;/p&gt;

&lt;p&gt;By a hundred thousand pages, react-server has finished in &lt;strong&gt;26&lt;br&gt;
seconds&lt;/strong&gt;. Astro, the leader, in &lt;strong&gt;22.6 seconds&lt;/strong&gt;. TanStack Start&lt;br&gt;
in 37. Gatsby in just over a minute.&lt;/p&gt;

&lt;p&gt;Next.js takes &lt;strong&gt;four and a half minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For the same work. Same content, same hundred thousand pages on&lt;br&gt;
disk. Next.js's curve is steeper than linear above 50k pages, and&lt;br&gt;
by 100k the wall time is into the "go for a coffee" territory&lt;br&gt;
that distinguishes a benchmark from a real engineering decision.&lt;/p&gt;

&lt;p&gt;The other notable result at this scale: at 100,000 pages, Gatsby&lt;br&gt;
finishes faster than Next.js. 62 seconds versus 264. Gatsby&lt;br&gt;
has a long-standing reputation for slow builds at scale, and&lt;br&gt;
that reputation isn't unfair, but on this specific workload it&lt;br&gt;
crosses the line first. The framework people moved off of for&lt;br&gt;
build performance is now, on this measurement, the faster of&lt;br&gt;
the two.&lt;/p&gt;

&lt;p&gt;The same data reads sharper as throughput: pages produced per&lt;br&gt;
second, the per-page work each framework does once it's warmed&lt;br&gt;
up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fab0nkqs2drpckfhl67kk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fab0nkqs2drpckfhl67kk.png" alt="Throughput" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The four frameworks that complete the workload all reach a&lt;br&gt;
plateau somewhere above ten thousand pages — a steady-state&lt;br&gt;
pages-per-second ceiling that holds up the rest of the way.&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server runs around 3,000–3,800 pages/s; Astro&lt;br&gt;
3,000–4,400; TanStack Start 1,900–2,700; Gatsby 1,500–1,700.&lt;br&gt;
The plateaus tell you how much overhead each framework has&lt;br&gt;
amortized away once the build is steady.&lt;/p&gt;

&lt;p&gt;Next.js never reaches a plateau. Its throughput peaks at 480&lt;br&gt;
pages/s at 10k, drops to 378 pages/s at 100k, and crashes before&lt;br&gt;
it can be measured at higher counts. The build is doing &lt;strong&gt;more&lt;br&gt;
work per page as the page count grows&lt;/strong&gt; — the opposite of what&lt;br&gt;
amortization should produce. That trajectory is what makes the&lt;br&gt;
next section's failure mode predictable in retrospect: a&lt;br&gt;
pipeline whose per-page cost is increasing was always going to&lt;br&gt;
hit a ceiling.&lt;/p&gt;
&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;Then I cranked the count to two hundred thousand.&lt;/p&gt;

&lt;p&gt;The build crashed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RangeError: Maximum call stack size exceeded
    at ignore-listed frames

&amp;gt; Build error occurred
Error: Failed to collect page data for /posts/[id]
    at ignore-listed frames {
  type: 'Error'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three seconds of CPU. No HTML. Exit code 1. Next.js's "collect&lt;br&gt;
page data" phase — the step that runs after Turbopack compiles&lt;br&gt;
your app and before the worker pool starts rendering — overflows&lt;br&gt;
V8's call stack.&lt;/p&gt;

&lt;p&gt;I bumped to 300k, 400k, 500k. Same crash, every time. The error&lt;br&gt;
itself is forthright: stack overflow, here's the phase. What the&lt;br&gt;
error can't tell you is that the input the pipeline cannot handle&lt;br&gt;
is your own page list, and that there is no flag in &lt;code&gt;next.config&lt;/code&gt;&lt;br&gt;
to ask for a different consumer of it.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;RangeError: Maximum call stack size exceeded&lt;/code&gt; is a recursion&lt;br&gt;
fingerprint. Something in Next's pipeline is walking the params&lt;br&gt;
array via naive recursion — JSON-serializing it, normalizing it,&lt;br&gt;
hashing it for the data cache, building a tree from it, take your&lt;br&gt;
pick — with recursion depth proportional to the array length&lt;br&gt;
itself, not to its log. (A balanced-tree traversal would push&lt;br&gt;
log₂(200,000) ≈ 18 frames; nowhere near a stack limit. The&lt;br&gt;
overflow only makes sense if each entry contributes a constant&lt;br&gt;
share of frames.) At 100k entries the depth still fits inside&lt;br&gt;
V8's default ~10k-frame stack. At 200k it doesn't.&lt;/p&gt;

&lt;p&gt;This is not something &lt;code&gt;--max-old-space-size=8192&lt;/code&gt; can fix (we&lt;br&gt;
tried). It's not a memory issue at all. It's an &lt;strong&gt;algorithmic&lt;br&gt;
ceiling&lt;/strong&gt;: Next.js's page-data collection is implemented as&lt;br&gt;
recursive traversal over the materialized params array, and that&lt;br&gt;
recursion has a depth limit baked into the JavaScript engine. You&lt;br&gt;
cannot grow your way past it. There is no flag because there is&lt;br&gt;
no scalar to turn.&lt;/p&gt;

&lt;p&gt;The runtime &lt;em&gt;requires&lt;/em&gt; the array contract — &lt;code&gt;generateStaticParams&lt;/code&gt;&lt;br&gt;
must return one — and the pipeline that consumes it cannot tolerate&lt;br&gt;
arrays past a certain size. Both halves of that statement are&lt;br&gt;
architecture, not bugs.&lt;/p&gt;

&lt;p&gt;react-server, on the same hardware, with the same content, spent&lt;br&gt;
&lt;strong&gt;155 seconds&lt;/strong&gt; on five hundred thousand pages. First HTML on&lt;br&gt;
disk: 2.87 seconds. The same TTFP it has at a thousand pages.&lt;br&gt;
Nothing in its pipeline ever sees a 500,000-element array, because&lt;br&gt;
nothing in its pipeline is allowed to construct one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually in the output directory
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F651z8ubr6ggoupdghkie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F651z8ubr6ggoupdghkie.png" alt="Deployable output size" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the wall time is the loud problem, the output bytes are the&lt;br&gt;
quiet one. At 100,000 pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Astro emits &lt;strong&gt;47 MiB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;react-server emits &lt;strong&gt;83 MiB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;TanStack Start emits &lt;strong&gt;172 MiB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Gatsby emits &lt;strong&gt;189 MiB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Next.js emits &lt;strong&gt;1.84 GiB&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At 100k pages, the deployable Next.js output is roughly forty&lt;br&gt;
times larger than Astro's and twenty times larger than react-&lt;br&gt;
server's. The bulk of it is per-page files: a &lt;code&gt;.txt&lt;/code&gt; RSC payload&lt;br&gt;
sidecar for every route, used to power client-router prefetch on&lt;br&gt;
navigation, plus a runtime bundle the page links to for hydration&lt;br&gt;
even on routes without &lt;code&gt;"use client"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both files are part of the App Router's contract: the &lt;code&gt;.txt&lt;/code&gt;&lt;br&gt;
payload exists so the client router can prefetch, the runtime&lt;br&gt;
exists so client components can hydrate. They're features of the&lt;br&gt;
deployment topology Next.js is designed for. The trade-off, when&lt;br&gt;
the deployment is fully static and no client component is ever&lt;br&gt;
going to run, is that the contract still ships. There's no&lt;br&gt;
documented flag to drop either for &lt;code&gt;output: "export"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;react-server makes the equivalent choice in the opposite&lt;br&gt;
direction: emit HTML only by default for fully static export, and&lt;br&gt;
let the user opt back into RSC payload sidecars per path if they&lt;br&gt;
want them. The benchmark's config-level &lt;code&gt;export()&lt;/code&gt; hook tags every&lt;br&gt;
yielded path with &lt;code&gt;rsc: false&lt;/code&gt; to keep the bytes column comparing&lt;br&gt;
HTML to HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory: where Gatsby still hurts and &lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server stays quiet
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjv319xmw67jji8m3toqs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjv319xmw67jji8m3toqs.png" alt="Peak resident memory" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The memory chart is shaped a lot like the wall chart, with one&lt;br&gt;
outlier: Gatsby. Gatsby's build cache is a Redux store that&lt;br&gt;
appends every &lt;code&gt;createPage&lt;/code&gt; call into in-memory state, and it never&lt;br&gt;
sheds that state until the build finishes. At 500k pages, Gatsby's&lt;br&gt;
peak resident set hits &lt;strong&gt;9.55 GiB&lt;/strong&gt;. Long-time Gatsby users will&lt;br&gt;
be unsurprised; this is what &lt;code&gt;gatsby build&lt;/code&gt; has always done.&lt;/p&gt;

&lt;p&gt;react-server holds between &lt;strong&gt;1.2 GiB at a thousand pages and 2.6&lt;br&gt;
GiB at half a million&lt;/strong&gt; — essentially flat above 10k. TanStack&lt;br&gt;
Start ranges from &lt;strong&gt;600 MiB at 1k to 3.6 GiB at 400k&lt;/strong&gt; before&lt;br&gt;
nudging back down to 3.1 GiB at 500k. Astro is the leanest of all&lt;br&gt;
at &lt;strong&gt;0.6 to 1.8 GiB&lt;/strong&gt; across the same range.&lt;/p&gt;

&lt;p&gt;The streaming path source is one reason react-server's memory&lt;br&gt;
curve flattens. The bigger reason is what it doesn't accumulate:&lt;br&gt;
no per-route manifest, no fingerprinted asset graph for every&lt;br&gt;
page, no client-router prefetch index. Whatever doesn't exist&lt;br&gt;
doesn't take memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note about Astro
&lt;/h2&gt;

&lt;p&gt;Astro is the fastest tool in this benchmark. It deserves the&lt;br&gt;
credit, with one important asterisk: &lt;strong&gt;Astro isn't running React&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;apps/astro/src/pages/posts/[id].astro&lt;/code&gt;, the page is written in&lt;br&gt;
Astro's own template language. There's no React reconciler, no&lt;br&gt;
hydration framework, no Server Components flight protocol — it's&lt;br&gt;
closer to JSX-flavored server-side templating with a fast static&lt;br&gt;
optimizer. Astro is the &lt;em&gt;right ceiling&lt;/em&gt; for "what can a static-&lt;br&gt;
site generator do at all," but it isn't an apples-to-apples&lt;br&gt;
comparison with React-runtime tools.&lt;/p&gt;

&lt;p&gt;Which makes the next sentence the actual story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server matches Astro.&lt;/strong&gt; Within ~15% on wall time&lt;br&gt;
at 100k (26s vs 22.6s), and &lt;strong&gt;faster on TTFP&lt;/strong&gt; (1.63s vs 2.18s). And it&lt;br&gt;
does this while running the actual React Server Components&lt;br&gt;
production server — the same one a deployment would serve at&lt;br&gt;
request time, bundled by Vite 8 and Rolldown, driven by a&lt;br&gt;
streaming path source. The HTML on disk after the export is the&lt;br&gt;
HTML the production server would have produced for a real&lt;br&gt;
request. A real React runtime moving at static-template-engine&lt;br&gt;
speed.&lt;/p&gt;

&lt;p&gt;That isn't a result you get by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;A React Server Components runtime keeping pace with a static-&lt;br&gt;
template engine doesn't happen because someone optimized a hot&lt;br&gt;
loop. It happens because the architecture has fewer places for&lt;br&gt;
work to pile up. Five things contribute, and none of them are&lt;br&gt;
clever; they're all just the absence of unnecessary buffers.&lt;/p&gt;

&lt;p&gt;The build phase produces a &lt;strong&gt;real production server&lt;/strong&gt;. Vite 8 and&lt;br&gt;
Rolldown bundle the runtime exactly as it would run at request&lt;br&gt;
time; the static export then starts that bundled server and asks&lt;br&gt;
it to render each yielded path. The thing that produces your HTML&lt;br&gt;
during the export is the same thing that would serve your HTML if&lt;br&gt;
you weren't exporting. There is no separate build-only renderer,&lt;br&gt;
no compile-time-only sandbox, no special static-export pipeline&lt;br&gt;
running its own copy of half the framework. Whatever the&lt;br&gt;
production server can render at request time, the export can&lt;br&gt;
produce. Two phases — bundle, then render — but the second phase&lt;br&gt;
is the production server you'd deploy, not a parallel universe&lt;br&gt;
of build-time machinery.&lt;/p&gt;

&lt;p&gt;The static path source &lt;strong&gt;streams by contract&lt;/strong&gt;. Both &lt;code&gt;[id]&lt;br&gt;
.static.mjs&lt;/code&gt; and the config-level &lt;code&gt;export()&lt;/code&gt; are &lt;code&gt;async function*&lt;/code&gt;&lt;br&gt;
shapes that the router pulls from. Memory of the path source is&lt;br&gt;
&lt;code&gt;O(1)&lt;/code&gt;. Rendering can start on the first yielded path.&lt;/p&gt;

&lt;p&gt;Render workers are &lt;strong&gt;driven by the stream&lt;/strong&gt;. The&lt;br&gt;
&lt;code&gt;--export-concurrency&lt;/code&gt; flag forks N child processes; each runs its&lt;br&gt;
own RSC main thread plus an SSR worker thread; the coordinator&lt;br&gt;
dispatches one path per free worker. Output bytes never cross the&lt;br&gt;
IPC boundary — every artifact (HTML, optional &lt;code&gt;.gz&lt;/code&gt; / &lt;code&gt;.br&lt;/code&gt;&lt;br&gt;
sidecars, postponed-fragment cache) is written to disk inside the&lt;br&gt;
child. There is no central "collect page data" buffer because&lt;br&gt;
there is no central buffer.&lt;/p&gt;

&lt;p&gt;There is &lt;strong&gt;no per-page runtime tax&lt;/strong&gt;. Pages without &lt;code&gt;"use client"&lt;/code&gt;&lt;br&gt;
get pure HTML. The runtime doesn't inject bootstrap scripts,&lt;br&gt;
doesn't write &lt;code&gt;_buildManifest.js&lt;/code&gt;, doesn't emit per-page payload&lt;br&gt;
sidecars unless you ask. The 22× output-size delta vs. Next.js&lt;br&gt;
collapses to: emit only what the page needs.&lt;/p&gt;

&lt;p&gt;And there is &lt;strong&gt;no extra compiler in the path&lt;/strong&gt;. No Turbopack-&lt;br&gt;
style parallel compiler stack, no SWC custom plugins, no static-&lt;br&gt;
build renderer that's a different runtime from the production&lt;br&gt;
server. Vite 8, Rolldown, Node, JavaScript — and the runtime&lt;br&gt;
itself. Phase 2 is just the runtime. Fewer moving parts than its&lt;br&gt;
peers, which is precisely why fewer of them break at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means if you have to pick
&lt;/h2&gt;

&lt;p&gt;If you have a thousand pages, all of these tools work. The&lt;br&gt;
differences are noise. Pick on developer experience.&lt;/p&gt;

&lt;p&gt;If you have ten thousand, Next.js is already five times slower&lt;br&gt;
than the leaders. Worth knowing before your next pitch deck.&lt;/p&gt;

&lt;p&gt;If you have a hundred thousand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro&lt;/strong&gt; is the fastest if you don't need React.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server&lt;/strong&gt; is the fastest React runtime that
completes the workload, on par with Astro while running RSC
end-to-end, with the smallest HTML-only output of any React
option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Start&lt;/strong&gt; completes but loses time to the materialized
&lt;code&gt;pages&lt;/code&gt; array.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gatsby&lt;/strong&gt; completes, slowly, with high memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; completes but takes about ten times as long as the
leaders and emits roughly twenty times the bytes; both numbers
follow from defaults that aren't configurable away in the
&lt;code&gt;output: "export"&lt;/code&gt; path today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have two hundred thousand pages or more of pre-rendered&lt;br&gt;
routes — a CMS-backed catalogue, a docs archive, a programmatically&lt;br&gt;
generated index — &lt;strong&gt;Next.js's static-export pipeline does not&lt;br&gt;
complete.&lt;/strong&gt; The build crashes with a &lt;code&gt;RangeError: Maximum call&lt;br&gt;
stack size exceeded&lt;/code&gt; during page-data collection. The failure is&lt;br&gt;
recursion depth in V8, not heap size, so it isn't fixable by&lt;br&gt;
flags or environment variables. The right framing is that&lt;br&gt;
&lt;code&gt;output: "export"&lt;/code&gt; at this scale isn't a supported topology for&lt;br&gt;
Next.js — its answer for catalogues this large is ISR, which is a&lt;br&gt;
different topology, which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  But what about ISR?
&lt;/h2&gt;

&lt;p&gt;Whenever the sentence "Next.js can't pre-render two hundred&lt;br&gt;
thousand pages" appears in public, someone responds: just use ISR.&lt;/p&gt;

&lt;p&gt;Incremental Static Regeneration is Next.js's answer to large&lt;br&gt;
catalogues. Don't pre-render every page at build time. Build the&lt;br&gt;
app shell, deploy it, and have the runtime generate each page on&lt;br&gt;
first request and cache the result. A &lt;code&gt;revalidate: N&lt;/code&gt; knob handles&lt;br&gt;
freshness. On Vercel it works well; on a Next.js-aware host it&lt;br&gt;
mostly works.&lt;/p&gt;

&lt;p&gt;For a strictly static deployment, it doesn't work at all.&lt;/p&gt;

&lt;p&gt;The unspoken word in "Incremental Static &lt;strong&gt;Regeneration&lt;/strong&gt;" is&lt;br&gt;
the regeneration, and regeneration requires a runtime. ISR turns&lt;br&gt;
your "static site" into an HTTP server that lazily produces HTML&lt;br&gt;
on the way to the browser. If your deployment target is a CDN&lt;br&gt;
that only serves files — GitHub Pages, S3 + CloudFront, an nginx&lt;br&gt;
in front of a directory, Cloudflare Pages without a Worker, the&lt;br&gt;
static-files product on Netlify, an air-gapped intranet, the&lt;br&gt;
classic shared-hosting plan your client insists on — there is no&lt;br&gt;
runtime for ISR to run on. The feature isn't degraded, it's&lt;br&gt;
missing.&lt;/p&gt;

&lt;p&gt;This is the case the benchmark was designed for: pure static HTML&lt;br&gt;
plus assets, no Node runtime at request time. All five tools in&lt;br&gt;
the comparison advertise themselves as supporting that mode. The&lt;br&gt;
point of measuring at 100k+ is to find out whether the advertised&lt;br&gt;
mode survives at the scale a real catalogue produces. ISR doesn't&lt;br&gt;
enter the comparison because it isn't the same product — it's a&lt;br&gt;
different deployment topology that swaps a build-time problem for&lt;br&gt;
a request-time one. Both are valid; they aren't interchangeable,&lt;br&gt;
and the trade-offs should be visible to whoever signs off on&lt;br&gt;
hosting cost, security posture, or operational surface area.&lt;/p&gt;

&lt;p&gt;Three concrete consequences of that swap, worth knowing before&lt;br&gt;
reaching for ISR as a workaround:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first visitor to every page pays the bill.&lt;/strong&gt; A hundred&lt;br&gt;
thousand product pages and a hundred thousand unique long-tail&lt;br&gt;
visits over a quarter mean each visitor is the unlucky one for&lt;br&gt;
exactly one page. Cold start plus render time plus cache write —&lt;br&gt;
typically a hundred milliseconds to a few seconds, depending on&lt;br&gt;
the page. A static export amortizes that work into one build. ISR&lt;br&gt;
amortizes it into one hundred thousand request-time renders, each&lt;br&gt;
on the critical path of someone's pageview.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are now paying for compute you weren't paying for.&lt;/strong&gt; A&lt;br&gt;
static site sits on CDN edge cache and costs essentially nothing&lt;br&gt;
above bandwidth. ISR requires a serverless function (or a long-&lt;br&gt;
running process) that's billable per invocation and per millisecond&lt;br&gt;
of execution. The bigger the catalogue, the more pages enter the&lt;br&gt;
"never visited" tail and the more compute you allocate for HTML&lt;br&gt;
that nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache invalidation enters your application's design.&lt;/strong&gt; ISR's&lt;br&gt;
freshness story is &lt;code&gt;revalidate: N&lt;/code&gt; plus on-demand revalidation&lt;br&gt;
hooks. Both are reasonable, both are concepts your team now has to&lt;br&gt;
think about, and both are operational surface area that didn't&lt;br&gt;
exist when the deployment was files in a directory. For sites&lt;br&gt;
whose content really doesn't change often, this is purely added&lt;br&gt;
complexity.&lt;/p&gt;

&lt;p&gt;And there's a subtler point. &lt;strong&gt;ISR doesn't fix the underlying&lt;br&gt;
build ceiling.&lt;/strong&gt; If you mark some routes as fully pre-rendered&lt;br&gt;
via the array contract — &lt;code&gt;dynamicParams: false&lt;/code&gt;, &lt;code&gt;generateStatic&lt;br&gt;
Params&lt;/code&gt; returning the full set — you're back in the recursion-&lt;br&gt;
overflow territory from earlier in this article. ISR side-steps&lt;br&gt;
the wall by routing around it. It doesn't move the wall.&lt;/p&gt;

&lt;p&gt;None of this makes ISR a bad feature. It makes ISR an answer to a&lt;br&gt;
different question. "How do I serve a hundred thousand pages&lt;br&gt;
without paying for a build that materializes them all" is a real&lt;br&gt;
problem. "How do I generate a hundred thousand pages of pure&lt;br&gt;
static HTML to a CDN" is a different real problem. You don't&lt;br&gt;
solve the second with the answer to the first.&lt;/p&gt;

&lt;p&gt;react-server, Astro, TanStack Start, and Gatsby answer the second&lt;br&gt;
one. Next.js, in its &lt;code&gt;output: "export"&lt;/code&gt; mode, scales to about&lt;br&gt;
150,000 pages and is designed around ISR for the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract is the product
&lt;/h2&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server didn't win this benchmark with new&lt;br&gt;
technology. The runtime is Node. The bundler is Vite 8 with&lt;br&gt;
Rolldown. The API is &lt;code&gt;async function*&lt;/code&gt; — a primitive that's been&lt;br&gt;
in JavaScript engines since 2018. There's nothing in the build&lt;br&gt;
pipeline you couldn't have shipped seven years ago.&lt;/p&gt;

&lt;p&gt;What's novel is choosing it.&lt;/p&gt;

&lt;p&gt;Most of the React ecosystem has spent the last half-decade&lt;br&gt;
optimizing the wrong layer. The renderer is fast everywhere. The&lt;br&gt;
worker pool is fast everywhere. The compiler — Turbopack, SWC,&lt;br&gt;
take your pick — is fast everywhere. The bottleneck at scale&lt;br&gt;
turns out to be one decision made at the top of your route file:&lt;br&gt;
&lt;strong&gt;does the path source return, or does it yield?&lt;/strong&gt; And the only&lt;br&gt;
way to fix the bottleneck is to change the contract. Nobody else&lt;br&gt;
has.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generateStaticParams&lt;/code&gt; returns an array. &lt;code&gt;getStaticPaths&lt;/code&gt; returns&lt;br&gt;
an array. TanStack Start's &lt;code&gt;pages&lt;/code&gt; is an array. Gatsby's&lt;br&gt;
&lt;code&gt;createPage&lt;/code&gt; is an array smuggled in through a loop. Every layer&lt;br&gt;
downstream of those APIs is forced to assume the worst case lives&lt;br&gt;
in memory at once. At a thousand pages the assumption costs&lt;br&gt;
nothing. At a hundred thousand it costs minutes. At two hundred&lt;br&gt;
thousand, in Next.js, it costs the build — &lt;code&gt;RangeError: Maximum&lt;br&gt;
call stack size exceeded&lt;/code&gt;, exit one, zero pages produced.&lt;/p&gt;

&lt;p&gt;react-server's &lt;code&gt;[id].static.mjs&lt;/code&gt; doesn't return anything. It&lt;br&gt;
yields. The renderer pulls. Memory of the path source is &lt;code&gt;O(1)&lt;/code&gt;.&lt;br&gt;
N is unbounded. The build is the same shape at a thousand pages&lt;br&gt;
as it is at half a million, because the architecture has nothing&lt;br&gt;
that grows with it.&lt;/p&gt;

&lt;p&gt;If you are picking an SSG in 2026 and your roadmap has more than&lt;br&gt;
ten thousand pages in it, look at the path-list API before you&lt;br&gt;
look at anything else. The framework that lets you yield will&lt;br&gt;
scale with your content. The framework that asks for a return&lt;br&gt;
will, eventually, give you back an empty &lt;code&gt;out/&lt;/code&gt; directory and a&lt;br&gt;
stack trace.&lt;/p&gt;

&lt;p&gt;This isn't really a Next.js problem. It's a generation-of-tooling&lt;br&gt;
problem. Static-site generation at scale has been treated as a&lt;br&gt;
build-pipeline optimization for years. It isn't. It's an API&lt;br&gt;
design problem, and the API is the array.&lt;/p&gt;

&lt;p&gt;Change the API. Yield, don't return.&lt;/p&gt;

&lt;p&gt;It's time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full benchmark is open source. &lt;a href="https://github.com/lazarv/ssg-bench/tree/main/apps" rel="noopener noreferrer"&gt;&lt;code&gt;apps/&lt;/code&gt;&lt;/a&gt; for each&lt;br&gt;
framework's setup, &lt;a href="https://github.com/lazarv/ssg-bench/tree/main/bench" rel="noopener noreferrer"&gt;&lt;code&gt;bench/&lt;/code&gt;&lt;/a&gt; for the harness, and&lt;br&gt;
&lt;a href="https://github.com/lazarv/ssg-bench/blob/main/bench/REPORT.md" rel="noopener noreferrer"&gt;&lt;code&gt;bench/REPORT.md&lt;/code&gt;&lt;/a&gt; for the complete table. To&lt;br&gt;
reproduce: &lt;code&gt;pnpm install &amp;amp;&amp;amp; pnpm bench:sweep &amp;amp;&amp;amp; pnpm report &amp;amp;&amp;amp;&lt;br&gt;
pnpm chart&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disagreements welcome
&lt;/h2&gt;

&lt;p&gt;I wrote &lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server. The disclosure is at the top of&lt;br&gt;
the article, and I built the bench to keep the comparison honest&lt;br&gt;
despite it — same content per route, fastest successful run wins&lt;br&gt;
per cell, sample HTML validated per build, failed cells reported&lt;br&gt;
as failed rather than dropped, every framework's idiomatic&lt;br&gt;
configuration used as documented. I believe the comparison is&lt;br&gt;
fair.&lt;/p&gt;

&lt;p&gt;But I'm one person reading my own benchmark. If you spot a flag&lt;br&gt;
I should have set, a version I should have tried, an inadvertent&lt;br&gt;
advantage I've handed &lt;a class="mentioned-user" href="https://dev.to/lazarv"&gt;@lazarv&lt;/a&gt;/react-server — open an issue or&lt;br&gt;
send a PR. The harness is in &lt;code&gt;bench/&lt;/code&gt;, the apps are in &lt;code&gt;apps/&lt;/code&gt;,&lt;br&gt;
and any change that produces a fairer comparison wins.&lt;/p&gt;

&lt;p&gt;If the data lands somewhere different in your read than in mine,&lt;br&gt;
that's the conversation worth having. I'd rather the article get&lt;br&gt;
the technical story right than win an argument.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>tanstack</category>
      <category>astro</category>
      <category>gatsby</category>
    </item>
    <item>
      <title>A Low Floor Is Not a Low Ceiling</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Fri, 01 May 2026 18:58:19 +0000</pubDate>
      <link>https://dev.to/lazarv/a-low-floor-is-not-a-low-ceiling-2o2f</link>
      <guid>https://dev.to/lazarv/a-low-floor-is-not-a-low-ceiling-2o2f</guid>
      <description>&lt;p&gt;There is a moment at the beginning of using a framework when the framework tells you what kind of developer it thinks you are.&lt;/p&gt;

&lt;p&gt;It rarely says this directly. It says it by what it asks of you before your own idea is allowed to appear. It says it through the scaffold it generates, the folders it names, the configuration files it creates, the conventions it assumes you already understand, and the amount of system you must accept before the smallest useful program can run.&lt;/p&gt;

&lt;p&gt;This first moment matters because it defines the emotional shape of the tool. Some systems begin with a primitive: a function, a component, a request handler, a file. They let the idea arrive first and allow structure to grow around it. Other systems begin with an institution. Before there is behavior, there is a project. Before there is a program, there is a topology.&lt;/p&gt;

&lt;p&gt;We have become used to this, especially in frontend development. A new app is expected to be born as a tree. It has routing before it has routes, build configuration before it has a build problem, lint rules before it has a team, deployment assumptions before it has users, and a package graph before it has a reason to exist. Each piece may be defensible on its own. The problem is not that any one file is absurd. The problem is that the smallest idea is asked to carry the shape of a much larger future.&lt;/p&gt;

&lt;p&gt;That is a strange bargain. It is especially strange now, because the two kinds of developers most exposed to the beginning of a system, &lt;strong&gt;beginners and AI agents&lt;/strong&gt;, are exactly the two least able to separate essential shape from accumulated ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  What experts stop seeing
&lt;/h2&gt;

&lt;p&gt;Experienced developers have a skill we do not talk about enough: &lt;em&gt;selective blindness&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;We can open a repository and immediately reduce its apparent size. We know that some files are behavior, some files are policy, some files are boilerplate, some files are generated, and some files are present only because a tool once needed a place to write down its preferences. We know when a folder name is meaningful to the framework and when it is merely organizational. We know when a config file is actively shaping the program and when it is an artifact of the scaffold.&lt;/p&gt;

&lt;p&gt;This is not the same as simplicity. It is &lt;em&gt;familiarity doing compression&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A beginner does not have that compression. When they open a scaffolded project, the entire tree arrives with equal authority. Every file might matter. Every convention might be something they are already supposed to know. Every import, suffix, folder, generated type, and default export might be part of the lesson. To an expert, the surrounding machinery is background. To a beginner, it is the room.&lt;/p&gt;

&lt;p&gt;That changes what the first lesson becomes. Instead of learning that a program is an idea made executable, the beginner learns that software begins inside a prepared environment whose rules are not yet visible. They learn that making even a small thing requires standing in the correct place, naming files correctly, accepting the correct project shape, and trusting that the framework will interpret the structure as intended.&lt;/p&gt;

&lt;p&gt;Some of that knowledge will eventually be necessary. But "eventually" is the important word. The first encounter with a tool should not require the learner to distinguish core concepts from scaffolding residue. A good beginning should bring the irreducible thing close: data becomes UI, input becomes state, a request becomes a response. Architecture should arrive as a way to preserve clarity as the program grows, not as the admission price for writing the first line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent has the same problem
&lt;/h2&gt;

&lt;p&gt;AI agents make this problem visible in a different way. They are not beginners in the usual sense; they have absorbed patterns from more code than any human will read. But when an agent enters a particular repository, it does not bring the local memory of the team. It does not know which conventions are intentional, which are obsolete, which are inherited from the starter template, and which are workarounds nobody likes but everyone is afraid to remove.&lt;/p&gt;

&lt;p&gt;The agent has to discover the system by reading it. That sounds obvious, but it changes the economics of ceremony. What used to be a one-time human annoyance at project creation becomes a recurring cost paid on every AI-assisted change. The model must spend attention on the filesystem, the dependency graph, the framework conventions, the version-specific behavior, and the shape of the surrounding setup before it can safely reason about the user's request.&lt;/p&gt;

&lt;p&gt;It is tempting to reduce this to token count. More files mean more tokens; more tokens mean more cost. That is true, but it is the least interesting part. The deeper issue is that tokens do not all have the same semantic weight. In a real project, some text defines behavior, some configures behavior, some describes behavior that used to exist, some is framework glue, and some is simply the fossil record of how the project began. A human teammate can often point at a file and say, "ignore that." The model has to infer it.&lt;/p&gt;

&lt;p&gt;This is where bloated systems become dangerous for AI. They do not merely give the model more to read. They give it more ways to be plausibly wrong. It can follow a pattern that exists in the repository but no longer represents the intended direction. It can apply a framework rule from the wrong version. It can miss that a file path changes rendering mode, or that a cache option interacts with a parent segment, or that a wrapper exists only because a previous tool could not express the smaller thing directly.&lt;/p&gt;

&lt;p&gt;The beginner asks, "where do I put the code?" The agent asks the same question in another form: &lt;em&gt;"which of these tokens are the program?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Systems with too much ceremony answer both questions poorly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code size is a reasoning surface
&lt;/h2&gt;

&lt;p&gt;We often talk about code size as if it were a maintenance problem that appears after the fact. The project gets larger, so it becomes harder to maintain. That is true, but it misses the more immediate effect: &lt;strong&gt;code size changes the way a system can be understood&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A small program can be held in the mind. You can read it and keep the whole shape present: inputs, outputs, state, effects, and failure modes. As the program grows, understanding has to move through supports: names, tests, types, boundaries, conventions, documentation, and trust. Those supports are necessary, but they are not free. Each one helps organize the system while also becoming another surface on which a wrong assumption can land.&lt;/p&gt;

&lt;p&gt;The growth is not linear because the problem is not only the number of lines. It is the number of relationships between them. A route can interact with a layout, a cache rule, a bundling boundary, a server/client split, a deployment target, and a default inherited from somewhere the developer is not currently looking. A config file can change the meaning of a component that does not mention it. A directory name can affect runtime behavior even though it looks like organization.&lt;/p&gt;

&lt;p&gt;At small sizes, adding code mostly adds capability. At larger sizes, adding code increasingly adds interaction. The surface the next change has to cross becomes wider, less local, and harder to see at once. That is the familiar moment when &lt;strong&gt;a small change stops being small&lt;/strong&gt; because the system around it must be understood first. You want to add a button, but first you need to know whether it belongs on the client. You want to move data fetching, but first you need to know which cache owns freshness. You want to simplify a file, but first you need to know whether the filename itself is an API.&lt;/p&gt;

&lt;p&gt;For humans, this becomes onboarding time, superstition, fatigue, and the slow accumulation of "don't touch that" knowledge. For AI agents, it becomes larger prompts, weaker locality, pattern matching where understanding should be, and edits that are syntactically reasonable but semantically misplaced.&lt;/p&gt;

&lt;p&gt;This is why "use a bigger context window" is not a complete answer. A bigger context window lets the model carry more of the maze. It does not tell us whether the maze needed to be there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toy path is not kindness
&lt;/h2&gt;

&lt;p&gt;Once the weight of modern tooling becomes visible, the obvious solution is to give beginners something smaller. A simpler framework. A reduced mode. A teaching tool. A toy environment with fewer concepts and fewer ways to get lost.&lt;/p&gt;

&lt;p&gt;Sometimes this is useful. Teaching often requires choosing a smaller surface. But as an architectural answer, it fails if the small path is not part of the same world as the large path. If the beginner learns one model and then has to abandon it when the application becomes real, the simplicity was not a doorway. It was a waiting room.&lt;/p&gt;

&lt;p&gt;The same is true for small projects. A tiny internal tool should not have to choose between a toy framework that will be outgrown and a production framework that arrives already bloated. A prototype should be allowed to be real. A first file should be allowed to become the first file of the final system. The path from "almost nothing" to "something serious" should be continuous.&lt;/p&gt;

&lt;p&gt;This is the part that is easy to miss: &lt;strong&gt;beginners do not need worse tools&lt;/strong&gt;. They need real tools with lower entry points.&lt;/p&gt;

&lt;p&gt;If the only way to make a framework approachable is to remove its power, then the framework has not solved approachability. It has outsourced it to a different tool. A better framework shape lets the same primitive participate at multiple scales. The first component is not a demo artifact; it is a legitimate member of the system. The first route is not a special tutorial mode; it is the smallest case of the routing model. The first cache is not a global doctrine; it is a local decision next to the computation it affects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A low floor is not a low ceiling.&lt;/strong&gt; In the best systems, the low floor is evidence that the ceiling is supported by real structure rather than by ceremony.&lt;/p&gt;

&lt;h2&gt;
  
  
  Almost nothing should work
&lt;/h2&gt;

&lt;p&gt;There is a design principle hiding here that sounds more radical than it is: &lt;em&gt;almost nothing should work&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A single file should work. A single component should work. No configuration should work. No router should work until there is more than one place to go. No cache policy should exist until freshness has become a question. No deployment adapter should change the meaning of the application before deployment is actually being discussed.&lt;/p&gt;

&lt;p&gt;Absence should be a valid state of the system.&lt;/p&gt;

&lt;p&gt;This is not minimalism for its own sake. It is maintainability in its most practical form. A file that does not exist cannot go stale. A wrapper that was never extracted cannot become a place where names drift. A configuration key that was never introduced cannot be copied into the next project without understanding. A convention that was never required cannot become folklore. The strongest abstraction is often not the clever one, but the missing one.&lt;/p&gt;

&lt;p&gt;Frameworks are usually better at adding capabilities than at preserving absence, because capabilities are easier to demonstrate. A router can be documented. A cache layer can be benchmarked. A deployment adapter can be announced. "You do not have to think about this yet" is harder to turn into a feature page, even though it may be the most important feature for the first hour, the first week, and every AI agent session after that.&lt;/p&gt;

&lt;p&gt;The discipline is not to avoid power. The discipline is to &lt;strong&gt;delay power until the problem asks for it&lt;/strong&gt;. Configuration is good when it changes something the developer has chosen to care about. Project structure is good when the project has enough internal gravity to need one. Defaults are good when they remain defaults. They become bloat when they appear before the program has earned them and then pretend their presence is neutral.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling downward
&lt;/h2&gt;

&lt;p&gt;We usually use "scalable" to mean that a system can grow upward. More users, more routes, more teams, more data, more features, more deployment targets. That kind of scale matters, and a framework that cannot grow upward will eventually trap serious applications.&lt;/p&gt;

&lt;p&gt;But there is another kind of scale that is just as important: &lt;strong&gt;a system must scale downward&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It must scale down to one file, one component, one endpoint, one idea tested before lunch. It must scale down to the beginner trying to see the whole program at once. It must scale down to the AI agent trying to make a narrow change without reconstructing the entire framework context first. A system that scales upward but not downward is not truly scalable. It is only &lt;em&gt;large-capable&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This distinction changes how we judge architecture. The question is not only whether a framework can host an enormous application. The question is whether it can host a tiny one without making it pretend. Can the smallest useful program be written directly? Can it grow by adding concepts one at a time? Can each new layer explain itself by answering a pressure already present in the code?&lt;/p&gt;

&lt;p&gt;That is what a grown-up framework should feel like. At the beginning, most decisions should be &lt;em&gt;not yet&lt;/em&gt;. Not yet a routing tree. Not yet a cache hierarchy. Not yet a deployment-specific semantic. Not yet a global configuration file. Just the program. Then, when the program needs a second page, routing appears. When it needs shared structure, layout appears. When it needs data freshness control, caching appears next to the data. When it needs background isolation, a worker boundary appears around the work. When it needs deployment specificity, an adapter appears at the edge rather than changing the meaning of the center.&lt;/p&gt;

&lt;p&gt;Each new concept should feel like a door opening from the room you are already standing in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same world
&lt;/h2&gt;

&lt;p&gt;The deepest mistake is believing that beginners, experts, and AI agents need different worlds. They do not. They need &lt;em&gt;different distances from the same center&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The beginner needs to stand close to the irreducible idea, where the relationship between code and behavior is visible. The expert needs to move outward into power, performance, specificity, and control without being trapped by the framework author's fixed menu. The AI agent needs the same locality both of them need: code whose meaning is present in the text before it is hidden in conventions that must be inferred.&lt;/p&gt;

&lt;p&gt;These are not competing requirements. They are the same architectural requirement seen from different heights.&lt;/p&gt;

&lt;p&gt;Make the primitive honest. Make the first step real. Make absence valid. Make defaults optional. Make every layer replaceable when it finally appears. &lt;strong&gt;Let the small thing belong to the same world as the large thing.&lt;/strong&gt; Then the beginner is not trapped in a toy path, the expert is not trapped in a convention path, and the agent is not trapped in a fog of scaffolding.&lt;/p&gt;

&lt;p&gt;We should stop admiring systems merely because they can host enormous applications. That is only one kind of strength. The more interesting strength is the ability to be gentle with beginnings: to let an idea exist before it has proved that it deserves architecture, and to let it grow without exile.&lt;/p&gt;

&lt;p&gt;A serious framework should be able to hold almost nothing.&lt;/p&gt;

&lt;p&gt;And if the idea grows, it should not have to leave home.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>discuss</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>A Function Should Know Where It Runs</title>
      <dc:creator>Viktor Lázár</dc:creator>
      <pubDate>Thu, 30 Apr 2026 10:27:03 +0000</pubDate>
      <link>https://dev.to/lazarv/a-function-should-know-where-it-runs-3721</link>
      <guid>https://dev.to/lazarv/a-function-should-know-where-it-runs-3721</guid>
      <description>&lt;p&gt;There is an obvious appeal to a server function you can call from anywhere. The old version of the same idea was not pleasant. You wrote an endpoint, then a client helper for that endpoint, then some shared schema to keep the two sides honest, then error handling in both places, and eventually a small pile of files whose main job was to move one value from the browser to the server and another value back again.&lt;/p&gt;

&lt;p&gt;So when a framework lets you write the server part as a normal function and call it as a normal function, it feels like the right kind of progress.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServerFn&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Somewhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is much nicer than wiring an endpoint by hand. The function is typed. The caller is typed. Refactors have a path through the codebase instead of disappearing into a string URL. For a lot of application code, especially small reads and mutations, this is exactly the kind of boilerplate a framework should remove.&lt;/p&gt;

&lt;p&gt;The question is not whether the API is useful. It is. The question is what gets hidden when the call becomes this smooth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same call is not always the same operation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;await getUser()&lt;/code&gt; can mean slightly different things depending on where it appears. If the call happens while the application is already running on the server, it can be a direct path into server code. If it happens in the browser, it has to become a request. If it happens in a route loader, it belongs to the router's data lifecycle. If it happens after a click, it belongs to an interaction that the user is waiting on.&lt;/p&gt;

&lt;p&gt;Those cases can all share the same TypeScript signature, but they are not the same situation. The value that comes back may have the same shape; the act of getting it does not.&lt;/p&gt;

&lt;p&gt;That is the part of isomorphic server functions that makes me uneasy. The abstraction removes a lot of code nobody wanted to write, but it also makes the call site less descriptive. The line looks ordinary in places where the operation behind it may not be ordinary at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TanStack makes pleasant
&lt;/h2&gt;

&lt;p&gt;TanStack Start leans into this trade quite naturally. A server function is explicit when it is defined, and then the exported value is designed to be called from the places where application code tends to need it: loaders, components, hooks, event handlers, other server functions. That fits the rest of TanStack's style. The router is central, the data flow is typed, and the application is assembled out of explicit functions rather than a large menu of special filenames. If that is already the way you want to build, the server function API feels consistent.&lt;/p&gt;

&lt;p&gt;There is nothing dishonest about the definition site. &lt;code&gt;createServerFn()&lt;/code&gt; tells you that the handler is server code. It can touch a database. It can read secrets. It can do work the browser cannot do. The ambiguity appears later, when the call has been made deliberately ordinary.&lt;/p&gt;

&lt;p&gt;That ordinariness is useful while you are writing the code. You know where you are. You know whether the call is inside a loader or inside a button handler. You know what the framework is going to do. The problem shows up later, when the code is read without all of that context already loaded into someone's head.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small refactor changes the role
&lt;/h2&gt;

&lt;p&gt;Imagine a settings page that starts like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFileRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/settings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SettingsPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later, someone adds a refresh button inside the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both calls are reasonable. Both may be exactly what the application wants. But they are not playing the same role anymore. The first call belongs to navigation. The second call belongs to an interaction after the page is already on screen. It has a different timing, a different failure shape, probably a different loading state, and possibly a different relationship to invalidation.&lt;/p&gt;

&lt;p&gt;Nothing about &lt;code&gt;getUser()&lt;/code&gt; is wrong here. The issue is that the call is too polite to mention that its role changed. The code moved from one part of the application to another, and the most important difference is now carried by the surrounding framework context rather than by the expression itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types do not carry place
&lt;/h2&gt;

&lt;p&gt;Types do not really solve this. They solve an important part of it, but not this part. &lt;code&gt;Promise&amp;lt;User&amp;gt;&lt;/code&gt; tells me what value I will eventually get. It does not tell me why I am waiting. It does not tell me whether the delay is a database query in the same process or a request from the browser to the server. It does not tell me whether cookies are involved, whether middleware runs, whether a rate limit can trip, or whether the user is now staring at a disabled button.&lt;/p&gt;

&lt;p&gt;All of those things can live behind the same return type.&lt;/p&gt;

&lt;h2&gt;
  
  
  What RSC keeps visible
&lt;/h2&gt;

&lt;p&gt;This is where React Server Components come from a different direction. RSC does not try to make server code and client code feel like the same kind of code. It lets them participate in the same React tree, but it keeps their environments distinct. Server Components run on the server. Client Components run in the browser. Server Functions are server code that can be referenced across the boundary.&lt;/p&gt;

&lt;p&gt;The same settings page has a different shape in that model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SettingsPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsForm&lt;/span&gt; &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;refreshUser&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refreshUser&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refreshUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SettingsForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshUser&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCurrentUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setCurrentUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshUser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is more ceremony here. The client piece has to be named. The server function has to be passed across the boundary. Depending on the framework, this may also mean another file. But the roles are visible in the shape of the code: the initial read belongs to the Server Component, and the later refresh is a client interaction calling a Server Function.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The split does not necessarily have to be a file split. With function-level boundaries, the same idea could live much closer to the place where it is used:&lt;/p&gt;


&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SettingsPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SettingsForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCurrentUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refreshUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setCurrentUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshUser&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsForm&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;That is the argument in &lt;a href="https://dev.to/lazarv/the-use-client-tax-1ed0"&gt;The "use client" Tax&lt;/a&gt;: the boundary should stay visible, but it should be allowed to live closer to the code it describes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The current ergonomics of that model are not perfect. Next's file-level &lt;code&gt;"use client"&lt;/code&gt; boundary creates real friction, and small interactive pieces often end up in files that exist mostly because the bundler needs a module boundary. That is not a minor annoyance; it changes how code is organized. But the underlying idea is still important: a piece of code should communicate where it belongs.&lt;/p&gt;

&lt;p&gt;When something is server code, the reader should be able to expect server capabilities. When something is client code, the reader should be able to expect browser capabilities. When a value or reference crosses from one side to the other, the model should have a visible place for that crossing. Not because visible boundaries are beautiful in themselves, but because hidden boundaries tend to come back later as surprises about latency, failure, serialization, or state.&lt;/p&gt;

&lt;p&gt;This is the difference I care about between the two approaches. With an isomorphic server function, the definition says "server", but the call site tries to feel universal. With RSC, the model keeps insisting that server and client are different places, even when they are composed together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary should be cheap, not invisible
&lt;/h2&gt;

&lt;p&gt;I do not think the answer is to give up the convenience of server functions. Hand-written endpoints are not some lost paradise. A framework should make it cheap to invoke server code from the client, and TanStack's version of that idea is useful. The part I would be careful with is the framing. There is a difference between "this is server code with a convenient client invocation mechanism" and "this is just a function you can call from anywhere."&lt;/p&gt;

&lt;p&gt;The first framing keeps the boundary in the reader's mind. The second makes the boundary feel incidental until some operational detail forces it back into view.&lt;/p&gt;

&lt;p&gt;That is not just a matter of taste. It becomes a real maintenance problem.&lt;/p&gt;

&lt;p&gt;It shows up in code review, when a harmless-looking call has moved from a loader into an event handler and the diff does not make the change feel as large as it is. It shows up in debugging, when a line that reads like a function call fails like a network interaction. It shows up in refactors, when moving code across an invisible boundary changes timing, failure, and user-visible behavior without changing the expression that caused it.&lt;/p&gt;

&lt;p&gt;That is why I find the RSC direction healthier, even with its current rough edges. The goal should not be to make every server call dramatic. It should not be to reintroduce ceremony for its own sake. It should be to make the boundary cheap enough that we can keep it visible without resenting it.&lt;/p&gt;

&lt;p&gt;A function does not need to shout where it runs. But if understanding the function requires knowing whether it is local code, server code, or a request in disguise, then that fact should not live only in the reader's memory of the framework. Once the boundary is invisible at the call site, every reader has to rediscover it later.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
