Apr 2026

·

5 min read

Frontend Optimization: Small Changes That Make a Big Difference

A list of small things I do to keep my frontend pages fast.

These are some things I gathered in my career and that I implement in every project. I primarily work with Next.js, so some of these techniques are specific to that ecosystem. Often, frontend optimization is ignored, but in today's world with many AI tools that create UI very quickly, we must work on performance and speeding up pages. That is becoming a big UX requirement. AI tools tend to skip this part entirely, or go the other way and overengineer it.

1. Stop using barrel files

A barrel file is an index.ts that re-exports everything from a folder:

export { Button } from './button'
export { Modal } from './modal'
export { DataTable } from './data-table'
export { Chart } from './chart'       // imports chart.js (200KB)
export { RichEditor } from './editor'  // imports tiptap (150KB)

We often use barrel files to keep imports short and clean. But there are trade-offs:

  • Development environments. When you write import { Button } from '@/components', you only want Button, but the barrel file itself must still be evaluated. This can cause all re-exported modules to be loaded and executed, including Chart and RichEditor and their heavy dependencies.
  • Production builds. Modern bundlers usually tree-shake unused exports, but only if your modules are structured correctly and have no side effects. Poorly structured barrels can still lead to unexpectedly large bundles.
  • Circular imports. When module A imports from module B, and module B imports back from module A, you get a circular dependency. Barrel files make this easy to create by accident. Depending on the runtime, circular imports can result in partially initialized modules or runtime errors.

TkDodo wrote a great post that explains all of this in detail: Please Stop Using Barrel Files.

2. Add dynamic imports for heavy client-only modules

Do not load everything on page load. Load it only when it is needed. Dynamic imports let you split your code and load parts of your application on demand, instead of shipping everything upfront.

A common use case is lazy-loading heavy, client-only libraries. Charts are a great example. Libraries like Recharts are relatively large and usually not needed immediately when the page loads. Instead of sending that code to every user, you can load it only when the component is actually used:

import dynamic from 'next/dynamic'

const Chart = dynamic(() => import('./MyChart'), {
  ssr: false,
})

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Chart />
    </div>
  )
}

Without a dynamic import, Recharts would be included in your main JavaScript bundle, meaning every user downloads it, even if they never interact with the chart. With dynamic import, the initial bundle is smaller, the page loads faster, and the chart code is fetched only when needed.

This is explained really well in Code splitting with dynamic imports in Next.js on web.dev.

3. Prevent leaked renders

Using && for conditional rendering is common in React, but it can introduce a subtle bug:

{count && <Component />}

If count is 0, React renders it as a number rather than skipping it, so a stray 0 ends up in your UI. Using a ternary makes the condition explicitly boolean and prevents that:

{count ? <Component /> : null}

Beyond the visual glitch, leaked renders cause unnecessary DOM updates and can introduce layout shifts. In server-rendered apps, they also cause hydration mismatches, where the HTML the server sends differs from what React expects on the client, forcing a full re-render of the affected subtree.

4. Use Server Components by default

In Next.js App Router, every component is a Server Component unless you explicitly add 'use client'. This means the component runs on the server, renders to HTML, and does not send its own JavaScript to the browser.

The problem is that many developers add 'use client' out of habit or because a single onClick handler lives somewhere deep in the tree. Once a component becomes a Client Component, everything it imports also ships to the client.

A better approach is to keep the parent as a Server Component and extract only the interactive part into a small Client Component:

// ServerPage.tsx (no 'use client')
import InteractiveButton from './InteractiveButton'

export default function ServerPage({ data }) {
  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.description}</p>
      <InteractiveButton />
    </div>
  )
}

// InteractiveButton.tsx
'use client'

export default function InteractiveButton() {
  return <button onClick={() => alert('clicked')}>Click me</button>
}

This way, only the button ships JavaScript. The rest of the page remains server-rendered with zero client-side cost. The key rule is to push 'use client' as far down the component tree as possible.

5. Streaming with Suspense

HTML streaming is a technique where a web server sends parts of an HTML page to the browser gradually, instead of waiting for the entire page to be ready before sending it all at once.

By default, React Server Components can stream HTML to the browser, but without explicit boundaries, slow data can still delay large parts of the UI.

If one API call takes three seconds, it can block everything that depends on it, even if the rest of the page is ready.

Streaming with Suspense solves this by letting you split the UI into independent chunks. You wrap slow parts in a Suspense boundary with a fallback, and the server can send the rest of the page immediately:

import { Suspense } from 'react'

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <QuickStats />

      <Suspense fallback={<p>Loading analytics...</p>}>
        <SlowAnalytics />
      </Suspense>
    </div>
  )
}

The user sees the heading and quick stats right away. SlowAnalytics streams in when it is ready, replacing the fallback, without blocking the rest of the page.

This is especially useful for dashboards, feeds, or any page where some sections depend on slower data sources. Instead of showing a blank screen, you render meaningful content immediately and progressively fill in the rest.

6. Optimize images

Images are usually the heaviest assets on a page. Unoptimized images cause slow load times and layout shifts. The simplest thing you can do is compress images before publishing. Tools like ImageOptim can reduce file sizes significantly with no visible quality loss.

For larger projects, hosting images on a dedicated service like Google Cloud Storage or Cloudinary offloads the work entirely. These services handle compression, format conversion, and responsive sizing automatically, so you do not have to think about it per image. If you are using Next.js, the built-in <Image> component already handles image optimization.

Measure first

None of these techniques are groundbreaking on their own, but they can add up. Before applying any of them, it is worth measuring first. Tools such as Lighthouse, the Chrome DevTools Performance tab, and the Next.js bundle analyzer can help identify actual bottlenecks, allowing you to address meaningful performance issues instead of optimizing areas with little impact.