vinext

Vinext 1.4: less server work, faster hydration, and lighter prefetching

9 min read

To upgrade:

npm install vinext@1.4.0 @vinext/cloudflare@1.3.0 @cloudflare/workers-response-store@1.1.4

If you run Workers Response Store as a separate Worker (the default service-binding mode), deploy it as well, because this release fixes how it handles overlapping refreshes. Projects created with vinext init have a deploy:response-store script for this.

Most of this release is performance work. We profiled Vinext against the Next.js version of the Hacker News app from solid-server-component-benchmarks and Next.js's App Router playground, and kept finding work the server and browser didn't need to do. On the Hacker News story page, 1.4 now spends less time on the server and in the browser than Next.js does:

Hacker News story page
Vinext 1.4 against 1.3 and Next.js
Vinext 1.3Vinext 1.4Next.js
Server CPU per request
−43%
54.4 ms in 1.3, 31.1 ms in 1.4. Next.js uses 33.7 ms.
Script time on page load
−26%
193 ms in 1.3, 143 ms in 1.4. Next.js uses 150 ms.
Lighthouse mobile score
88
Up from 81 in 1.3. Next.js scores 87.
› How this was measured
Every server ran the same Hacker News app on Node 24.22.1 behind the benchmark's brotli front proxy: Vinext 1.3.0 from npm, Vinext main at f938be86, and Next.js 16.4.2 with next start. The story page has about 1,500 comments and renders on every request. Server CPU is process-tree CPU time per request under a closed loop of 8 concurrent clients. Script time is headless Chromium with a 4× CPU throttle, the median of 8 loads. The Lighthouse score is the median of 5 mobile runs. The sections below have the details for each.
Vinext 1.3Vinext 1.4Next.js
Server CPU per request54.4 ms31.1 ms33.7 ms
Script time on page load193 ms143 ms150 ms
Lighthouse mobile score818887

The rest of the release brings caching closer to Next.js: a page's cached HTML and RSC payload are now filled and regenerated together, and Cloudflare caches share them across every host an app is served on.

Less work on the server

Profiling the Hacker News app showed how much time went into plumbing rather than rendering. Every streamed response was wrapped several times, by the SSR entry, the page lifecycle, tracing, the cache and after(), and each wrapper piped the body through its own TransformStream. Those wrappers now read the stream directly and merge into one when they're stacked. The RSC stream got the same treatment, and a rewrite of React's stylesheet hints that ran over every byte is gone, since React 19.3 sends those hints correctly. <Link> also skips the dozen hooks it used to run during SSR, where none of them do anything.

On Node, Vinext now renders HTML with React's Node build, as Next.js does, instead of the edge build, which allocates a new buffer for every text node and attribute. The RSC payload embedded in the page also stays as UTF-8 bytes, instead of being decoded, escaped and encoded again.

Together, rendering a 1,500-comment Hacker News story on Node now takes 43% less CPU than in 1.3, slightly less than Next.js, and the server handles 40% more requests per second under load:

Hacker News · server
Rendering a 1,500-comment story under load
Vinext 1.3Vinext 1.4Next.js
CPU per request
↓ Lower is better
−43%
since 1.3
Vinext 1.3
54.4 ms
Vinext 1.4
31.1 ms
Next.js
33.7 ms
Ahead of Next.js by 8%
Throughput
↑ Higher is better
+40%
since 1.3
Vinext 1.3
16.3/s
Vinext 1.4
22.9/s
Next.js
21.3/s
Ahead of Next.js by 8%
Median response time
↓ Lower is better
−28%
since 1.3
Vinext 1.3
480.8 ms
Vinext 1.4
343.9 ms
Next.js
372.1 ms
Ahead of Next.js by 8%
Peak memory
↓ Lower is better
−10%
since 1.3
Vinext 1.3
744 MB
Vinext 1.4
673 MB
Next.js
689 MB
Ahead of Next.js by 2%
› How this was measured
Every server ran the same Hacker News app on Node 24.22.1 behind the benchmark's brotli front proxy: Vinext 1.3.0 from npm, Vinext main at f938be86, and Next.js 16.4.2 with next start. The story page has about 1,500 comments and renders on every request. A closed loop of 8 concurrent clients requested the story page for 3 rounds of 20 seconds per server. CPU and memory cover the whole process tree. The upstream Hacker News API delay is 150 ms, which is included in response times. The machine was shared, with a load average of 4.7–7.4, so the servers were interleaved to spread the noise evenly.
Vinext 1.3Vinext 1.4Next.js
CPU per request54.4 ms31.1 ms33.7 ms
Throughput16.3/s22.9/s21.3/s
Median response time480.8 ms343.9 ms372.1 ms
Peak memory744 MB673 MB689 MB

