Available for freelanceContact me so I can help your business grow or turn your idea into reality!

I'm interested
Web Performance Optimization Techniques Every Frontend Developer Should Know

Web Performance Optimization Techniques Every Frontend Developer Should Know

A client once asked me why their product page took 6 seconds to become interactive on a mid-range phone. I opened the bundle analyzer and found a date-picker library, a full icon set, and a charting library — all loaded on a page that showed none of them until the user clicked "Add to cart." That's not an edge case. That's most React apps I've audited.

Performance work isn't about micro-optimizing render loops. Ninety percent of the time, it's about shipping less JavaScript and loading it at the right moment.

Where the milliseconds actually go

Before touching code, understand what you're optimizing for. Two metrics matter most to users:

  • Time to Interactive (TTI) — when the page responds to clicks and taps, not just when it looks done
  • Largest Contentful Paint (LCP) — when the biggest visible element (usually a hero image or heading) finishes rendering

Both get destroyed by the same root cause: too much JavaScript parsed and executed before the browser can do anything useful. A 500KB bundle doesn't just take longer to download — it takes longer to parse and execute, and that blocks the main thread on low-end devices far more than it does on your M-series laptop.

Test on throttled CPU (4x slowdown in Chrome DevTools) and a throttled 3G connection. Your fast machine lies to you about what real users experience.


The three levers that actually move the needle

1. Code splitting

You don't need the whole app in one bundle. Split by route automatically, and split by feature manually for anything heavy and conditional.

// ❌ Loaded on every page, even if the modal never opens
import { ExportModal } from '@/components/ExportModal'

function Dashboard() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Export</button>
      {open && <ExportModal onClose={() => setOpen(false)} />}
    </>
  )
}
// ✅ Only fetched when the user actually clicks Export
import dynamic from 'next/dynamic'

const ExportModal = dynamic(() => import('@/components/ExportModal'), {
  loading: () => <Spinner />,
})

function Dashboard() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Export</button>
      {open && <ExportModal onClose={() => setOpen(false)} />}
    </>
  )
}

That one change moved the ExportModal's dependencies — a PDF library, in this case — out of the initial bundle entirely. On the client project I mentioned, this alone cut the initial JS payload by 40%.

2. Image delivery

Images are usually the biggest bytes on the page, and they're the easiest to fix wrong.

// ❌ Full-resolution image, no lazy loading, no explicit dimensions
<img src="/hero-banner.jpg" alt="Product hero" />
// ✅ Responsive, lazy by default below the fold, explicit sizing prevents layout shift
import Image from 'next/image'

;<Image
  src="/hero-banner.jpg"
  alt="Product hero"
  width={1200}
  height={600}
  priority // only for above-the-fold images
/>

Use priority only on the image that determines your LCP — usually the hero. Marking everything priority defeats the purpose; you're back to loading everything eagerly.

3. Third-party scripts

Analytics, chat widgets, A/B testing tools — these are often the actual culprit, not your own code. Each one adds its own network request, its own JS parse cost, and sometimes its own render-blocking behavior.

// ❌ Blocks parsing immediately, runs before the page is interactive
<script src="https://widget.example.com/chat.js"></script>
// ✅ Loads after the page is interactive, doesn't compete for the main thread on first load
import Script from 'next/script'

;<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />

next/script's strategy prop gives you control most teams don't know exists: beforeInteractive, afterInteractive, or lazyOnload. Default third-party scripts to lazyOnload unless something depends on them being ready immediately.


Measuring the actual impact

Don't guess. Run Lighthouse or next build with the bundle analyzer before and after:

ANALYZE=true npm run build

On the audit I mentioned earlier, here's what the numbers looked like:

MetricBeforeAfter
Initial JS bundle780 KB410 KB
Time to Interactive (3G, mid-range phone)6.1s2.8s
Largest Contentful Paint3.4s1.6s

Same features. Same design. The only change was deferring what didn't need to load immediately.


Common mistakes

  • Optimizing useMemo/useCallback before checking bundle size. Re-render performance rarely matters if the page took 4 seconds to become interactive in the first place. Fix the bigger problem first.
  • Lazy-loading everything, including above-the-fold content. If you dynamically import your hero section, you delay the very thing that determines your LCP score. Lazy load what's hidden, not what's immediately visible.
  • Importing an entire library for one function. import _ from 'lodash' pulls in the whole library even if you only use debounce. Use import debounce from 'lodash/debounce' or a native implementation.
  • Ignoring third-party script impact because "it's not my code." Users don't care whose code slowed the page down. Audit every script tag the same way you audit your own bundle.

Best practices

  • Set a bundle size budget and enforce it in CI. Tools like bundlesize or Next.js's built-in warnings catch regressions before they ship, not after a user complains.
  • Default to next/dynamic for anything below the fold or behind an interaction — modals, tabs not currently active, charts, rich text editors.
  • Compress and serve modern image formats. WebP or AVIF instead of JPEG/PNG cuts image weight significantly with no visible quality loss in most cases.
  • Preconnect to third-party origins you can't avoid. <link rel="preconnect" href="https://fonts.googleapis.com"> shaves the DNS/TLS handshake off the critical path.
  • Re-measure after every "small" dependency you add. A single date-picker library can add 80KB. Check before merging, not after the app feels slow.

What to do next

Run a bundle analyzer on your app today — not next sprint. Find the three biggest chunks that aren't your core UI, and ask whether they need to load on first paint. In most cases, the answer is no, and moving them behind dynamic() or lazyOnload takes fifteen minutes per component.

Do that consistently, and bundle size stops being a quarterly cleanup project and becomes a habit that catches regressions before they reach production.