Sheetal Sindhu

Jun 25, 2025 • 7 min read

The Frontend Performance Optimization Checklist I Wish I Had 3 Years Ago

A practical guide for developers who want to ship fast, performant applications

After 3+ years of building React applications with Next.js and TypeScript, I've learned that performance optimization isn't just about fancy metrics—it's about creating delightful user experiences that convert better and cost less to maintain. Here's the checklist I wish I had when I started.

🚀 Core Web Vitals: The Non-Negotiables

Largest Contentful Paint (LCP) < 2.5s

The Problem: Your main content takes forever to load.

Quick Wins:

// ❌ Bad: Large images without optimization
<img src="/hero-image.jpg" alt="Hero" />

// ✅ Good: Next.js Image component with priority
import Image from 'next/image'

<Image
  src="/hero-image.jpg"
  alt="Hero"
  width={1200}
  height={600}
  priority
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
/>

Advanced: Use next/dynamic for heavy components:

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false
})

First Input Delay (FID) < 100ms

The Problem: Your app feels unresponsive to user interactions.

React-Specific Solutions:

// ❌ Bad: Blocking the main thread
const ExpensiveComponent = ({ data }: { data: LargeDataSet }) => {
  const processedData = data.map(item => heavyProcessing(item))
  return <div>{/* render */}</div>
}

// ✅ Good: Use useMemo for expensive calculations
const ExpensiveComponent = ({ data }: { data: LargeDataSet }) => {
  const processedData = useMemo(
    () => data.map(item => heavyProcessing(item)),
    [data]
  )
  return <div>{/* render */}</div>
}

// ✅ Better: Use React.startTransition for non-urgent updates
const [isPending, startTransition] = useTransition()

const handleSearch = (query: string) => {
  setQuery(query) // Urgent: update input
  startTransition(() => {
    setResults(searchResults(query)) // Non-urgent: update results
  })
}

Cumulative Layout Shift (CLS) < 0.1

The Problem: Your layout jumps around as content loads.

TypeScript-Enhanced Solution:

// Define consistent sizing interfaces
interface ImageDimensions {
  width: number
  height: number
  aspectRatio: string
}

const ImageContainer = styled.div<ImageDimensions>`
  width: ${props => props.width}px;
  height: ${props => props.height}px;
  aspect-ratio: ${props => props.aspectRatio};
`

// Always reserve space for dynamic content
const ProductCard: React.FC<{ product?: Product }> = ({ product }) => {
  if (!product) {
    return <ProductCardSkeleton /> // Same dimensions as actual card
  }
  
  return (
    <ImageContainer width={300} height={200} aspectRatio="3/2">
      <Image src={product.image} alt={product.name} fill />
    </ImageContainer>
  )
}

🎯 React Performance Patterns

1. Memoization Strategy

interface ExpensiveListProps {
  items: Item[]
  onItemClick: (id: string) => void
}

// ❌ Bad: Re-renders every item on any change
const ExpensiveList: React.FC<ExpensiveListProps> = ({ items, onItemClick }) => {
  return (
    <div>
      {items.map(item => (
        <ExpensiveItem 
          key={item.id} 
          item={item} 
          onClick={() => onItemClick(item.id)} 
        />
      ))}
    </div>
  )
}

// ✅ Good: Memoized with stable references
const ExpensiveList: React.FC<ExpensiveListProps> = ({ items, onItemClick }) => {
  const handleItemClick = useCallback((id: string) => {
    onItemClick(id)
  }, [onItemClick])

  return (
    <div>
      {items.map(item => (
        <MemoizedExpensiveItem 
          key={item.id} 
          item={item} 
          onItemClick={handleItemClick}
        />
      ))}
    </div>
  )
}

const MemoizedExpensiveItem = React.memo(ExpensiveItem)

2. Smart Component Splitting

// ❌ Bad: Monolithic component
const Dashboard = () => {
  const [userData, setUserData] = useState<User | null>(null)
  const [analytics, setAnalytics] = useState<Analytics | null>(null)
  const [notifications, setNotifications] = useState<Notification[]>([])

  // All data fetching in one component...
  // Component re-renders when any state changes
}