@gengjiawen also found that the Node server kept every App Router route handler request in memory. After 500,000 requests the heap had grown to 1.18 GB. It now stays at about 10 MB.

Layouts run once per request

Vinext used to run every App Router layout once before rendering the page. That early pass caught a layout's redirect() or notFound(), and classified the layout as static or dynamic so the client could skip static layouts on navigation. The cost was that a static layout ran twice on every request. In apps with a heavy layout, such as a docs sidebar or a dashboard shell, that adds up.

The render now handles a layout's redirect(), notFound(), forbidden() and unauthorized() itself, as Next.js does, so the early pass is gone and every layout runs once. On a version of the Hacker News app whose root layout renders a 300-link sidebar, client navigations dropped by 14% in CPU instructions and full page loads by 8%.

Client navigations can also skip the layouts the client already has, as in Next.js. The client says which layouts it holds, and the server renders only below them. Vinext does this only for responses it never caches, such as force-dynamic routes and dynamic routes without generateStaticParams. Anything cached is a full payload any client can use. Differences from Next.js lists exactly when layouts are skipped.

Faster hydration

In the browser, the biggest change is that the chunks a page needs at startup now carry V8's explicit compile hint. Chrome 136 and later compile them on a background thread while they download, instead of compiling each function on the main thread the first time hydration calls it. On a Hacker News story with about 650 client components, at a 4× CPU throttle, hydration went from 104 ms to 87 ms.

Startup scripts now load at low priority, as in Next.js, so they no longer compete with the document for bandwidth on slow connections, and the page paints sooner.

The App Router browser entry also used to hold a loader for every client component in the app, which @quantizor pointed out. Each component's RSC row now names the chunks it needs, as in Next.js, so the entry only contains what every page uses. It's also more stable: adding a client component no longer changes the entry's file name, so browsers keep it cached across deploys.

On the Hacker News story page, these changes take between a fifth and a quarter off the time spent running script, both on the first load and on client navigations from the feed:

Hacker News · browser
Loading and navigating to the same story
Vinext 1.3Vinext 1.4Next.js
Script time on load
↓ Lower is better
−26%
since 1.3
Vinext 1.3
193 ms
Vinext 1.4
143 ms
Next.js
150 ms
Ahead of Next.js by 5%
Main-thread work on load
↓ Lower is better
−14%
since 1.3
Vinext 1.3
706 ms
Vinext 1.4
609 ms
Next.js
706 ms
Ahead of Next.js by 14%
Largest Contentful Paint
↓ Lower is better
−12%
since 1.3
Vinext 1.3
712 ms
Vinext 1.4
628 ms
Next.js
668 ms
Ahead of Next.js by 6%
Script time, client navigation
↓ Lower is better
−20%
since 1.3
Vinext 1.3
182.4 ms
Vinext 1.4
146.5 ms
Next.js
155.4 ms
Ahead of Next.js by 6%
› How this was measured
Every server ran the same Hacker News app on Node 24.22.1 behind the benchmark's brotli front proxy: Vinext 1.3.0 from npm, Vinext main at f938be86, and Next.js 16.4.2 with next start. The story page has about 1,500 comments and renders on every request. Headless Chromium with a 4× CPU throttle loaded the story page 8 times per server, alternating the order. Client navigation clicks from the feed to the story and measures until more than 1,000 comments have painted, 7 runs per server. All values are medians.
Vinext 1.3Vinext 1.4Next.js
Script time on load193 ms143 ms150 ms
Main-thread work on load706 ms609 ms706 ms
Largest Contentful Paint712 ms628 ms668 ms
Script time, client navigation182.4 ms146.5 ms155.4 ms

Lighthouse simulates a slow 4G connection, which is where low-priority scripts help most. First Contentful Paint went from 3.3 s in 1.3 to 2.1 s, level with Next.js, and the score from 81 to 88:

Hacker News · Lighthouse
Mobile Lighthouse on the story page
81
Vinext 1.3
FCP
3.31 s
LCP
3.77 s
TBT
110 ms
88
Vinext 1.4
FCP
2.11 s
LCP
3.69 s
TBT
56 ms
87
Next.js
FCP
2.26 s
LCP
3.64 s
TBT
91 ms
› How this was measured
Every server ran the same Hacker News app on Node 24.22.1 behind the benchmark's brotli front proxy: Vinext 1.3.0 from npm, Vinext main at f938be86, and Next.js 16.4.2 with next start. The story page has about 1,500 comments and renders on every request. Lighthouse's default mobile preset simulates a slow 4G connection and a 4× slower CPU. Each value is the median of 5 runs, each in a fresh Chromium. Simulated paint times move in steps of about 150 ms, one simulated round trip, so a gap that small isn't a real difference.
ScoreFCPLCPTBT
Vinext 1.3813.31 s3.77 s110 ms
Vinext 1.4882.11 s3.69 s56 ms
Next.js872.26 s3.64 s91 ms

Pages Router apps get faster too. The HTML now preloads the page and _app chunks, so they download alongside the client entry instead of after it. That alone made hydration 19–27% faster on a test app. next/image, next/dynamic, next/script, next/form and the App Router client runtime now only load on pages that use them.

Lighter prefetching

Next.js doesn't render a dynamic page just to prefetch it. It prefetches the route and, if there is one, its loading state, and renders the page when you navigate. Vinext used to render the whole page for every link in view. On a prototype of a large content site, where every page renders dynamically, opening a single page prefetched 3.9 MB. Vinext 1.4 prefetches only each route's loading state, under 1 kB a route:

Content site prototype · prefetch
What the browser prefetches after opening one page
Vinext 1.3
3.9 MB
Vinext 1.4
10.7 kB364× less
Next.js
One route-tree request
› How this was measured
A prototype of a large content site, with its page routes set to force-dynamic so every request renders. Vinext 1.3.0 and main ran under local workerd with vite preview, and Next.js 16.2.6 ran on Node with next start, on an Apple M4 Pro. The two runtimes differ, so compare the Vinext versions with each other first. Bytes are the uncompressed bodies of the RSC requests the page issues for the links it shows, within 4 seconds of loading and without throttling.
Bytes prefetchedRequests
Vinext 1.33.9 MB13
Vinext 1.410.7 kB13
Next.jsOne route-tree request1

HTML and RSC cached in sync

Next.js stores an App Router page's HTML and its RSC payload in one cache entry. Whichever request misses first, a full page load or a client navigation, fills both, and regenerating the page rewrites both. Vinext stored them under separate keys, so a page first reached by client navigation still missed on its first full load, as @yoohahn noticed.

Vinext still stores them separately, but on every backend it now fills and regenerates them together. When an RSC request misses, Vinext renders the page's HTML from the payload it already has, without running your server components again, and stores both. Regenerating either part rewrites the other. Workers Cache can't store a response nobody asked for, so there Vinext fetches the other part in the background, which costs one extra request each time a page misses or refreshes.

Those entries are also shared across hosts now. Next.js keys its cache by path, so an app served on several domains shares its cached pages, but Workers Response Store and Workers Cache included the host in their keys, so every domain rendered and stored its own copy. Every domain now uses the same copy, as the KV cache already did. This is safe because a render that reads the host, such as headers().get("host") or request.nextUrl.hostname, is dynamic and never stored. Pages Router pages that run getServerSideProps or getInitialProps are still only stored when a CDN-Cache-Control policy opts them in, and then with one entry per host. Hosts has the details.

Benchmarking a larger app

The content site prototype from the prefetching test is a harder benchmark than Hacker News, and Next.js still wins parts of it. Vinext ran under workerd, as it does on Workers, and Next.js on Node, so the server numbers compare runtimes as much as frameworks. 1.4 paints a third sooner than 1.3 and spends a fifth less time running script, and Vinext downloads 40% less JavaScript than Next.js and peaks at under half its memory:

Content site prototype
A large content site, rendered on every request
Vinext 1.3Vinext 1.4Next.js
First Contentful Paint
↓ Lower is better
−34%
since 1.3
Vinext 1.3
2.54 s
Vinext 1.4
1.67 s
Next.js
1.21 s
Behind Next.js by 38%
Lighthouse score
↑ Higher is better
+5%
since 1.3
Vinext 1.3
80
Vinext 1.4
84
Next.js
80
Ahead of Next.js by 5%
Script time on load
↓ Lower is better
−22%
since 1.3
Vinext 1.3
245 ms
Vinext 1.4
191 ms
Next.js
220 ms
Ahead of Next.js by 13%
JavaScript downloaded
↓ Lower is better
±0%
since 1.3
Vinext 1.3
382 kB
Vinext 1.4
382 kB
Next.js
623 kB
Ahead of Next.js by 39%
Server CPU per request
↓ Lower is better
−2%
since 1.3
Vinext 1.3
132.9 ms
Vinext 1.4
130.3 ms
Next.js
102.5 ms
Behind Next.js by 27%
Peak server memory
↓ Lower is better
−5%
since 1.3
Vinext 1.3
1017 MB
Vinext 1.4
968 MB
Next.js
2193 MB
Ahead of Next.js by 56%
› How this was measured
A prototype of a large content site, with its page routes set to force-dynamic so every request renders. Vinext 1.3.0 and main ran under local workerd with vite preview, and Next.js 16.2.6 ran on Node with next start, on an Apple M4 Pro. The two runtimes differ, so compare the Vinext versions with each other first. First Contentful Paint, the Lighthouse score and JavaScript downloaded come from Lighthouse 12.8.2's mobile preset. Script time is headless Chromium with a 4× CPU throttle, 8 loads of each of 4 pages. Server numbers come from a closed loop of 8 concurrent clients over 8 pages, 3 rounds of 20 seconds per server. Renders fetch data over the network, so response times carry network noise.
Vinext 1.3Vinext 1.4Next.js
First Contentful Paint2.54 s1.67 s1.21 s
Lighthouse score808480
Script time on load245 ms191 ms220 ms
JavaScript downloaded382 kB382 kB623 kB
Server CPU per request132.9 ms130.3 ms102.5 ms
Peak server memory1017 MB968 MB2193 MB

Next.js paints first, by about half a second. The browser can't paint until it has every stylesheet in the page's head, and Vinext's page links 15 of them, about 44 kB compressed, where Next.js links 4, about 31 kB. Turbopack merges a page's CSS into a few files, while Vite keeps one file per chunk, so a page built from many components pulls in many small stylesheets, and on Lighthouse's simulated slow 4G connection each extra request delays the first paint.

Next.js also uses about a fifth less CPU per request. Some of that is the runtime: on workerd, Vinext still renders with React's edge build rather than the Node build mentioned earlier. Some of it is payload size. Vinext's RSC payload for the about page is 297 kB, against 204 kB for Next.js, and every request has to serialize it and embed it in the HTML. We're looking at more ways to shrink the payload.

Trying TanStack Redact

TanStack released Redact this week. It swaps React and React DOM in the browser for a smaller runtime at build time, while the server keeps rendering with React, and it ships a Vinext adapter. We tried it on the same Hacker News app.

It does what it promises on size: every page downloads a quarter less JavaScript, and on the front page, which lists 30 stories, Redact spends 14 ms running script where React spends 51 ms. The 1,500-comment story page goes the other way. Script time on load nearly doubles, and Lighthouse's Total Blocking Time goes from 56 ms to 666 ms, which takes the score from 88 to 70.

Almost all of the difference is hydration. For each of the page's 11,600 elements, Redact compares every prop with the attribute in the server-rendered HTML to catch mismatches, and for each comment's dangerouslySetInnerHTML it reads the HTML back, parses it and compares it. React only runs those checks in development, so Redact ends up spending about twice as long per element. Neither runtime yields during the first hydration, so it all happens in one long task, and nearly all of the extra time counts as blocking time.

That's a choice Redact made rather than something Vinext can change, and dropping those checks in production builds, as React does, would likely close most of the gap. Until then, TanStack's advice to test your busiest screens is worth following: smaller pages come out ahead.

