TL;DR: Measure real users first, then reduce the work required to render the first useful view. Keep data-heavy work on the server, make client boundaries small, optimize image dimensions and fonts, remove unnecessary JavaScript, cache intentionally, and validate the production build on representative devices.
Performance Is a User Flow, Not a Lighthouse Number
Next.js can produce a fast page, but the framework cannot decide which data is essential, whether a client component is necessary, or if a third-party script is worth its cost. Performance work starts by looking at the user journey: landing page, search, product detail, checkout, dashboard, or another action that creates business value.
Core Web Vitals provide useful signals, but they are symptoms of the work your application performs. Largest Contentful Paint reflects how quickly the main content becomes visible. Interaction to Next Paint reflects the responsiveness of interactions. Cumulative Layout Shift reflects visual stability. A high score does not guarantee a good product flow, and a low score should lead you to the specific page and device conditions causing it.
For a deeper baseline, SoftwareCrafting’s performance and analytics services can help connect field data to the user journeys that matter instead of optimizing isolated demo pages.
Start With Field and Lab Measurements
Use two types of data:
- Field data represents real users, networks, devices, geographies, and returning versus first-time visits.
- Lab data gives repeatable traces for local debugging, pull requests, and release comparisons.
Before changing code, record the URL, template, device class, connection profile, deployment version, and metric distribution. A median score can hide a poor experience for slower phones. Track p75 values for the key metrics and split dashboards by route and release.
Useful questions include:
- Which element is the LCP candidate?
- Is the delay caused by server response, resource discovery, image decoding, or rendering?
- Which interaction has the slowest input-to-update path?
- Where does layout move after the page becomes visible?
- Did a recent dependency or third-party script change the distribution?
Keep the Server and Client Boundaries Deliberate
The App Router makes Server Components the default. That is valuable because server-rendered components can fetch data and produce HTML without sending their implementation to the browser. The performance benefit disappears when a high-level layout is marked with 'use client' and pulls a large subtree into the client bundle.
Use client components for browser-only APIs, local interactive state, event handlers, and genuinely interactive UI. Keep the boundary close to the interactive leaf.
// Server Component
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<main>
<ProductSummary product={product} />
<AddToCartButton productId={product.id} />
</main>
);
}
ProductSummary can remain a Server Component, while AddToCartButton is a small client boundary. Avoid turning the entire page into a client component only because one button needs an event handler.
Also keep serialized props small. Passing a complete product catalog, internal metadata, or large configuration object into a client component increases the response and hydration work. Select only the fields the browser needs.
Make Data Fetching Parallel and Intentional
Sequential awaits create waterfalls when requests are independent. Start independent work together:
export default async function DashboardPage() {
const overviewPromise = getOverview();
const activityPromise = getRecentActivity();
const alertsPromise = getAlerts();
const [overview, activity, alerts] = await Promise.all([
overviewPromise,
activityPromise,
alertsPromise,
]);
return <Dashboard overview={overview} activity={activity} alerts={alerts} />;
}
Do not parallelize calls that depend on the output of another call. Instead, model the dependency clearly and cache shared reads. If a page contains independent sections with different latency, use streaming and Suspense so the useful shell can render while slower sections complete.
Avoid fetching the same data in a Server Component, a client component, and a route handler unless each copy serves a clear purpose. Duplicate requests can hide behind fast local development and become expensive in production.
Optimize the Largest Contentful Paint Candidate
The LCP element is often a hero image, headline block, or product image. Find it in a trace instead of assuming it is the image. Then improve the bottleneck that actually delays it.
For an important image:
- Use
next/imagewith accuratewidth,height, or responsivesizes. - Provide
priorityonly for the image that is truly above the fold and important to the first view. - Use the correct source dimensions so the browser does not download a desktop asset for a small phone.
- Avoid placing a large image behind a client-only component that waits for hydration.
- Make sure the server response and image origin are geographically sensible.
For a text LCP candidate, inspect font loading and CSS. Preload only fonts that are needed immediately, use a modern format, and avoid loading several weights before the first view. A fallback that renders quickly is often better than blocking the page for a rarely used typeface.
Remove JavaScript That Does Not Create User Value
Every client dependency has download, parse, compile, and runtime costs. Review:
- Date and chart libraries loaded on pages that do not show them.
- Global providers that wrap the entire application for one route.
- UI libraries that ship large modules for a small interaction.
- Analytics scripts loaded before the page is usable.
- Repeated icon packages or broad barrel imports.
- Hydration of hidden menus, dialogs, and dashboards before the user opens them.
Use dynamic imports for heavy, interaction-triggered features. Defer non-essential third-party scripts until after the page is usable and consent requirements are satisfied. Code splitting helps only when the initial route does not import the split module through another path.
Prevent Layout Shift by Reserving Space
Layout shift usually comes from content whose size is unknown when the initial layout is painted. Reserve image dimensions, set stable aspect ratios for media, avoid injecting banners above existing content, and give skeletons dimensions that match the final component.
Ads, consent notices, personalization, and font swaps need special attention. If a banner must appear, render a reserved region or place it where existing content does not move. For conditional UI, decide whether the space should exist in both states.
Animations should use transform and opacity when possible. Animating layout properties such as width, height, top, or left can trigger expensive recalculation and produce jank on lower-end devices.
Cache Data With a Clear Freshness Model
Caching is a correctness decision. For each read, define whether the data is:
| Data type | Typical approach |
|---|---|
| Public content | Time-based revalidation or tag-based invalidation |
| Product catalog | Cached read with invalidation after a catalog change |
| User-specific dashboard | Request-scoped or short-lived cache with authorization checks |
| Financial or permission state | Fresh read or carefully bounded cache |
| Search results | Cache only when query and authorization dimensions are safe |
Do not cache personalized data in a shared layer without including the right identity and authorization boundaries. Do not use a long revalidation period simply to hide slow database queries. Fix query shape, indexing, and upstream latency first, then choose the cache lifetime that matches user expectations.
Audit CSS, Fonts, and Third-Party Code
CSS can block rendering and large global stylesheets can increase work on every route. Remove unused rules, keep route-specific styles local where practical, and avoid importing a large design system into a small landing page.
Third-party scripts deserve a budget. For each script, record its purpose, load timing, transfer size, main-thread work, privacy implications, and fallback behavior. If a marketing script causes the primary interaction to miss its responsiveness budget, move it later or remove it.
Test the Production Build
Development mode is not a performance baseline. Test a production build with realistic content, image sizes, caching, and deployment infrastructure. Include:
- A slower Android device or an equivalent CPU throttle.
- Fast and slow network profiles.
- First visit and repeat visit behavior.
- Authenticated and unauthenticated routes.
- Long lists, empty states, errors, and personalized content.
- JavaScript disabled or blocked third-party scenarios where relevant.
Add budgets to CI for bundle size and key route traces, but do not let a synthetic budget replace field monitoring. A page can pass a lab check while a particular region receives slow server responses or a third-party dependency fails.
Common Failure Modes
The same patterns appear repeatedly:
- Marking the root layout as client-side to manage one toggle.
- Calling an API from the browser for data that could be rendered on the server.
- Setting every image to
priorityand removing the value of prioritization. - Memoizing components while leaving the actual data waterfall untouched.
- Adding a cache without defining invalidation or user isolation.
- Measuring only the homepage while the highest-value route is slow.
- Treating a green Lighthouse score as proof that real users are fast.
Production Checklist
Before shipping a performance-sensitive route, confirm:
- Field metrics are available by route, device, geography, and release.
- The LCP candidate and its bottleneck are known.
- Client boundaries are limited to interactive features.
- Independent data requests run in parallel or stream independently.
- Images, fonts, and layout dimensions are intentional.
- Third-party code has an owner and loading budget.
- Caching respects freshness and authorization.
- Production builds are tested on representative devices.
- Regressions trigger an alert or release review.
Performance is an ongoing operating practice. Start with the user journey, find the largest source of work, make one change at a time, and verify the result in both traces and field data. For a broader architecture review, explore full-stack web development services with performance, caching, and deployment considered together.

