By the end of this post, you’ll be able to look at a specific performance symptom in your app — a slow first paint, a laggy interaction, a bad Lighthouse score — and trace it back to whether server-side rendering or client-side rendering is the actual source, then apply a targeted fix instead of switching rendering strategies on a guess.
The SSR-vs-CSR debate tends to get framed as a single up-front architectural decision, but in practice most teams inherit a rendering strategy from a framework default and only investigate it once something feels slow. That’s the right order to work in. Below is a symptom-first troubleshooting guide, organized the way you’d actually diagnose a performance ticket: what you’re seeing, why it’s happening, and what to change.
Symptom: The Page Feels Blank for Several Seconds Before Anything Appears
What you’re seeing: Lighthouse reports a poor Largest Contentful Paint (LCP) or First Contentful Paint (FCP), and users watching the page load describe a long stretch of white screen before content shows up.
Likely cause: This is the classic signature of a pure client-side rendered app. The server sends a nearly empty HTML shell — often just a <div id="root"></div> — and the browser has to download the JavaScript bundle, parse it, execute it, and only then run the code that fetches data and renders content. Every one of those steps happens before the user sees anything meaningful.
<!-- What the browser initially receives from a CSR app -->
<body>
<div id="root"></div>
<script src="/static/js/bundle.js"></script>
</body>
Fix: Move the initial render to the server. With server-side rendering, the HTML that arrives already contains the content, so paint doesn’t wait on JavaScript execution.
// Next.js: rendered on the server per-request
export async function getServerSideProps() {
const data = await fetchArticleData();
return { props: { data } };
}
export default function Article({ data }) {
return <ArticleBody data={data} />;
}
If your data doesn’t change per-request, static generation is an even better fix — it gets the content-first benefit of SSR without paying the per-request rendering cost.
Symptom: First Paint Is Fast, but the Page Is Unresponsive to Clicks and Taps
What you’re seeing: Content shows up quickly, LCP looks fine, but Lighthouse flags a high Total Blocking Time, and manual testing shows buttons that don’t respond to the first tap or two.
Likely cause: This is the cost of hydration, and it’s specific to SSR. The server sent fully-formed HTML, but that HTML is inert — React still needs to download the JavaScript bundle and attach event listeners to the existing DOM before anything is interactive. On a large page with a heavy bundle, that gap between “looks done” and “is actually done” can stretch into seconds, especially on mid-range mobile hardware.
Fix: Reduce what has to hydrate, and control when it happens. A few approaches, roughly in order of effort:
- Code-split by route and by component, so the initial bundle only contains what’s needed for the visible page.
- Defer hydration of below-the-fold sections using
next/dynamicwith{ ssr: false }or lazy loading, so the browser prioritizes interactive elements users can actually reach first. - Adopt an islands-style architecture (via frameworks like Astro, or React Server Components) where only the interactive pieces of the page ship JavaScript at all, and static content stays static.
import dynamic from 'next/dynamic';
// Comments widget hydrates only when it's needed, not blocking the main thread upfront
const CommentsWidget = dynamic(() => import('../components/CommentsWidget'), {
loading: () => <p>Loading comments...</p>,
});
Symptom: Server Response Times Spike Under Traffic, Even Though the Page Itself Isn’t Complex
What you’re seeing: Time to First Byte (TTFB) climbs during traffic spikes, and server CPU usage tracks closely with request volume rather than with anything happening on the client.
Likely cause: Server-side rendering means every request triggers a fresh render on the server — renderToString (or its streaming equivalent) has real CPU cost, and if that work is repeated identically for thousands of requests serving the same content, you’re paying that cost far more often than necessary.
Fix: Cache the rendered output instead of regenerating it per request. Incremental Static Regeneration, edge caching, or a CDN layer in front of your SSR endpoint can all convert repeated render work into a cache hit.
// Next.js: rendered once at build time, then regenerated at most every 60 seconds
export async function getStaticProps() {
const data = await fetchArticleData();
return { props: { data }, revalidate: 60 };
}
Reach for full per-request SSR only for content that’s genuinely personalized or changes on every request. For anything else, static generation with periodic revalidation removes the server rendering cost from the request path almost entirely.
Symptom: Navigating Between Pages Feels Slower Than It Did on the Old Site
What you’re seeing: Individual pages load fast, but clicking a link triggers a full-page reload with a visible flash, rather than the instant transition users expect from a modern web app.
Likely cause: This usually shows up in SSR setups that aren’t paired with client-side routing — every navigation is treated as a brand-new request, so the server re-renders the whole page and the browser reloads everything, including JavaScript and CSS that hasn’t changed.
Fix: Pair server rendering for the initial load with client-side navigation for everything after hydration completes. This hybrid pattern is what frameworks like Next.js and Remix provide by default, but it’s worth confirming it’s actually wired up correctly in your app.
import Link from 'next/link';
// Client-side transition after the first page has hydrated — no full reload
<Link href="/articles/next-post">Read the next article</Link>
If you’re on a custom SSR setup without a router handling this, that’s the gap to close before considering anything else.
Symptom: Search Engines or Link Previews Show an Empty Page
What you’re seeing: Google Search Console reports thin or missing content for pages that look fine to a human visitor, and shared links on social platforms show no title, description, or preview image.
Likely cause: Crawlers and link-preview bots frequently don’t execute JavaScript, or do so with limits that time out before a client-side rendered app finishes fetching and rendering its content. They see the same near-empty HTML shell a slow connection would see on first paint.
Fix: Serve pre-rendered HTML to these consumers, either through full SSR or through static generation at build time. If a full rendering migration isn’t feasible right away, a prerendering service that serves cached static HTML to known bot user agents is a reasonable stopgap — though it’s a patch, not a substitute for fixing the underlying rendering strategy.
A Diagnostic Checklist Before You Change Anything
Before switching rendering strategies for an entire application, confirm the symptom actually maps to the cause:
- Measure LCP, FCP, TTI, and TBT separately. A slow LCP and a slow TBT point to different problems with different fixes, even though both feel like “the page is slow.”
- Check whether the problem is present on server response time (TTFB) or client execution time. A network waterfall in DevTools will show this clearly.
- Confirm hydration is the bottleneck, not the initial HTML, by disabling JavaScript temporarily and checking whether content still renders.
- Test on throttled CPU and network conditions, not just your development machine. Hydration and bundle-parsing costs are dramatically more visible on mid-range mobile hardware.
Most real-world performance problems in this space aren’t “SSR vs CSR” in the abstract — they’re a specific symptom with a specific, identifiable cause, and a fix that’s usually smaller than a full rendering migration.
What symptom are you seeing in your own app right now — a slow first paint, unresponsive interactions after load, or something else? Describe what the Network tab and Lighthouse are telling you, and the likely cause is probably on this list.
🔗 Recommended Reading
- Optimizing Custom Hooks for Performance: A Beginner vs. Advanced Comparison
- Reducing JavaScript Execution Time in React Apps: A Q&A Guide
- Measuring Core Web Vitals in React Apps: Myth vs. Reality
- React Compiler and Automatic Memoization: A Step-by-Step Walkthrough
- Performant React Animations: A Step-by-Step Guide to Choosing CSS vs. JavaScript