DEV Community

Cover image for Optimizing Performance in Next.js and React.js: Best Practices and Strategies
BHARGAB KALITA
BHARGAB KALITA

Posted on

Optimizing Performance in Next.js and React.js: Best Practices and Strategies

Performance optimization forms the backbone of great web experiences, and it’s a critical focus when building with React.js and Next.js. A fast app keeps users satisfied, reduces load times, enhances SEO, and scales effectively. Drawing from practical projects, this guide explores detailed strategies to maximize performance in both frameworks, blending actionable tips with lessons learned from real-world applications.


0. Table of Contents

To navigate directly to a specific optimization strategy, use the links below:


1. Identifying Performance Bottlenecks

Optimization begins with pinpointing issues. Tools like Google PageSpeed Insights and Lighthouse, prove invaluable for assessing metrics such as Largest Contentful Paint (LCP)—the time until main content appears—Cumulative Layout Shift (CLS)—how much the page shifts during loading—and Time to First Byte (TTFB)—the server’s response speed. For React.js, the React Developer Tools Profiler highlights components that re-render excessively or load slowly. Skipping this step once led to hours wasted on misdirected efforts—profiling first has since become a non-negotiable starting point.


2. Image Optimization

Images often hinder performance but offer significant optimization potential. Here’s how they’re managed effectively:

  • Next.js: The next/image component streamlines the process by resizing images, converting them to efficient formats like WebP, and lazy-loading them by default. For above-the-fold images, such as homepage banners, setting loading="eager" and fetchpriority="high" prioritizes them for LCP. Here’s an example from a recent project:
  import Image from "next/image";
  function HeroSection() {
    return (
      <Image
        src="/banner.jpg"
        width={1200}
        height={600}
        fetchpriority="high"
        alt="Main banner"
      />
    );
  }
Enter fullscreen mode Exit fullscreen mode
  • React.js: Without Next.js’s built-in tools, libraries like react-lazy-load-image-component or manual loading="lazy" attributes on <img> tags handle the task. It’s less automated but effective.
  • Extras: Serving images via a CDN (e.g., Cloudflare) speeds up delivery. Modern formats like WebP reduce file sizes dramatically—transforming a 2MB PNG into 200KB proved this point. Skeleton screens or blurred placeholders (auto-generated in Next.js with blurDataURL) prevent CLS, enhancing the loading experience noticeably.

3. Code Splitting and Tree Shaking

Large JavaScript bundles silently degrade performance, but modern bundlers like Webpack mitigate this with code splitting and tree shaking:

  • React.js: React.lazy and Suspense enable on-demand component loading. In a dashboard app, lazy-loading a chart component not visible on initial load cut the main bundle by 300KB:
  const Chart = React.lazy(() => import("./Chart"));
  function Dashboard() {
    return (
      <Suspense fallback={<div>Loading chart...</div>}>
        <Chart />
      </Suspense>
    );
  }
Enter fullscreen mode Exit fullscreen mode
  • Next.js: next/dynamic offers similar benefits, often with SSR disabled for client-heavy components. For example:
  import dynamic from "next/dynamic";
  const Map = dynamic(() => import("../components/Map"), { ssr: false });
Enter fullscreen mode Exit fullscreen mode

Next.js also splits code by page automatically, a boon for multi-page apps.

  • Tree Shaking: Modern bundlers like Webpack enable tree shaking by default in production mode, eliminating unused code. Using named imports (e.g., import { useEffect } from 'react') instead of import * maximizes this feature. A bloated bundle from a library’s import *as once ballooned a build—switching to specific imports let Webpack trim 150KB of dead code.

4. Server-Side and Client-Side Caching

Caching eliminates redundant work, boosting efficiency:

  • Next.js: getStaticProps pre-renders static pages (e.g., blogs), while getServerSideProps handles dynamic content (e.g., user profiles). Incremental Static Regeneration (ISR) balances static efficiency with freshness, updating pages in the background. An e-commerce site used this to refresh product listings every 10 seconds without full rebuilds:
  export async function getStaticProps() {
    const products = await fetchProducts();
    return { props: { products }, revalidate: 10 };
  }
Enter fullscreen mode Exit fullscreen mode
  • React.js: Client-side caching with SWR or React Query keeps API calls lightweight and data current:
  import useSWR from "swr";
  const fetcher = (url) => fetch(url).then((res) => res.json());
  function Profile() {
    const { data } = useSWR("/api/user", fetcher);
    return <div>{data?.name}</div>;
  }
Enter fullscreen mode Exit fullscreen mode
  • Extras: Static assets cached on a CDN with Cache-Control: max-age=31536000 and service workers for offline support dropped one app’s load time from 2 seconds to under 1 second.