// ✅ Good: Split by data dependencies
const Dashboard = () => {
  return (
    <>
      <UserProfile /> {/* Only re-renders when user data changes */}
      <AnalyticsDashboard /> {/* Only re-renders when analytics change */}
      <NotificationCenter /> {/* Only re-renders when notifications change */}
    </>
  )
}

3. Virtual Scrolling for Large Lists

import { FixedSizeList as List } from 'react-window'

interface VirtualizedListProps {
  items: LargeDataItem[]
}

const VirtualizedList: React.FC<VirtualizedListProps> = ({ items }) => {
  const Row = ({ index, style }: { index: number; style: CSSProperties }) => (
    <div style={style}>
      <ItemComponent item={items[index]} />
    </div>
  )

  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={80}
      width="100%"
    >
      {Row}
    </List>
  )
}

⚡ Next.js Specific Optimizations

1. Strategic Rendering Strategies

// Static Generation (Best for SEO + Performance)
export const getStaticProps: GetStaticProps = async () => {
  const data = await fetchStaticData()
  return {
    props: { data },
    revalidate: 3600 // ISR: Revalidate every hour
  }
}

// Server-Side Rendering (For dynamic, personalized content)
export const getServerSideProps: GetServerSideProps = async (context) => {
  const userSession = await getSession(context)
  const personalizedData = await fetchUserData(userSession.userId)
  
  return {
    props: { personalizedData }
  }
}

// Client-Side (For dashboard-like apps)
const Dashboard = () => {
  const { data, error } = useSWR('/api/dashboard', fetcher, {
    refreshInterval: 30000, // Refresh every 30 seconds
    revalidateOnFocus: false
  })
  
  if (!data) return <DashboardSkeleton />
  return <DashboardContent data={data} />
}

2. Bundle Optimization

// next.config.js
const nextConfig = {
  experimental: {
    modern: true,
    serverComponents: true
  },
  webpack: (config, { dev, isServer }) => {
    // Analyze bundle in development
    if (!dev && !isServer) {
      config.resolve.alias = {
        ...config.resolve.alias,
        // Replace heavy libraries with lighter alternatives
        'moment': 'dayjs',
        'lodash': 'lodash-es'
      }
    }
    return config
  },
  // Enable compression
  compress: true,
  // Optimize images
  images: {
    formats: ['image/webp', 'image/avif'],
    minimumCacheTTL: 31536000
  }
}

3. API Route Optimization

// pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { unstable_cache } from 'next/cache'

const getCachedProducts = unstable_cache(
  async () => {
    const products = await db.products.findMany()
    return products
  },
  ['products'],
  { revalidate: 3600 } // Cache for 1 hour
)

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Set cache headers
  res.setHeader(
    'Cache-Control',
    'public, s-maxage=3600, stale-while-revalidate=86400'
  )
  
  try {
    const products = await getCachedProducts()
    res.status(200).json(products)
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch products' })
  }
}

🔍 Advanced TypeScript Patterns for Performance

1. Type-Safe Lazy Loading

// Define component props with loading states
interface ComponentProps<T> {
  data?: T
  loading?: boolean
  error?: Error | null
}

// Generic lazy loading hook
function useLazyLoad<T>(
  loader: () => Promise<T>,
  deps: React.DependencyList = []
): ComponentProps<T> {
  const [state, setState] = useState<ComponentProps<T>>({ loading: true })
  
  useEffect(() => {
    let cancelled = false
    
    loader()
      .then(data => {
        if (!cancelled) setState({ data, loading: false })
      })
      .catch(error => {
        if (!cancelled) setState({ error, loading: false })
      })
    
    return () => { cancelled = true }
  }, deps)
  
  return state
}

// Usage with type safety
const MyComponent = () => {
  const { data, loading, error } = useLazyLoad<User[]>(
    () => import('./api/users').then(m => m.fetchUsers()),
    []
  )
  
  if (loading) return <Skeleton />
  if (error) return <ErrorBoundary error={error} />
  return <UserList users={data!} />
}

