git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/codex/perf-client-side-rendering" ~/.claude/skills/intense-visions-harness-engineering-perf-client-side-rendering-7036d7 && rm -rf "$T"
agents/skills/codex/perf-client-side-rendering/SKILL.mdClient-Side Rendering
Master client-side rendering performance — SPA rendering optimization, reducing unnecessary re-renders, skeleton screen patterns, progressive rendering strategies, virtual DOM efficiency, React performance profiling, and concurrent rendering features for responsive user interfaces.
When to Use
- A SPA has slow initial render due to large JavaScript bundles
- React DevTools Profiler shows components re-rendering unnecessarily
- User interactions feel sluggish (INP > 200ms) in a client-rendered application
- Skeleton screens are needed to improve perceived performance during data fetching
- State changes in parent components cause expensive child re-renders
- A dashboard with many widgets re-renders all widgets when one updates
- Form inputs lag because typing triggers expensive renders in unrelated components
- List rendering with hundreds of items causes visible frame drops
- Transitioning between views in a SPA shows blank content instead of loading states
- React concurrent features (useTransition, useDeferredValue) could improve responsiveness
Instructions
-
Profile rendering with React DevTools Profiler. Identify which components render, why, and how long they take:
1. Open React DevTools → Profiler tab 2. Click "Record" → interact with the application → "Stop" 3. Examine the flame chart: - Gray components: did not render - Colored components: rendered (warmer = slower) - "Why did this render?" shows the trigger 4. Look for: - Components rendering on unrelated state changes - Large subtrees re-rendering for small changes - Components rendering >16ms (frame budget) -
Prevent unnecessary re-renders with memoization. Use React.memo, useMemo, and useCallback strategically:
// React.memo: skip re-render when props are unchanged const ExpensiveList = React.memo(function ExpensiveList({ items, onItemClick, }: { items: Item[]; onItemClick: (id: string) => void; }) { return ( <ul> {items.map(item => ( <ListItem key={item.id} item={item} onClick={onItemClick} /> ))} </ul> ); }); // Parent: stabilize callback reference function Dashboard() { const [items, setItems] = useState<Item[]>([]); const [selectedId, setSelectedId] = useState<string | null>(null); // useCallback: stable reference across renders const handleItemClick = useCallback((id: string) => { setSelectedId(id); }, []); return ( <> <Sidebar selectedId={selectedId} /> <ExpensiveList items={items} onItemClick={handleItemClick} /> </> ); } -
Implement skeleton screens for data-loading states. Skeletons reduce perceived load time by 15-30%:
function ProductGrid() { const { data, isLoading } = useProducts(); if (isLoading) { return ( <div className="grid"> {Array.from({ length: 12 }, (_, i) => ( <ProductCardSkeleton key={i} /> ))} </div> ); } return ( <div className="grid"> {data.map(product => <ProductCard key={product.id} product={product} />)} </div> ); }Use a CSS shimmer animation (
) onbackground: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite
elements with explicit dimensions matching the loaded content to prevent layout shift..skeleton -
Use concurrent rendering for responsive interactions. React 18 concurrent features prevent expensive renders from blocking user input:
import { useTransition, useDeferredValue } from 'react'; // useTransition: mark state updates as non-urgent function SearchPage() { const [query, setQuery] = useState(''); const [results, setResults] = useState<Result[]>([]); const [isPending, startTransition] = useTransition(); function handleSearch(e: React.ChangeEvent<HTMLInputElement>) { const value = e.target.value; setQuery(value); // urgent: update input immediately startTransition(() => { // non-urgent: can be interrupted by user input const filtered = filterResults(allData, value); setResults(filtered); }); } return ( <> <input value={query} onChange={handleSearch} /> {isPending && <Spinner />} <ResultsList results={results} /> </> ); } // useDeferredValue: defer expensive re-renders function FilteredList({ filter }: { filter: string }) { const deferredFilter = useDeferredValue(filter); return ( <div style={{ opacity: filter !== deferredFilter ? 0.7 : 1 }}> <ExpensiveList filter={deferredFilter} /> </div> ); } -
Optimize list rendering. Large lists are the most common CSR performance problem:
// Key stability: use stable IDs, not array index // Bad: items.map((item, index) => <Item key={index} ... />) // Good: items.map(item => <Item key={item.id} ... />) // Avoid creating new objects/arrays in render // Bad: <List items={data.filter(d => d.active)} /> // Good: const activeItems = useMemo( () => data.filter(d => d.active), [data] ); return <List items={activeItems} />; // For very long lists (1000+), use virtualization // See: perf-lazy-loading skill for virtual scrolling patterns -
Batch state updates for fewer renders. React 18 automatically batches state updates in all contexts:
// React 18: all state updates are batched automatically // This triggers ONE re-render, not three: async function handleSubmit() { const data = await fetchData(); setItems(data.items); // batched setTotal(data.total); // batched setLoading(false); // batched → single re-render } // When you need to force a synchronous update (rare): import { flushSync } from 'react-dom'; flushSync(() => { setMeasurement(value); // renders immediately }); // DOM is updated here — safe to measure const height = ref.current.offsetHeight; -
Profile with the Performance panel. Beyond React DevTools, the Chrome Performance panel shows the full picture:
1. Performance tab → Record → interact → Stop 2. Look for: - Long Tasks (>50ms) in the Main thread - Scripting vs Rendering vs Painting breakdown - React commit phases (look for "React" in the call stack) 3. Common findings: - Large component trees re-rendering: many short React frames - Single expensive computation: one long scripting block - Layout thrashing: alternating "Recalculate Style" and "Layout"
Details
Virtual DOM Reconciliation Cost
React's reconciliation algorithm diffs the previous and next virtual DOM trees to determine minimal DOM updates. The diffing itself is O(n) where n is the number of elements. For 1000 list items, the diff cost is ~1-5ms. The expensive part is DOM mutation: inserting, moving, or removing real DOM nodes costs ~0.1-0.5ms each. This is why stable keys are critical — they help React match elements across renders and minimize DOM mutations.
Worked Example: Figma File Browser
Figma uses windowed rendering plus React.memo on file cards with Zustand selector-based subscriptions, so selecting a file does not re-render the grid. The search input uses useDeferredValue to keep typing responsive. Result: 60fps scrolling through 10,000+ files with <50ms INP.
Worked Example: Linear Issue Tracker
Linear achieves instant-feeling interactions via: (1) optimistic updates before server confirmation, (2) fine-grained state subscriptions so status changes re-render only that row, (3) CSS transitions instead of React-driven animations, (4) skeleton screens matching loaded layout. Result: <50ms INP for status changes and zero layout shift.
Anti-Patterns
Premature memoization. Only memoize components that receive complex props and render frequently, are expensive (>5ms), or sit below frequently-changing state. Profile first, memoize second.
State in the wrong component. Lifting state too high causes entire subtrees to re-render. Keep state as close to where it is used as possible.
Creating objects in JSX props.
<Child style={{ color: 'red' }} /> creates a new object every render, defeating React.memo. Extract constants or use useMemo.
Synchronous heavy computation during render. Move heavy computation (sorting, filtering 10K+ items) to a Web Worker or use useDeferredValue.
Source
- React: Performance — https://react.dev/learn/render-and-commit
- React: useTransition — https://react.dev/reference/react/useTransition
- React: Profiler — https://react.dev/reference/react/Profiler
- web.dev: Rendering performance — https://web.dev/articles/rendering-performance
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- React DevTools Profiler shows no unnecessary re-renders on common interactions.
- All loading states use skeleton screens that match loaded content dimensions.
- useTransition or useDeferredValue is used for expensive filtering/sorting operations.
- List rendering uses stable keys and memoized items where beneficial.
- INP is under 200ms for all common user interactions.