DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Performance Optimization Patterns in React: Beyond the Basics

Performance Optimization Patterns in React: Beyond the Basics

Performance optimization in React feels like a solved problem—everyone knows about useMemo, useCallback, and code splitting. But knowing about them and actually using them correctly in production are different things.

I've watched teams waste weeks micro-optimizing components that weren't bottlenecks, while leaving genuine performance killers untouched. CitizenApp started hitting 4.2MB bundle sizes before I got serious about optimization. After systematic work, we hit 1.1MB. That's not luck—it's a pattern.

Here's what actually matters, based on what burned me in production.

Measure First, Optimize Second

This sounds obvious until you realize most teams skip it entirely. I've seen engineers confidently implement useMemo on functions that run once per session, while their actual bottleneck is unoptimized images or N+1 API calls.

Set a performance budget before you write code:

// .github/workflows/bundle-check.yml example concept
// In your CI pipeline, track bundle sizes

const performanceBudget = {
  main: { max: 150, maxAsync: 50 }, // KB
  analytics: { max: 30 },
  editor: { max: 200 },
};

// Use Bundle Analyzer to actually see what's in your bundles
// npm install -D @next/bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

I prefer Cloudflare Pages with Vercel's Analytics because they give real-world timing data, not just lab metrics. Lab metrics (Lighthouse) optimize for a specific machine. Real users have 4G on a 2019 iPhone. That's your actual performance budget.

For CitizenApp, we set these constraints:

  • Core bundle: 150KB (gzipped)
  • Each async chunk: 50KB max
  • Time to Interactive: <2.5s on 4G/mid-range device
  • Largest Contentful Paint: <1.8s

These numbers aren't arbitrary—they're based on conversion data. We lose measurable revenue when LCP exceeds 2.2s.

Memoization: The Trade-off Nobody Talks About

useMemo and useCallback carry a hidden cost: memory overhead and closure capture complexity. I've seen code like this:

// ❌ Bad: Memoizing everything
const MyComponent = ({ items, filter, onSelect }) => {
  const memoizedItems = useMemo(() => 
    items.filter(i => i.status === filter),
    [items, filter]
  );

  const memoizedHandler = useCallback((item) => {
    onSelect(item);
  }, [onSelect]);

  const memoizedChildren = useMemo(() => (
    memoizedItems.map(item => (
      <ItemCard key={item.id} item={item} onClick={memoizedHandler} />
    ))
  ), [memoizedItems, memoizedHandler]);

  return <div>{memoizedChildren}</div>;
};
Enter fullscreen mode Exit fullscreen mode

This creates three dependency graphs to maintain. If onSelect changes, the callback recreates, which invalidates the children memo. You're spending more CPU tracking dependencies than you'd spend just re-rendering.

Here's my actual pattern from CitizenApp:

// ✅ Good: Memoize only data transformations with expensive operations
const UserAnalyticsDashboard = ({ userId, timeRange }) => {
  // Expensive computation: aggregate 10K events
  const metrics = useMemo(() => {
    const events = fetchUserEvents(userId, timeRange); // Assume this is cached
    return computeMetricsFromEvents(events); // O(n) operation
  }, [userId, timeRange]);

  // Don't memoize simple renders—let React's reconciliation handle it
  return (
    <div>
      <MetricsChart data={metrics} />
      <DataTable data={metrics.breakdown} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The rule I actually follow:

  1. Memoize only if the computation takes >1ms (measure it)
  2. Memoize objects/functions passed to memo'd children (otherwise memo is useless)
  3. Never memoize for "safety"—memoization is a performance optimization, not a correctness tool

This mindset shift alone cut unnecessary memory overhead in CitizenApp by 30%.

Code Splitting: The Right Way

Everyone splits code. Few people do it strategically. I've seen teams split every route, creating 50+ chunks that defeat HTTP/2 multiplexing benefits.

// ❌ Over-splitting
const AdminPanel = lazy(() => import('./admin/Panel'));
const AdminUsers = lazy(() => import('./admin/users'));
const AdminRoles = lazy(() => import('./admin/roles'));
const AdminAudit = lazy(() => import('./admin/audit'));

// ✅ Strategic splitting
const AdminModule = lazy(() => import('./admin')); // Single bundle, lazy-loaded

// Keep together what's always used together
export const Admin = () => (
  <Suspense fallback={<Spinner />}>
    <AdminModule />
  </Suspense>
);

// Inside admin/index.tsx: split by role/permission, not page
const ROUTE_CONFIGS = {
  users: { component: Users, requiredRole: 'admin' },
  roles: { component: Roles, requiredRole: 'superadmin' },
  audit: { component: Audit, requiredRole: 'auditor' },
};
Enter fullscreen mode Exit fullscreen mode

For CitizenApp, we split at the business capability level, not the UI component level:

  • core.js: Layout, routing, auth (loaded immediately)
  • tenant-dashboard.js: Main dashboard (loaded when user logs in)
  • ai-features.js: All 9 AI features + their dependencies (loaded on-demand)
  • admin.js: Admin tools (loaded if user has admin role)

This kept our core bundle at 150KB while deferring 600KB of features users might never touch.

Bundle Analysis: See the Actual Problem

npm run build -- --analyze
# Or use: npm install -D webpack-bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

This shows you what's actually in your bundle. I discovered that lodash was being fully imported in 3 places (500KB total) despite us only using 6 functions.

// ❌ Before: 500KB
import _ from 'lodash';
const sorted = _.sortBy(items, 'date');

// ✅ After: 2KB
import sortBy from 'lodash-es/sortBy';
const sorted = sortBy(items, 'date');

// Or even better, no dependency
const sorted = items.sort((a, b) => a.date - b.date);
Enter fullscreen mode Exit fullscreen mode

That single change saved 500KB in CitizenApp's production bundle.

Gotcha: Hydration Mismatches Kill Performance

This burned me hard. We optimized the bundle perfectly, but initial render was slow because of hydration mismatches in React 18.

// ❌ This causes client-side re-render (defeating SSR benefits)
const Component = () => {
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  return mounted ? <ExpensiveComponent /> : null;
};

// ✅ Use Suspense boundaries properly
const Component = ({ data }) => (
  <Suspense fallback={<Skeleton />}>
    <ExpensiveComponent data={data} />
  </Suspense>
);
Enter fullscreen mode Exit fullscreen mode

Hydration mismatches force React to throw away the server-rendered HTML and re-render entirely. Your LCP skyrockets. Track this with suppressHydrationWarning being a symptom, not a solution.

What I Actually Do Now

  1. Set measurable budgets before coding
  2. Profile with real users (not Lighthouse)
  3. Memoize judiciously—only O(n²) or worse operations
  4. Split at capability boundaries, not component boundaries
  5. Audit bundle content quarterly
  6. Test hydration on every deployment

These patterns took CitizenApp from 4.2MB → 1.1MB bundle, and TTI from 4.8s → 1.9s on 4G. That translates to measurable conversion improvement.

Performance isn't about being the fastest—it's about being fast enough for your users' hardware. Optimize for that, not for benchmarks.

Top comments (0)