2. Performance-Aware Component Types

// Define performance constraints at the type level
interface PerformantComponentProps {
  maxItems?: number
  virtualized?: boolean
  memoized?: boolean
}

type OptimizedListProps<T> = {
  items: T[]
  renderItem: (item: T) => React.ReactNode
} & PerformantComponentProps

const OptimizedList = <T extends { id: string }>({
  items,
  renderItem,
  maxItems = 100,
  virtualized = false,
  memoized = true
}: OptimizedListProps<T>) => {
  // Warn about performance issues at compile time
  if (items.length > maxItems && !virtualized) {
    console.warn(`Consider enabling virtualization for ${items.length} items`)
  }
  
  const ItemComponent = memoized ? 
    React.memo(({ item }: { item: T }) => <>{renderItem(item)}</>) :
    ({ item }: { item: T }) => <>{renderItem(item)}</>
  
  if (virtualized) {
    return <VirtualizedList items={items} ItemComponent={ItemComponent} />
  }
  
  return (
    <div>
      {items.slice(0, maxItems).map(item => (
        <ItemComponent key={item.id} item={item} />
      ))}
    </div>
  )
}

🛠 Development Tools & Monitoring

1. Performance Profiling Setup

// utils/performance.ts
export const measurePerformance = (name: string) => {
  if (typeof window !== 'undefined' && window.performance) {
    return {
      start: () => performance.mark(`${name}-start`),
      end: () => {
        performance.mark(`${name}-end`)
        performance.measure(name, `${name}-start`, `${name}-end`)
        const measure = performance.getEntriesByName(name)[0]
        console.log(`${name}: ${measure.duration}ms`)
      }
    }
  }
  return { start: () => {}, end: () => {} }
}

// Hook for component performance
export const usePerformanceProfiler = (componentName: string) => {
  useEffect(() => {
    const profiler = measurePerformance(componentName)
    profiler.start()
    return profiler.end
  }, [componentName])
}

2. Runtime Performance Monitoring

// components/PerformanceMonitor.tsx
import { Profiler, ProfilerOnRenderCallback } from 'react'

const onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  // Only log slow renders in development
  if (process.env.NODE_ENV === 'development' && actualDuration > 16) {
    console.warn(`Slow render detected in ${id}:`, {
      phase,
      actualDuration,
      baseDuration
    })
  }
}

export const PerformanceMonitor: React.FC<{ children: React.ReactNode }> = ({ 
  children 
}) => {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      {children}
    </Profiler>
  )
}

📋 Quick Checklist for Every Feature

Before Shipping:

  • Images: Optimized, correct format (WebP/AVIF), proper sizing

  • Components: Memoized expensive renders, stable callback references

  • Data Fetching: Cached appropriately, loading states implemented

  • Bundle: No unnecessary dependencies, code-splitting applied

  • Metrics: Core Web Vitals measured, performance budget respected

  • TypeScript: Performance constraints encoded in types where applicable

Regular Audits:

  • Lighthouse CI: Automated performance testing in CI/CD

  • Bundle Analyzer: Regular bundle size monitoring

  • Real User Metrics: Track actual user experience, not just lab metrics

  • Performance Budget: Set and enforce performance budgets

🎯 The Reality Check

Performance optimization is not about chasing perfect scores—it's about understanding your users and their constraints. A 90 Lighthouse score that loads instantly on 3G is better than a 100 score that takes 5 seconds on mobile.

Focus on:

  1. Measure first: Don't optimize what you can't measure

  2. User-centric metrics: Care about what users feel, not just what tools report

  3. Progressive enhancement: Make it work, then make it fast

  4. Sustainable practices: Build performance into your development process

The best performance optimization is the one your users notice—because your app just works, fast, everywhere.

Join Sheetal on Peerlist!

Join amazing folks like Sheetal and thousands of other builders on Peerlist.

peerlist.io/

It’s available... this username is available! 😃

Claim your username before it's too late!

This username is already taken, you’re a little late.😐

0

2

1