5. Load Balancing for Scalability

Load balancing ensures stability during traffic spikes:

  • Next.js: Deploying with Nginx as a reverse proxy distributes requests across servers. Vercel’s auto-scaling handled a flash sale seamlessly with zero downtime. Stateful apps (e.g., chat features) benefit from sticky sessions to maintain user context across servers.
  • React.js: As a client-side framework, this applies more to hosting or backend setups, where platforms like Netlify or Cloudflare balance static assets automatically.
  • Lesson: Neglecting this once crashed a site during a surge—planning for scale is now standard practice.

6. Lazy Loading and Intersection Observer

Lazy loading minimizes initial load overhead:

  • React.js: The Intersection Observer API loads content as it enters the viewport. An infinite scroll feature built this way runs smoothly:
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef();
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsVisible(true);
        observer.disconnect();
      }
    });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);
  return <div ref={ref}>{isVisible ? <BigVideo /> : <Skeleton />}</div>;
Enter fullscreen mode Exit fullscreen mode
  • Next.js: Pairing next/dynamic with Intersection Observer defers heavy components, like footers, until needed.

7. Preventing Unnecessary Re-Renders and Managing State Efficiently

Excessive re-renders slow apps, but they’re manageable with the right techniques and state management choices:

  • React.js:

    • React.memo prevents re-renders unless props change, halving render time for a list item component:
    const ListItem = React.memo(({ text }) => <li>{text}</li>);
    
    • useMemo and useCallback optimize costly operations. A filtering app avoided lag by memoizing a sorted array:
    const sortedData = useMemo(() => data.sort((a, b) => a - b), [data]);
    const handleFilter = useCallback(() => applyFilter(), []);
    
    • Avoiding inline JSX functions (e.g., <button onClick={() => foo()}>) prevents unnecessary recreations that disrupt child components.
  • Next.js: These techniques carry over, with SSR and SSG offloading some rendering from the client, reducing client-side re-render pressure.

  • State Management: Context suits small apps due to its simplicity, but larger Next.js projects benefit from Zustand or Redux for performance and scalability. A key reason to prefer Redux over useContext lies in re-rendering behavior: with useContext, every component consuming the context re-renders whenever any part of the context value changes, even if it doesn’t use the updated state. For example, updating a single user property in a context triggers re-renders across all consumers:

  const MyContext = createContext({ user: { name: "John" }, theme: "light" });
  function NameDisplay() {
    const { user } = useContext(MyContext); // Re-renders on any context change
    return <div>{user.name}</div>;
  }
Enter fullscreen mode Exit fullscreen mode

Redux, by contrast, uses a store with selectors (e.g., via reselect) to ensure components only re-render when their specific slice of state changes:

  const selectUserName = (state) => state.user.name;
  function NameDisplay() {
    const name = useSelector(selectUserName); // Only re-renders if name changes
    return <div>{name}</div>;
  }
Enter fullscreen mode Exit fullscreen mode

This granularity avoids unnecessary re-renders, critical in complex apps with frequent state updates. Normalizing state (e.g., flat objects) further enhances efficiency. Switching from Context to Zustand or Redux once transformed a sluggish UI into a responsive one by eliminating wasteful renders.


8. Server-Side Performance Optimization

Server-side enhancements accelerate Next.js apps:

  • Load balancers (noted above) pair with optimized database queries—Prisma avoids N+1 issues, cutting TTFB from 500ms to 100ms in one case.
  • HTTP/2 or HTTP/3, enabled via Nginx, speeds up asset delivery.
  • Gzip or Brotli compression reduced a docs site’s payload from 1MB to 300KB.
  • React.js: This applies more to API servers, where lean fetch calls maintain client-side efficiency.

9. Purging CSS

CSS bloat quietly undermines performance, making purging essential:

  • Next.js: Tailwind CSS’s built-in purging (via purgecss) strips unused classes at build time. Configuring tailwind.config.js to scan relevant files cut a portfolio site’s CSS from 500KB to 20KB:
  module.exports = {
    content: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
    theme: { extend: {} },
    plugins: [],
  };
Enter fullscreen mode Exit fullscreen mode
  • React.js: Integrating PurgeCSS or cssnano into Webpack works well:
  const PurgeCSSPlugin = require("purgecss-webpack-plugin");
  const glob = require("glob");
  module.exports = {
    plugins: [
      new PurgeCSSPlugin({
        paths: glob.sync(`${__dirname}/src/**/*`, { nodir: true }),
      }),
    ],
  };
