React Performance Guidelines
Version 1.0 Vercel Engineering January 2026
Note: This document is mainly for agents and LLMs to follow when maintaining, generating, or refactoring React and Next.js codebases at Vercel. Humans may also find it useful, but guidance here is optimized for automation and consistency by AI-assisted workflows.
Abstract
Performance optimization guide for React and Next.js applications, ordered by impact. Sections 1-2 yield the highest gains (2-10x improvements), sections 3-5 provide medium gains (20-50%), and sections 6-8 offer incremental improvements (5-20%) in hot paths.
Table of Contents
- •Eliminating Waterfalls - CRITICAL
- •Bundle Size Optimization - CRITICAL
- •Server-Side Performance - HIGH
- •Client-Side Data Fetching - MEDIUM-HIGH
- •Re-render Optimization - MEDIUM
- •Rendering Performance - MEDIUM
- •JavaScript Performance - LOW-MEDIUM
- •Advanced Patterns - LOW
1. Eliminating Waterfalls
Impact: CRITICAL (2-10x improvement)
Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
1.1 Promise.all() for Independent Operations
When async operations have no interdependencies, execute them
concurrently using Promise.all().
Incorrect (sequential execution, 3 round trips):
const user = await fetchUser() const posts = await fetchPosts() const comments = await fetchComments()
Correct (parallel execution, 1 round trip):
const [user, posts, comments] = await Promise.all([ fetchUser(), fetchPosts(), fetchComments() ])
1.2 Dependency-Based Parallelization
For operations with partial dependencies, use better-all to
maximize parallelism. It automatically starts each task at the
earliest possible moment.
Incorrect (profile waits for config unnecessarily):
const [user, config] = await Promise.all([ fetchUser(), fetchConfig() ]) const profile = await fetchProfile(user.id)
Correct (config and profile run in parallel):
import { all } from 'better-all'
const { user, config, profile } = await all({
async user() { return fetchUser() },
async config() { return fetchConfig() },
async profile() {
return fetchProfile((await this.$.user).id)
}
})
Reference: better-all
1.3 Prevent Waterfall Chains in API Routes
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
Incorrect (config waits for auth, data waits for both):
export async function GET(request: Request) {
const session = await auth()
const config = await fetchConfig()
const data = await fetchData(session.user.id)
return Response.json({ data, config })
}
Correct (auth and config start immediately):
export async function GET(request: Request) {
const sessionPromise = auth()
const configPromise = fetchConfig()
const session = await sessionPromise
const [config, data] = await Promise.all([
configPromise,
fetchData(session.user.id)
])
return Response.json({ data, config })
}
2. Bundle Size Optimization
Impact: CRITICAL (directly affects TTI and LCP)
Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
2.1 Dynamic Imports for Heavy Components
Use next/dynamic to lazy-load large components not needed on
initial render.
Incorrect (Monaco bundles with main chunk ~300KB):
import { MonacoEditor } from './monaco-editor'
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
Correct (Monaco loads on demand):
import dynamic from 'next/dynamic'
const MonacoEditor = dynamic(
() => import('./monaco-editor').then(m => m.MonacoEditor),
{ ssr: false }
)
function CodePanel({ code }: { code: string }) {
return <MonacoEditor value={code} />
}
2.2 Preload Based on User Intent
Preload heavy bundles before they're needed to reduce perceived latency.
Example (preload on hover/focus):
function EditorButton({ onClick }: { onClick: () => void }) {
const preload = () => {
void import('./monaco-editor')
}
return (
<button
onMouseEnter={preload}
onFocus={preload}
onClick={onClick}
>
Open Editor
</button>
)
}
2.3 Conditional Module Loading
Load large data or modules only when a feature is activated.
Example (lazy-load animation frames):
function AnimationPlayer({ enabled }: { enabled: boolean }) {
const [frames, setFrames] = useState<Frame[] | null>(null)
useEffect(() => {
if (enabled && !frames) {
import('./animation-frames.js')
.then(mod => setFrames(mod.frames))
.catch(() => setEnabled(false))
}
}, [enabled, frames])
if (!frames) return <Skeleton />
return <Canvas frames={frames} />
}
2.4 Defer Non-Critical Third-Party Libraries
Analytics, logging, and error tracking don't block user interaction. Load them after hydration.
Incorrect (blocks initial bundle):
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
Correct (loads after hydration):
import dynamic from 'next/dynamic'
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
)
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
)
}
3. Server-Side Performance
Impact: HIGH (eliminates server-side waterfalls)
3.1 Parallel Data Fetching with Component Composition
React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
Incorrect (Sidebar waits for Page's fetch to complete):
export default async function Page() {
const header = await fetchHeader()
return (
<div>
<div>{header}</div>
<Sidebar />
</div>
)
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
Correct (both fetch simultaneously):
async function Header() {
const data = await fetchHeader()
return <div>{data}</div>
}
async function Sidebar() {
const items = await fetchSidebarItems()
return <nav>{items.map(renderItem)}</nav>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
</div>
)
}
3.2 Minimize Serialization at RSC Boundaries
The React Server/Client boundary serializes all object properties. Only pass fields that the client actually uses.
Incorrect (serializes all 50 fields):
async function Page() {
const user = await fetchUser() // 50 fields
return <Profile user={user} />
}
'use client'
function Profile({ user }: { user: User }) {
return <div>{user.name}</div> // uses 1 field
}
Correct (serializes only 1 field):
async function Page() {
const user = await fetchUser()
return <Profile name={user.name} />
}
'use client'
function Profile({ name }: { name: string }) {
return <div>{name}</div>
}
3.3 Per-Request Deduplication with React.cache()
Use React.cache() for server-side request deduplication.
Authentication and database queries benefit most.
Usage:
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user?.id) return null
return await db.user.findUnique({
where: { id: session.user.id }
})
})
Within a single request, multiple calls to getCurrentUser()
execute the query only once.
3.4 Cross-Request LRU Caching
React.cache() only works within one request. For data shared
across sequential requests (user clicks button A then button B),
use an LRU cache.
Implementation:
import { LRUCache } from 'lru-cache'
const cache = new LRUCache<string, any>({
max: 1000,
ttl: 5 * 60 * 1000 // 5 minutes
})
export async function getUser(id: string) {
const cached = cache.get(id)
if (cached) return cached
const user = await db.user.findUnique({ where: { id } })
cache.set(id, user)
return user
}
Use when sequential user actions hit multiple endpoints needing the same data within seconds. In serverless, consider Redis for cross-process caching.
4. Client-Side Data Fetching
Impact: MEDIUM-HIGH (automatic deduplication)
4.1 Use SWR for Automatic Deduplication
SWR enables request deduplication, caching, and revalidation across component instances.
Incorrect (no deduplication, each instance fetches):
function UserList() {
const [users, setUsers] = useState([])
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(setUsers)
}, [])
}
Correct (multiple instances share one request):
import useSWR from 'swr'
function UserList() {
const { data: users } = useSWR('/api/users', fetcher)
}
4.2 Deduplicate Global Event Listeners
Use useSWRSubscription() to share global event listeners
across component instances.
Incorrect (N instances = N listeners):
function KeyboardShortcut({ onTrigger }: Props) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === 'k') {
onTrigger()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onTrigger])
}
Correct (N instances = 1 listener):
import useSWRSubscription from 'swr/subscription'
function useKeyboardShortcut(key: string, callback: () => void) {
useSWRSubscription(['keydown', key], (_, { next }) => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.key === key) {
next(null, e)
callback()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
})
}
5. Re-render Optimization
Impact: MEDIUM (reduces unnecessary work)
5.1 Subscribe to Derived State
Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
Incorrect (re-renders on every pixel change):
function Sidebar() {
const width = useWindowWidth() // updates continuously
const isMobile = width < 768
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
Correct (re-renders only when boolean changes):
function Sidebar() {
const isMobile = useMediaQuery('(max-width: 767px)')
return <nav className={isMobile ? 'mobile' : 'desktop'}>
}
5.2 Narrow Effect Dependencies
Specify primitive dependencies instead of objects to minimize effect re-runs.
Incorrect (re-runs on any user field change):
useEffect(() => {
console.log(user.id)
}, [user])
Correct (re-runs only when id changes):
useEffect(() => {
console.log(user.id)
}, [user.id])
5.3 Use Transitions for Non-Urgent Updates
Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
Incorrect (blocks UI on every scroll):
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => setScrollY(window.scrollY)
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
Correct (non-blocking updates):
import { startTransition } from 'react'
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handler = () => {
startTransition(() => setScrollY(window.scrollY))
}
window.addEventListener('scroll', handler, { passive: true })
return () => window.removeEventListener('scroll', handler)
}, [])
}
5.4 Extract to Memoized Components
Extract expensive work into memoized components to enable early returns before computation.
Incorrect (computes avatar even when loading):
function Profile({ user, loading }: Props) {
const avatar = useMemo(() => {
const id = computeAvatarId(user)
return <Avatar id={id} />
}, [user])
if (loading) return <Skeleton />
return <div>{avatar}</div>
}
Correct (skips computation when loading):
const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
const id = useMemo(() => computeAvatarId(user), [user])
return <Avatar id={id} />
})
function Profile({ user, loading }: Props) {
if (loading) return <Skeleton />
return (
<div>
<UserAvatar user={user} />
</div>
)
}
5.5 Defer State Reads to Usage Point
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
Incorrect (subscribes to all searchParams changes):
function ShareButton({ chatId }: { chatId: string }) {
const searchParams = useSearchParams()
const handleShare = () => {
const ref = searchParams.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
Correct (reads on demand, no subscription):
function ShareButton({ chatId }: { chatId: string }) {
const handleShare = () => {
const params = new URLSearchParams(window.location.search)
const ref = params.get('ref')
shareChat(chatId, { ref })
}
return <button onClick={handleShare}>Share</button>
}
6. Rendering Performance
Impact: MEDIUM (reduces rendering work)
6.1 Hoist Static JSX Elements
Extract static JSX outside components to avoid re-creation.
Incorrect (recreates element every render):
function LoadingSkeleton() {
return <div className="animate-pulse h-20 bg-gray-200" />
}
function Container() {
return (
<div>
{loading && <LoadingSkeleton />}
</div>
)
}
Correct (reuses same element):
const loadingSkeleton = (
<div className="animate-pulse h-20 bg-gray-200" />
)
function Container() {
return (
<div>
{loading && loadingSkeleton}
</div>
)
}
6.2 Use Activity Component for Show/Hide
Use React's <Activity> to preserve state/DOM for expensive
components that frequently toggle visibility.
Usage:
import { Activity } from 'react'
function Dropdown({ isOpen }: Props) {
return (
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ExpensiveMenu />
</Activity>
)
}
6.3 CSS content-visibility for Long Lists
Apply content-visibility: auto to defer off-screen rendering.
CSS:
.message-item {
content-visibility: auto;
contain-intrinsic-size: 0 80px;
}
Example:
function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="overflow-y-auto h-screen">
{messages.map(msg => (
<div key={msg.id} className="message-item">
<Avatar user={msg.author} />
<div>{msg.content}</div>
</div>
))}
</div>
)
}
For 1000 messages, browser skips layout/paint for ~990 off-screen items (10x faster initial render).
6.4 Optimize SVG Precision
Reduce SVG coordinate precision to decrease file size.
Incorrect (excessive precision):
<path d="M 10.293847 20.847362 L 30.938472 40.192837" />
Correct (1 decimal place):
<path d="M 10.3 20.8 L 30.9 40.2" />
Automate with SVGO:
npx svgo --precision=1 --multipass icon.svg
7. JavaScript Performance
Impact: LOW-MEDIUM (micro-optimizations for hot paths)
7.1 Combine Multiple Array Iterations
Multiple .filter() or .map() calls iterate the array
multiple times. Combine into one loop.
Incorrect (3 iterations):
const admins = users.filter(u => u.isAdmin) const testers = users.filter(u => u.isTester) const inactive = users.filter(u => !u.isActive)
Correct (1 iteration):
const admins: User[] = []
const testers: User[] = []
const inactive: User[] = []
for (const user of users) {
if (user.isAdmin) admins.push(user)
if (user.isTester) testers.push(user)
if (!user.isActive) inactive.push(user)
}
7.2 Cache Property Access in Loops
Cache object property lookups in hot paths.
Incorrect (3 lookups x N iterations):
for (let i = 0; i < arr.length; i++) {
process(obj.config.settings.value)
}
Correct (1 lookup total):
const value = obj.config.settings.value
const len = arr.length
for (let i = 0; i < len; i++) {
process(value)
}
7.3 Use Set/Map for O(1) Lookups
Convert arrays to Set/Map for repeated membership checks.
Incorrect (O(n) per check):
const allowedIds = ['a', 'b', 'c', ...] items.filter(item => allowedIds.includes(item.id))
Correct (O(1) per check):
const allowedIds = new Set(['a', 'b', 'c', ...]) items.filter(item => allowedIds.has(item.id))
7.4 Early Exit from Loops
Exit as soon as result is determined.
Incorrect (always iterates all):
const hasAdmin = users.some(u => u.role === 'admin') const admin = users.find(u => u.role === 'admin') // Two iterations
Correct (single pass with early exit):
let admin: User | undefined
for (const user of users) {
if (user.role === 'admin') {
admin = user
break
}
}
const hasAdmin = admin !== undefined
7.5 Hoist RegExp Creation
Don't create RegExp inside render. Hoist to module scope or
memoize with useMemo().
Incorrect (new RegExp every render):
function Highlighter({ text, query }: Props) {
const regex = new RegExp(`(${query})`, 'gi')
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
Correct (memoize or hoist):
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function Highlighter({ text, query }: Props) {
const regex = useMemo(
() => new RegExp(`(${escapeRegex(query)})`, 'gi'),
[query]
)
const parts = text.split(regex)
return <>{parts.map((part, i) => ...)}</>
}
7.6 Cache Storage API Calls
localStorage, sessionStorage, and document.cookie are
synchronous and expensive. Cache reads in memory.
Incorrect (reads storage on every call):
function getTheme() {
return localStorage.getItem('theme') ?? 'light'
}
// Called 10 times = 10 storage reads
Correct (Map cache):
const storageCache = new Map<string, string | null>()
function getLocalStorage(key: string) {
if (!storageCache.has(key)) {
storageCache.set(key, localStorage.getItem(key))
}
return storageCache.get(key)
}
function setLocalStorage(key: string, value: string) {
localStorage.setItem(key, value)
storageCache.set(key, value) // keep cache in sync
}
7.7 Build Index Maps for Repeated Lookups
Multiple .find() calls by the same key should use a Map.
Incorrect (O(n) per lookup):
function processOrders(orders: Order[], users: User[]) {
return orders.map(order => ({
...order,
user: users.find(u => u.id === order.userId)
}))
}
Correct (O(1) per lookup):
function processOrders(orders: Order[], users: User[]) {
const userById = new Map(users.map(u => [u.id, u]))
return orders.map(order => ({
...order,
user: userById.get(order.userId)
}))
}
Build map once (O(n)), then all lookups are O(1). For 1000 orders x 1000 users: 1M ops -> 2K ops.
8. Advanced Patterns
Impact: LOW (advanced patterns for specific cases)
8.1 useLatest for Stable Callback Refs
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
Implementation:
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
Incorrect (effect re-runs on every callback change):
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
Correct (stable effect, fresh callback):
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchRef = useLatest(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchRef.current(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
8.2 Store Event Handlers in Refs
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
Incorrect (re-subscribes on every render):
function useWindowEvent(event: string, handler: () => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}
Correct (stable subscription):
function useWindowEvent(event: string, handler: () => void) {
const handlerRef = useRef(handler)
useEffect(() => {
handlerRef.current = handler
}, [handler])
useEffect(() => {
const listener = () => handlerRef.current()
window.addEventListener(event, listener)
return () => window.removeEventListener(event, listener)
}, [event])
}