Frontend Performance at Scale

How I shrank bundles, split routes, and cached at the edge on React and Vue apps serving millions, with two production scars to show for it.

It was a Wednesday night, around 1 a.m., Chrome profiler open over the visual builder we shipped at the creator-economy platform I worked at. Creators used it to assemble their own branded mobile apps. React, TypeScript, in front of millions of people. The initial chunk was 1.8 MB gzipped and the canvas felt like it was dragging a filing cabinet. Drag a block, wait.

The product was already getting real traction, so nobody wanted a rewrite. We wanted a fix.

Here’s how I think about frontend performance after enough of these nights. Measure first, cut by data, never trust the part of your brain that wants to memoize everything because it feels productive.

Measure before you cut

The first 30 minutes of any perf work I do is the bundle analyzer. Not refactoring. Not “I bet it’s lodash”. Just looking. On Vite I run rollup-plugin-visualizer, on older Webpack repos I still run webpack-bundle-analyzer. The picture is the same shape every time. Two or three huge dependencies, a vendor blob nobody split, and a long tail of things you forgot you pulled in.

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: 'dist/stats.html',
      template: 'treemap',
      gzipSize: true,
      brotliSize: true,
    }),
  ],
  build: {
    sourcemap: true,
    chunkSizeWarningLimit: 300,
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('react') || id.includes('scheduler')) return 'react'
            if (id.includes('@tanstack')) return 'tanstack'
            if (id.includes('chart.js') || id.includes('d3')) return 'charts'
            return 'vendor'
          }
        },
      },
    },
  },
})

That manualChunks block does real work. The default vendor chunk lumps everything into one blob that re-downloads any time a single dep version moves. Splitting React, TanStack Query, and the charting libs means a routine TanStack bump invalidates 60 KB, not 600.

The other thing I check here is what’s getting tree-shaken. Barrel files in utils/index.ts style are still the most common reason something you thought was 4 KB ships as 80. Import from the leaf path, not the barrel.

Code splitting that pays

Route-level splitting is the cheapest win in the building. React.lazy plus Suspense and you’re done. The trap is people then split every panel and modal, end up with 80 chunks, and the waterfall on slow 4G becomes its own bug.

The rule we landed on for the builder. Split anything behind a route, behind a tab the user might never click, or heavier than 50 KB. Everything else stays in the parent chunk.

import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import { EditorSkeleton } from '@/components/editor/EditorSkeleton'
import { ErrorBoundary } from '@/components/system/ErrorBoundary'

const StudioEditor = lazy(() =>
  import('@/features/studio/StudioEditor').then((m) => ({
    default: m.StudioEditor,
  })),
)

const ThemePanel = lazy(() => import('@/features/studio/panels/ThemePanel'))
const PublishDrawer = lazy(() => import('@/features/studio/PublishDrawer'))

export function StudioRoutes() {
  return (
    <ErrorBoundary fallback={<EditorSkeleton state="error" />}>
      <Suspense fallback={<EditorSkeleton />}>
        <Routes>
          <Route path="/studio/:appId" element={<StudioEditor />}>
            <Route path="theme" element={<ThemePanel />} />
            <Route path="publish" element={<PublishDrawer />} />
          </Route>
        </Routes>
      </Suspense>
    </ErrorBoundary>
  )
}

A note on Suspense fallbacks. Spinners are a tell. Skeletons that match the layout move LCP and stop the jump-shift that wrecks CLS. We saw CLS on the editor route drop from 0.18 to under 0.05 the day we swapped spinners for skeletons with the right bounding boxes.

The other splitting pattern that’s underrated. Dynamic import on user action, not on mount.

async function exportProjectAsPdf(projectId: string) {
  const { generatePdf } = await import('@/features/export/pdf')
  return generatePdf(projectId)
}

PDF export was 220 KB of a niche library that maybe a sliver of users ever clicked. Moving it behind the click handler took it off the critical path entirely.

Runtime renders

This is where I see the most cargo culting. Someone reads about React.memo, wraps every component, and three weeks later the app is slower because the props are inline objects and the comparison does more work than the render it was supposed to prevent.

My rule. Profile first. Memoize components that re-render too often, not the ones that look expensive. React DevTools Profiler is free.

For lists, virtualization is non-negotiable past a few hundred rows. On the builder canvas the layer list could hit a thousand nested blocks on busier creator projects. Without virtualization, scrolling was unusable on a Chromebook. With @tanstack/react-virtual it scrolled smoothly on a six-year-old laptop.

import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

export function LayerList({ layers }: { layers: Layer[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const rowVirtualizer = useVirtualizer({
    count: layers.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 32,
    overscan: 8,
  })

  return (
    <div ref={parentRef} className="h-full overflow-auto">
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((row) => (
          <LayerRow
            key={layers[row.index].id}
            layer={layers[row.index]}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              transform: `translateY(${row.start}px)`,
              height: row.size,
            }}
          />
        ))}
      </div>
    </div>
  )
}

LayerRow is wrapped in React.memo with a custom comparator that checks id, name, and isSelected. Once you isolate the bit that changes between renders, memo earns its keep.

Edge for the static parts

Not every byte has to come from your origin. At a live-video creator platform I led engineering at, we ran creator profile pages, /creator/:slug, through Cloudflare Workers. Dynamic Open Graph metadata per locale, cache the assembled HTML, serve from the closest pop. Part of a long rework that pulled the team out of a velocity slump and meaningfully cut our production bill.

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(req.url)
    const locale = pickLocale(req)
    const ogVersion = env.OG_VERSION

    const cacheKey = new Request(
      `${url.origin}${url.pathname}|${locale}|${ogVersion}`,
      req,
    )
    const cache = caches.default

    const cached = await cache.match(cacheKey)
    if (cached) return cached

    const origin = await fetch(`https://origin.example.com${url.pathname}`, {
      headers: { 'accept-language': locale, 'x-og-version': ogVersion },
    })

    if (!origin.ok) return origin

    const response = new Response(origin.body, origin)
    response.headers.set('cache-control', 'public, max-age=60, s-maxage=300')
    ctx.waitUntil(cache.put(cacheKey, response.clone()))
    return response
  },
}

The shape that matters is the cache key. Path, locale, and a versioned og_version knob that lets us bust the cache cleanly when the OG template changes.

Performance budgets in CI

The hardest part of frontend perf isn’t the first optimization, it’s keeping the win. The bundle creeps back. Someone imports all of lodash. A new charting feature lands on the dashboard. Without a guard, you’re back to 1.8 MB by next quarter.

So we put a budget in CI and let it fail PRs.

name: web-ci
on: [pull_request]

jobs:
  bundle-budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - name: enforce budget
        run: pnpm size-limit
      - name: lighthouse-ci
        run: pnpm lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The size-limit config pins each chunk. Main app under 180 KB gzipped, vendor under 150, anything else flagged. Lighthouse CI runs against three representative routes per PR. If LCP, CLS, or TBT regress beyond thresholds, the check fails and the author either justifies it or fixes it.

This is what turns frontend performance from heroics into a habit.

Takeaways

  • Open the bundle analyzer before you write a single line of optimization code.
  • Split at routes and at user-action boundaries. Don’t split every panel.
  • Memoize the components that re-render too often, not the ones that look expensive.
  • A performance budget in CI is the only thing that keeps the win.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.