Enter fullscreen mode Exit fullscreen mode
  • Impact: Unpurged CSS from libraries once bloated a React app with 1MB of unused Bootstrap styles—purging became a must after that.

10. Inlining Critical CSS

Inlining critical CSS—styles for above-the-fold content—accelerates first paint:

  • Next.js: Tools like Critical or Penthouse extract critical CSS for inlining in the <head>. Running Critical in the build process:
  npx critical pages/index.html --base . --inline > pages/index-critical.html
Enter fullscreen mode Exit fullscreen mode

Then injecting it with <Head>:

  import Head from "next/head";
  export default function Home() {
    return (
      <>
        <Head>
          <style dangerouslySetInnerHTML={{ __html: `body { margin: 0; } .hero { font-size: 2rem; }` }} />
        </Head>
        <div className="hero">Welcome!</div>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Non-critical CSS loads async via <link rel="stylesheet">.

  • React.js: The critical-css-webpack-plugin automates this in Webpack:
  const CriticalCssPlugin = require("critical-css-webpack-plugin");
  module.exports = {
    plugins: [new CriticalCssPlugin({ src: "index.html", inline: true })],
  };
Enter fullscreen mode Exit fullscreen mode
  • Approach: Lighthouse identifies critical rendering path blockers, then Critical or Penthouse extracts styles for headers or hero sections. Inlining 2KB of critical CSS on a landing page dropped FCP from 1.5s to 0.8s, deferring the rest.

11. Optimizing Fonts and Third-Party Scripts

Fonts and scripts subtly impact performance:

  • Next.js: next/font hosts fonts locally, with font-display: swap ensuring text visibility during load.
  • React.js: Self-hosting critical fonts with <link rel="preload"> and deferring others avoids blocking.
  • Scripts: Third-party tools (e.g., analytics) use async or defer:
  <script src="tracking.js" defer></script>
Enter fullscreen mode Exit fullscreen mode

Deferring a chat widget once shaved off a 1-second delay.


12. Optimizing Third-Party API Calls

Third-party APIs can bottleneck apps if mishandled:

  • React.js: Batching requests with axios or Promise.all and debouncing rapid calls (e.g., autocomplete) conserve resources:
  const debounce = (fn, delay) => {
    let timeout;
    return (...args) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => fn(...args), delay);
    };
  };
  const fetchSuggestions = debounce(async (query) => {
    const res = await fetch(`/api/suggestions?q=${query}`);
    setSuggestions(await res.json());
  }, 300);
Enter fullscreen mode Exit fullscreen mode
  • Next.js: Pre-fetching in getStaticProps or getServerSideProps bakes data into pages, while SWR handles client-side updates:
  import useSWR from "swr";
  function LiveData() {
    const { data } = useSWR("/api/live", fetcher, { refreshInterval: 5000 });
    return <div>{data?.value}</div>;
  }
Enter fullscreen mode Exit fullscreen mode
  • Takeaway: Setting 5-second timeouts on fetches and falling back to cached data when APIs fail halved one app’s load time after over-fetching issues surfaced.

13. Offloading Third-Party Scripts with Partytown

Third-party scripts like analytics or ads often bog down the main thread, but Partytown offloads them to web workers for better performance:

  • Next.js with App Router: Introduced in Next.js 12.1 as an experimental feature, the next/script component’s strategy="worker" uses Partytown to run scripts in a web worker. Enable it in next.config.js:
  module.exports = {
    experimental: { nextScriptWorkers: true },
  };
Enter fullscreen mode Exit fullscreen mode

