One Team Measured React Server Components Against a Raw DOM Write and Found Nothing Broke
React Server Components (RSCs) promise smaller bundles and faster first loads, but every framework migration carries risk. One team decided to measure that risk by pitting RSCs against the absolute floor: a raw DOM write. Their two-week experiment across 1.2 million sessions found no meaningful regression in any user-facing metric. The result challenges the assumption that adding a server-rendering layer inevitably introduces overhead or brittleness.
The Tension: A Framework Change That Should Have Broken Something
The team maintained a moderately complex e-commerce frontend built on React 18. Over three years, they had accumulated roughly 400 components, a mix of server-side rendered pages and client-side interactive widgets. When they started exploring RSCs, the engineering lead expected trouble. Every previous framework upgrade—from class components to hooks, from legacy context to the new context API—had produced at least one regression that took weeks to isolate.
RSCs represent a deeper shift: the component tree is evaluated on the server, and only the serialised output reaches the client. That means any component that depends on browser APIs, event listeners, or client-side state must be explicitly marked as a client component. The team worried that marking a component as client would accidentally pull in too much JavaScript, negating the size benefit. They also feared that streaming boundaries would cause visible layout shifts or flickering.
To establish a baseline, they built a second version of the same page that bypassed React entirely for the initial render. This variant used a lightweight template engine to write directly to the DOM via innerHTML and then attached event handlers with vanilla JavaScript. It was ugly, fragile, and deliberately minimal—the fastest possible path to a rendered page. If RSCs came close to that baseline, the team would consider the experiment a success.
The surprise came when the data started arriving. Not only did RSCs match the raw DOM variant on Largest Contentful Paint (LCP), but the server-side metrics also improved. CPU load on the rendering servers dropped roughly 20%, and the median Time to First Byte (TTFB) shrank by a few dozen milliseconds. The team had expected trade-offs; they found none.
How the Experiment Was Designed: A/B in Production
The experiment ran on a single product listing page that accounted for roughly 30% of the site's traffic. Two variants were served to 5% of users each, with the remaining 90% continuing on the existing React 18 server-side rendering path as a control. The raw DOM variant and the RSC variant each saw about 600,000 sessions over two weeks.
Instrumentation covered three layers. On the client, the team collected Core Web Vitals using the web-vitals library, plus custom hydration timers that recorded when each interactive element became responsive. On the network layer, they tracked bundle sizes, number of requests, and cache hit rates. On the server, they monitored CPU, memory, and the distribution of rendering times at the 50th, 95th, and 99th percentiles.
The raw DOM variant was built with a Node.js script that generated the full HTML string on the server, sent it down, and then loaded a small JavaScript file that attached click handlers and form listeners. There was no virtual DOM, no diffing, no reconciliation. It was the kind of architecture that a team might prototype in a weekend but never ship to production—except now it was running alongside a modern framework.
The RSC variant used the same data sources but broke the page into a server component tree with a few client islands for the interactive parts—a search box, a filter panel, and a cart button. The team used the react-server-dom-webpack plugin and kept the build configuration as close to the control as possible. They deliberately avoided any optimisation that the raw DOM variant could not replicate, such as streaming or selective hydration.
What the Data Actually Showed
LCP in the RSC variant averaged 1.42 seconds, within 50 milliseconds of the raw DOM variant's 1.38 seconds. The difference was not statistically significant at the 95% confidence level. First Input Delay (FID) hovered around 12 milliseconds for both, and Total Blocking Time (TBT) overlapped within the margin of error. The control variant—the existing React 18 SSR—came in at 1.61 seconds LCP, confirming that both experimental variants outperformed the baseline.
The bundle size story was more dramatic. The RSC variant shipped roughly 45 kilobytes of JavaScript on first load, compared to 92 kilobytes for the control and 28 kilobytes for the raw DOM variant. The raw DOM variant's tiny footprint came from having no framework at all, but its interactivity was limited. The RSC variant delivered comparable interactivity to the control while cutting the JavaScript payload in half.
Server CPU load dropped from an average of 68% utilisation on the control to 54% on the RSC variant. The team attributed this to the fact that RSCs do not require full serialisation of the component tree into a string; they stream serialised React elements directly, which is less CPU-intensive than generating a large HTML string with embedded React root markup. The raw DOM variant's server load was even lower at 48%, but the difference was small in absolute terms.
Error rates across all three variants remained under 0.1%, with no statistically significant differences. The RSC variant showed a slight increase in client-side re-renders on pages with heavy filter interactions, but the counts were low—fewer than 0.5% of sessions—and did not affect user-reported issues.
Where the Team Expected Pain and Found None
Streaming boundaries are often cited as a source of layout instability. When the server sends parts of the page progressively, the browser may shift content as new chunks arrive. The team had prepared for this by adding explicit placeholders and measuring Cumulative Layout Shift (CLS). The RSC variant's CLS was 0.03, identical to the raw DOM variant and well below the 0.1 threshold.
Third-party scripts—analytics trackers, ad networks, and chatbot widgets—interacted identically with both variants. The team had worried that moving to a server-first architecture would alter the timing of script execution, but the scripts loaded in the same order relative to the critical rendering path. The only difference was that the RSC variant's smaller initial payload meant scripts started executing slightly earlier, which actually improved perceived performance.
Authentication state posed a theoretical challenge: how do you pass a user's session token from a server component to a client component without leaking it to the client? The team used a cookie-based approach where the server component read the token and passed a signed, encrypted claim to the client component via props. The client component then used that claim to make authorised API calls. No regressions appeared in login flows or session expiry handling.
Accessibility tree construction was another area of concern. The team ran automated audits with axe-core on both variants and found no differences in landmark roles, heading structure, or ARIA attributes. Manual testing with screen readers confirmed that navigation and announcements behaved identically. The raw DOM variant's simplicity actually made it easier to audit, but the RSC variant did not introduce any new violations.
Cache invalidation—often a pain point in server-rendered architectures—behaved predictably. The RSC variant used the same Redis-backed cache layer as the control, with cache keys based on the URL and user segment. Invalidations triggered by data updates cleared the relevant keys, and the next request re-rendered the page on the server. No stale content was served, and no extra cache-miss spikes appeared in the metrics.
The One Edge Case That Did Surface
Every experiment has its outlier. In this case, a single component that used document.addEventListener directly in the module scope broke under RSCs. The component was a small notification toaster that listened for a custom event dispatched by a third-party library. Because the RSC evaluated the component on the server, document was undefined, and the import crashed the entire server-side render.
The fix was straightforward: move the event listener into a useEffect hook inside a client component. The team had assumed that any component with browser-specific code would already be marked as client, but this one had slipped through because the event listener was registered outside the component function, in the module body. The pattern is common in older codebases where developers attached global listeners at import time.
No other DOM-dependent patterns failed. Components that read window.innerWidth or navigator.userAgent were already wrapped in client boundaries or used the use-sync-external-store shim. The team ran a codebase audit after the incident and found three other files with similar patterns; all were patched within a single sprint cycle.
A handful of internal state-management libraries needed minor adjustments. One library used a global store that relied on WeakMap for caching, which worked fine on both server and client because WeakMap is available in Node.js. Another library used MutationObserver to detect DOM changes, which required wrapping in a client component. The total effort for all fixes was roughly three engineer-days.
Operational Lessons for Adopting Server Components
The team's experience distills into a few concrete practices. First, audit every component for browser API calls before migration. A simple static analysis script that scans for references to document, window, and navigator can catch most issues. The team used ESLint with a custom rule and found 14 components that needed attention, only one of which caused a runtime failure.
Second, prefer gradual rollout over a big-bang switch. The team's A/B experiment gave them confidence to expand RSC coverage incrementally. They started with one page, then added sections, then moved to the entire product category tree. Each step was validated against the same metrics. Six months later, roughly 70% of pages used RSCs, with no measurable regression in any Core Web Vital.
Third, keep a raw-DOM escape hatch for truly synchronous needs. The team maintained the raw DOM variant as a fallback for pages that required absolute minimal latency—for example, the checkout confirmation page where every millisecond of TTFB correlated with conversion rate. The escape hatch was never used in production, but its existence gave the team permission to experiment without fear of painting themselves into a corner.
Fourth, monitor not just Vitals but also server-side latency percentiles. The team's biggest surprise was the CPU load reduction, which they would have missed if they had only looked at client metrics. Server-side monitoring also revealed that the 99th percentile rendering time increased slightly under RSCs during traffic spikes, likely due to the overhead of streaming serialisation. The increase was less than 100 milliseconds and did not affect user experience, but it was real and worth tracking.
Fifth, expect no framework change to be invisible at scale. The team's experiment showed that RSCs can match a raw DOM baseline, but that does not mean every team will have the same experience. Codebase maturity, component patterns, and third-party dependencies all influence the migration effort. The team's investment in instrumentation and their willingness to run a controlled experiment were the deciding factors in their success.
What This Means for the Next Generation of Frontend Architectures
RSCs are not a rewrite; they are a refactoring pattern. The team did not throw away their existing React codebase. They added a new rendering mode and gradually shifted component boundaries. The raw DOM variant proved that the theoretical floor is low, but RSCs came close enough that the trade-off—bundled interactivity for a small latency penalty—was clearly worth it.
Raw DOM writes remain a valid baseline for comparison. The exercise of building a no-framework version forced the team to understand exactly what their framework was doing. That understanding carried over into the RSC migration: they knew which parts of the page truly needed client-side JavaScript and which could stay server-only. Any team considering RSCs would benefit from the same exercise, if only to calibrate their expectations.
Teams can adopt server-first rendering without fear. The narrative that framework changes inevitably break something is not wrong—every migration has rough edges—but the rough edges in this case were minor and fixable. The team's data shows that the gap between a hand-optimised raw DOM render and a server component render is narrow enough to be irrelevant for most use cases.
The real win is reduced client-side JavaScript, not raw speed. The RSC variant's LCP was only slightly better than the control, but its JavaScript payload was half the size. That reduction translates to faster parse times, less memory usage, and improved performance on low-end devices. The team saw a 12% reduction in bounce rate on mobile devices after the full rollout, which they attributed to the smaller bundle.
Future frameworks will likely blend both models by default. The distinction between server and client components is already blurring: React's own documentation recommends composing server and client components in the same tree. The team's experiment suggests that this hybrid model is not just theoretically sound but operationally stable. As more teams run similar experiments, the collective understanding of where server components shine—and where they do not—will only grow.