Hacker News · TanStack Redact
Hacker News with Redact in the browser
Vinext 1.4With Redact
JavaScript downloaded
↓ Lower is better
−25%
with Redact
Vinext 1.4
143 kB
With Redact
108 kB
Script time, front page
↓ Lower is better
−73%
with Redact
Vinext 1.4
51 ms
With Redact
14 ms
Script time, story page
↓ Lower is better
+85%
with Redact
Vinext 1.4
143 ms
With Redact
264 ms
Lighthouse Total Blocking Time
↓ Lower is better
12×
with Redact
Vinext 1.4
56 ms
With Redact
666 ms
Script time, client navigation
↓ Lower is better
+61%
with Redact
Vinext 1.4
146.5 ms
With Redact
235.4 ms
› How this was measured
Every server ran the same Hacker News app on Node 24.22.1 behind the benchmark's brotli front proxy: Vinext 1.3.0 from npm, Vinext main at f938be86, and Next.js 16.4.2 with next start. The story page has about 1,500 comments and renders on every request. The Redact build adds @tanstack/redact 0.2.2 with redact() from @tanstack/redact/vinext, which only accepts Vinext 1.3.0, so its version check was bypassed for the run. Server numbers stayed within noise (31.1 ms against 30.4 ms of CPU per request), since Redact only changes the browser runtime. Script time is headless Chromium with a 4× CPU throttle, the median of 8 loads of each page; client navigation is the median of 7 clicks from the front page to the story. The front page lists 30 stories from the live Hacker News API. JavaScript downloaded and Total Blocking Time come from the median of 5 mobile Lighthouse runs.
Vinext 1.4With Redact
JavaScript downloaded143 kB108 kB
Script time, front page51 ms14 ms
Script time, story page143 ms264 ms
Lighthouse Total Blocking Time56 ms666 ms
Script time, client navigation146.5 ms235.4 ms

The adapter currently only accepts Vinext 1.3.0, so we bypassed its version check to run it against 1.4.

Other fixes

  • vinext start, vinext-cloudflare deploy, vinext typegen and custom servers that call startProdServer() set NODE_ENV to production unless you've set it yourself, as the matching next commands do. vinext start used to run React's development build.
  • A next/dynamic component whose chunk fails to load in the App Router now reaches the nearest error.tsx. Vinext used to render the loading component with an error prop, which most spinners ignore, so a chunk missing after a deploy left the page on "Loading..." forever. Next.js only does that in the Pages Router.
  • revalidateTag(tag, profile) serves older entries stale until the profile expires, as in Next.js. The in-memory and KV caches used to drop them straight away, so the next request waited for a render.
  • Response Store no longer uses up the account's Workers Cache purge budget on routine refreshes. @williamrogersdev reported this.
  • A layout that calls notFound() returns a 404 with the nearest not-found page above it, instead of a 500, as @GauravKumawat2002 and @quantizor reported. Parallel slots and intercepted routes render their own not-found, forbidden and unauthorized pages.
  • Google Tag Manager and GA4 record every client navigation again. Vinext called its own saved copy of history.pushState, so wrappers added after the page loaded never saw navigations. @mattgwagner reported this.
  • Production servers log render errors. A failed render used to return a 500 with nothing in the logs, as @melkir found. They also reported fixes for draft mode with cached fetch, apps with several root layouts, and next-intl's precompiled messages.
  • @Gourav224 added --vite-config to vinext-cloudflare deploy, and --ignore-wrangler-config to vinext init for projects with a Wrangler config for another Worker. @ericjuta asked for both.
  • @hakarrr and @Godsenal fixed several Pages Router API route differences from Next.js, including streamed res.send(), request body charsets, and next.config headers.
  • @yoohahn fixed useSearchParams() staying empty after hydration on force-static pages.
  • vinext dev starts after a reboot even if the previous session's lock file is still there, as @quantizor reported.

Thanks to everyone who contributed to 1.4: @exKAZUu, @gengjiawen, @Godsenal, @Gourav224, @hakarrr and @williamrogersdev. Thanks also to the 1.3.1 contributors: @Divkix, @fengmk2, @MumuTW, @RaphaelFakhri, @tannerlinsley and @yoohahn. And thanks to @BetoFrega, @ericjuta, @GauravKumawat2002, @itseasypop, @jantimon, @mattgwagner, @melkir, @NathanDrake2406, @pcsontos, @quantizor and @tonyxty for their bug reports.

The full lists of changes are in the release notes for vinext 1.4.0, @vinext/cloudflare 1.3.0 and @cloudflare/workers-response-store 1.1.4.