Then, in a layout or page:

  import Script from "next/script";
  export default function RootLayout({ children }) {
    return (
      <html lang="en">
        <body>
          {children}
          <Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" strategy="worker" />
        </body>
      </html>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Note: As of February 22, 2025, this remains experimental and unsupported in the Next.js 13+ App Router due to stability issues. Testing revealed Google Analytics offloading worked but took time to register hits, suggesting patience or alternative strategies like lazyOnload for now.

  • React.js: Partytown integrates manually via @builder.io/partytown/react. Install it (npm install @builder.io/partytown), then add scripts with type="text/partytown" and the Partytown component:
  import { Partytown } from "@builder.io/partytown/react";
  function App() {
    return (
      <>
        <head>
          <script type="text/partytown" src="https://example.com/analytics.js"></script>
          <Partytown forward={["dataLayer.push"]} />
        </head>
        <div>My App</div>
      </>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Copy Partytown’s worker files to public/~partytown for serving.

  • Benefits and Caveats: Offloading frees the main thread, improving responsiveness—Lighthouse scores jumped from 69 to 85 in one test after moving Google Tag Manager to a worker. However, not all scripts (e.g., those needing synchronous DOM access) work seamlessly. Partytown’s trade-offs documentation advises proxying requests for CORS issues, a common snag with some APIs.

14. Using Next Bundle Analyzer to Identify Bundle Size Issues

Large bundles can silently inflate load times, and Next Bundle Analyzer provides clarity on what’s taking up space in Next.js apps:

  • Setup: Install @next/bundle-analyzer (npm install @next/bundle-analyzer) and configure it in next.config.js:
  const withBundleAnalyzer = require('@next/bundle-analyzer')({
    enabled: process.env.ANALYZE === 'true',
  });
  module.exports = withBundleAnalyzer({
    // Other Next.js config options
  });
Enter fullscreen mode Exit fullscreen mode

Run it with ANALYZE=true next build to generate a visual report.

  • Usage: After building, the analyzer opens a browser window with an interactive treemap, showing bundle sizes for pages, components, and dependencies. Each block’s size and color indicate its contribution—larger, darker blocks highlight heavy modules. For example, a bloated third-party library like moment.js might dominate the treemap, signaling a need to switch to a lighter alternative like day.js.
  • React.js Context: While Next Bundle Analyzer is Next.js-specific, React.js projects can use webpack-bundle-analyzer similarly. Add it to Webpack config:
  const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
  module.exports = {
    plugins: [new BundleAnalyzerPlugin()],
  };
Enter fullscreen mode Exit fullscreen mode

Running the build generates a comparable report.

  • Practical Impact: Analyzing one Next.js app revealed a 1.2MB bundle from an unused charting library imported across pages—lazy-loading it with next/dynamic slashed the initial load by 800KB. Regularly checking bundle sizes ensures optimization efforts target the biggest culprits, like oversized dependencies or unminified code.

15. Rendering Huge Lists with Virtual Lists and Infinite Scroll

Rendering large datasets, like thousands of items, can cripple performance, but virtual lists and infinite scroll optimize this:

  • React.js with Virtual Lists: Libraries like react-virtualized or react-window render only visible items in the viewport, not the entire list. For a 10,000-item list, react-window keeps DOM nodes minimal:
  import { FixedSizeList } from 'react-window';
  const Row = ({ index, style }) => <div style={style}>Item {index}</div>;
  function VirtualList() {
    return (
      <FixedSizeList height={400} width={300} itemCount={10000} itemSize={35}>
        {Row}
      </FixedSizeList>
    );
  }
Enter fullscreen mode Exit fullscreen mode

This approach dropped render time from seconds to milliseconds in a data-heavy app.

  • Infinite Scroll: Building on Intersection Observer (see #6), infinite scroll fetches more items as users scroll. Here’s an example fetching 20 items at a time:
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const loader = useRef(null);
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setPage((prev) => prev + 1);
        }
      },
      { threshold: 1.0 }
    );
    if (loader.current) observer.observe(loader.current);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    fetch(`/api/items?page=${page}`).then((res) =>
      res.json().then((data) => setItems((prev) => [...prev, ...data]))
    );
  }, [page]);
  return (
    <div>
      {items.map((item, i) => (
        <div key={i}>{item}</div>
      ))}
      <div ref={loader}>Loading...</div>
    </div>
  );
Enter fullscreen mode Exit fullscreen mode
  • Next.js: Combine next/dynamic with virtual lists for client-side rendering of large datasets, or pre-fetch initial items with getStaticProps. Testing showed a 50,000-item list rendered smoothly with react-virtualized, while infinite scroll kept server requests efficient.
  • Why It Matters: Rendering all items naively spikes memory usage and slows scrolling—virtualization and infinite scroll ensure smooth performance even with massive datasets.

16. Continuous Monitoring and Performance Improvements

Optimization requires ongoing effort:

  • Weekly Lighthouse runs and Web Vitals logging catch regressions—CLS spikes once flagged an image issue.
  • Google Analytics reveals navigation patterns, guiding rendering tweaks like preloading popular pages.
  • Keeping Next.js and React updated leverages performance fixes—a minor update once shaved 100ms off load time effortlessly.

Conclusion

From tackling slow React re-renders to leveraging Next.js’s SSR and ISR, optimization blends framework features with disciplined coding. Tools like React.memo, next/image, ISR, CSS purging, inlined critical CSS, Partytown offloading, bundle analysis, virtual lists, and API optimizations, paired with caching and monitoring, transform apps into efficient, user-friendly experiences. Applying these strategies in projects consistently yields noticeable improvements.


Note

This article will be continuously updated as new optimization techniques, tools, or insights emerge. Check back periodically for the latest strategies to keep Next.js and React.js applications performing at their peak.

Top comments (0)