React Performance Optimization - I: Throttling, Debouncing, and Memoization


Picture this: you're building a search feature for an e-commerce site. Every time a user types a character, your app fires an API request. A user searching for "laptop" triggers six separate API calls in rapid succession. Your server groans under the load, your user's experience stutters, and your app questions your life choices.
When building modern React apps, it's easy to get caught up in component logic and overlook how frequently certain functions get triggered—especially in response to user events like typing, scrolling, or resizing the window.
The Performance Problem We All Face
React is fast, but it's not magic. Every state change triggers a re-render, and every re-render can cascade through your component tree just like how dominoes fall. Without proper optimization, a simple user interaction can cause your app to freeze, your API to crash, or your users to abandon their shopping carts.
The good news? These issues are not that difficult to fix. Three core concepts—throttling, debouncing, and memoization—can solve 90% of your performance headaches.
Throttling
Throttling in React, and in general JavaScript, is a technique used to control the rate at which a function is executed. It ensures that a function is called at most once within a specified time interval, regardless of how many times the event triggering the function occurs during that interval.
🚦 Real-life Analogy: Imagine you're at a toll booth; and it allows only one car to pass through every 30 seconds, no matter how many cars are waiting. That's throttling in a nutshell - like a speed limit on function calls.
When do we need Throttling?
Throttling is particularly helpful when dealing with events that fire continuously:
API calls that need rate limiting
Scroll events
Mouse movement tracking
Window resize handlers
Implementation
import { useRef } from 'react';
function ThrottledAPIExample() {
const lastCallTimeRef = useRef(0);
const throttledAPICall = () => {
const now = Date.now();
// If enough time (2 seconds) has passed since the last call, proceed
if (now - lastCallTimeRef.current >= 2000) {
lastCallTimeRef.current = now;
// Simulate an API call
console.log("API called at:", new Date().toLocaleTimeString());
// For example: fetch('/api/endpoint')
} else {
console.log("Throttled: Too soon!");
}
};
return (
<div>
<h3>Click the button repeatedly</h3>
<button onClick={throttledAPICall}>Call API</button>
</div>
);
}
export default ThrottledAPIExample;
The ThrottledAPIExample
component renders a button. When the button is clicked, it tries to make an API call, but the call is throttled so that it can only happen only once every 2 seconds (2000 ms), no matter how fast you click. If the user tries to press the button before the interval has passed, the API call would not be made.
Debouncing
Debouncing ensures that a function is only called after a specified period of inactivity. If the event is triggered again before the time ends, the timer resets. This is useful when you want to wait until a user stops performing an action. For example, waiting until the user stops typing before sending a search query.
🚦 Real-life Analogy: Debouncing is like waiting for your friend to stop talking before you respond.
When do we need Debouncing?
Debouncing is the perfect technique when you want to wait for user input to stabilize:
Search inputs (the classic use case)
Form validation
Auto-save functionality
Any API call triggered by user typing
Implementation
import { useState, useEffect } from 'react';
function DebouncedInput() {
const [query, setQuery] = useState('');
const [apiQuery, setApiQuery] = useState('');
useEffect(() => {
// Set up a timer to delay the API call
const timer = setTimeout(() => {
setApiQuery(query); // Simulate calling API with this query
console.log('API called with:', query);
}, 500); // 500ms debounce delay
// Cleanup function: clears the timer if user types again quickly
return () => clearTimeout(timer);
}, [query]);
return (
<div>
<h3>Type to search (debounced)</h3>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Start typing..."
/>
<p>Search query sent to API: <strong>{apiQuery}</strong></p>
</div>
);
}
export default DebouncedInput;
As the user types, the input updates immediately but the simulated API call is delayed. The console logs the API call only after the user stops typing for 500ms. If the user types again before 500ms, the previous call is cancelled. This ensures the API is called only once after typing pauses.
Wait, Throttling and Debouncing look like the same thing to me
Throttling and Debouncing both limit how often a function runs, but they behave very differently in practice.
They have the same destination, but their ways are different.
Let’s understand the difference using the example of Typing in a Search Bar:
Debouncing
Waits until the user stops typing for a specific time (say 500ms), then runs the function.
If the user types again before the timer finishes, the timer resets.
Final effect: Function runs only once, after typing is done.
Throttling
Limits the function to run at fixed intervals (say once every 1000ms), even if the user is still typing.
The first few keystrokes may trigger the function, then it waits for the interval before allowing it again.
Use Case Comparison
Scenario | Use | Why |
User typing into a search bar | Debounce | Don't call API on every keypress |
Scroll position update | Throttle | Don't update state 100s of times per second |
Window resize listener | Throttle | Prevent performance issues |
Auto-saving form after user pauses | Debounce | Save only after they’re done editing |
🐢 Debounce = Wait till you're done
⚡ Throttle = Limit how often you go
Memoization
Memoization is an optimization technique that caches the result of expensive function calls and returns the cached result when the same inputs occur again. Instead of recalculating the same result over and over, you cache it and reuse it when the inputs haven't changed.
🚦 Real-life Analogy: Think of a student solving math problems. The first time they solve a tough problem, it takes effort. But once they've written the solution in their notebook, the next time they're asked the same question, they just flip to that page and give the answer instantly.
When do we need Memoization?
Memoization is useful when you’re dealing with:
You’re repeating expensive or time-consuming computations (e.g., filtering, sorting, complex math)
The input doesn't change frequently
You want to prevent unnecessary re-renders in React
You're passing functions or calculated values as props to child components
React Tools for Memoization
React gives us three powerful memoization tools:
React.memo
: Prevents unnecessary re-renders.useMemo
: Caches expensive calculations.useCallback
: Prevents function recreation.
Implementation of React.memo
import { useState, memo } from 'react';
// Without memo, this component re-renders every time parent re-renders
const ExpensiveComponent = memo(({ name, price }) => {
console.log('ExpensiveComponent rendering for:', name);
// Simulate expensive calculation
const expensiveValue = (() => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += i;
}
return result;
})();
return (
<div>
<h3>{name}</h3>
<p>Price: ${price}</p>
<p>Expensive calculation result: {expensiveValue}</p>
</div>
);
});
const ProductList = () => {
const [counter, setCounter] = useState(0);
const [products] = useState([
{ id: 1, name: 'Laptop', price: 1000 },
{ id: 2, name: 'Phone', price: 500 }
]);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>
Counter: {counter}
</button>
{products.map(product => (
<ExpensiveComponent
key={product.id}
name={product.name}
price={product.price}
/>
))}
</div>
);
};
When the ProductList
component renders for the first time, each ExpensiveComponent
runs its expensive calculation and logs its render. Clicking the Counter button causes ProductList
to re-render, but since ExpensiveComponent
is wrapped in memo
and its props (name
and price
) don’t change, it does not re-render. As a result, the expensive calculation doesn't run again, and nothing is logged from ExpensiveComponent
. This shows how React.memo
helps avoid unnecessary renders and boosts performance.
Implementation of useMemo
import { useState, useMemo } from 'react';
const ProductFilter = () => {
const [products] = useState([
{ id: 1, name: 'Laptop', price: 1000, category: 'Electronics' },
{ id: 2, name: 'Phone', price: 500, category: 'Electronics' },
{ id: 3, name: 'Shirt', price: 50, category: 'Clothing' },
{ id: 4, name: 'Jeans', price: 80, category: 'Clothing' }
]);
const [filter, setFilter] = useState('all');
const [counter, setCounter] = useState(0);
// This expensive calculation only runs when products or filter changes
const filteredProducts = useMemo(() => {
console.log('Filtering products...');
if (filter === 'all') return products;
return products.filter(product => product.category === filter);
}, [products, filter]);
// Calculate total price (also expensive)
const totalPrice = useMemo(() => {
console.log('Calculating total price...');
return filteredProducts.reduce((sum, product) => sum + product.price, 0);
}, [filteredProducts]);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>
Counter: {counter}
</button>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All Categories</option>
<option value="Electronics">Electronics</option>
<option value="Clothing">Clothing</option>
</select>
<div>
<h3>Products ({filteredProducts.length})</h3>
<p>Total Price: ${totalPrice}</p>
{filteredProducts.map(product => (
<div key={product.id}>
{product.name} - ${product.price}
</div>
))}
</div>
</div>
);
};
When ProductFilter
renders initially, both useMemo
blocks run — first logging "Filtering products...", then "Calculating total price..." — because it's the first render and both dependencies are fresh. If you now click the Counter button, the component re-renders, but neither the products
array nor the filter
value changes. So, thanks to useMemo
, the expensive filtering and price calculation are skipped, and nothing is logged again. However, if you change the filter from "all" to "Electronics" or "Clothing", the filter value updates, causing filteredProducts
to recompute (logs "Filtering products..."), which in turn updates totalPrice
(logs "Calculating total price..."). This demonstrates how useMemo
prevents unnecessary recalculations by caching values unless their dependencies change.
Implementation of useCallback
import { useState, useCallback, memo } from 'react';
const TodoItem = memo(({ todo, onDelete }) => {
console.log('Rendering:', todo);
return (
<div>
{todo}
<button onClick={() => onDelete(todo)}>Delete</button>
</div>
);
});
const TodoApp = () => {
const [todos, setTodos] = useState(['Buy milk', 'Read book']);
const [counter, setCounter] = useState(0);
const deleteTodo = useCallback((todoToDelete) => {
setTodos((prev) => prev.filter(todo => todo !== todoToDelete));
}, []);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>
Counter: {counter}
</button>
{todos.map(todo => (
<TodoItem key={todo} todo={todo} onDelete={deleteTodo} />
))}
</div>
);
};
When the app first loads, each todo item renders and logs its name. Clicking the Counter button re-renders the parent, but since deleteTodo
is wrapped in useCallback
, its reference doesn’t change. So, the memoized TodoItem
s don’t re-render again. If useCallback
wasn’t used, React would think the onDelete
function changed, and all todos would re-render — even though nothing about them changed. So useCallback
helps avoid unnecessary re-renders by keeping the function "the same" across renders.
The Art of Knowing When to Optimize
Here's the thing about performance optimization: it's not about using these techniques everywhere. It's about understanding when they're needed. A simple component with a few props probably doesn't need memoization. A basic form doesn't need debouncing. A static header doesn't need throttling.
The real skill is recognizing the warning signs:
Your app stutters during user interactions
API calls are firing more frequently than necessary
Components are re-rendering when their props haven't changed
Expensive calculations are running on every render
When you spot these patterns, you'll know exactly which tool to reach for. Throttling for high-frequency events, debouncing for user input, and memoization for expensive operations or unnecessary re-renders.
Conclusion
Throttling, debouncing, and memoization aren't just performance tricks—they're fundamental patterns that separate good React code from great React code. They transform janky, unresponsive apps into smooth, delightful user experiences. Once you understand these concepts, you'll start seeing optimization opportunities everywhere.
Your users might not notice perfect performance, but they'll definitely notice when it's missing. Master these three techniques, and you'll be well on your way to building React applications that feel fast, responsive, and professional.
👋 Connect with me on: LinkedIn
✨ Check my Twitter (X) space: Twitter (X)
🧑💻 Here's my code: GitHub
Subscribe to my newsletter
Read articles from Krishnanand Yadav directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Krishnanand Yadav
Krishnanand Yadav
I am pursuing M.Sc in Computer Science from Pondicherry University. I am a Full Stack Developer (Next.js and MERN Stack). I am also competent in building games using the Unity game engine. I have dabbled in Machine Learning too, using the TensorFlow and Keras libraries in Python. I am an ardent follower of technological advancements that can bring about positive change in the society. From technologies like machine learning and cybersecurity to innovative platforms for social impact, I am always eager to explore and understand how these tools can shape our future for the better.