TL;DR
Optimize your JavaScript with debouncing, throttling, memoization, and memory management
Key concepts
- JavaScript performance
- JS optimization
- web performance tips
- JavaScript speed
Performance Optimization
Fast applications keep users engaged. Slow ones drive them away. Performance optimization is not about premature micro-tuning; it is about understanding common bottlenecks and applying proven patterns to eliminate them. In this lesson you will learn debouncing and throttling for controlling execution frequency, memoization for caching expensive computations, lazy loading for deferring unnecessary work, requestAnimationFrame for smooth animations, Web Workers for offloading heavy tasks, and practical memory management to prevent leaks.
What You'll Learn
- How to debounce and throttle function calls
- Memoization patterns for caching expensive results
- Lazy loading to defer work until it is actually needed
- Using
requestAnimationFramefor smooth visual updates - What Web Workers are and when to use them
- Memory management basics and how to avoid common leaks
- Benchmarking with
performance.now()
Measuring Performance
Before optimizing anything, you need to measure it. performance.now() gives you high-resolution timestamps for accurate benchmarking:
// Benchmarking utility
function benchmark(label, fn) {
const start = performance.now();
const result = fn();
const end = performance.now();
console.log(label + ": " + (end - start).toFixed(3) + "ms");
return result;
}
// Compare two approaches to summing numbers
benchmark("for loop", function() {
let sum = 0;
for (let i = 0; i < 100000; i++) {
sum += i;
}
return sum;
});
benchmark("reduce", function() {
const arr = Array.from({ length: 100000 }, function(_, i) { return i; });
return arr.reduce(function(sum, n) { return sum + n; }, 0);
});
// Multiple runs for more accurate results
function benchmarkAvg(label, fn, runs) {
runs = runs || 10;
const times = [];
for (let i = 0; i < runs; i++) {
const start = performance.now();
fn();
times.push(performance.now() - start);
}
const avg = times.reduce(function(a, b) { return a + b; }, 0) / times.length;
const min = Math.min.apply(null, times);
const max = Math.max.apply(null, times);
console.log(label + ": avg=" + avg.toFixed(3) + "ms, min=" + min.toFixed(3) + "ms, max=" + max.toFixed(3) + "ms");
}
benchmarkAvg("String concatenation", function() {
let str = "";
for (let i = 0; i < 10000; i++) {
str += "x";
}
}, 5);
benchmarkAvg("Array join", function() {
const parts = [];
for (let i = 0; i < 10000; i++) {
parts.push("x");
}
parts.join("");
}, 5);
Debouncing
Debouncing ensures a function only runs after a caller stops invoking it for a set period. This is essential for events that fire rapidly, like typing in a search box. Without debouncing, you might send a network request for every single keystroke:
function debounce(fn, delay) {
let timer = null;
return function() {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function() {
fn.apply(context, args);
}, delay);
};
}
// Simulate rapid keystrokes
let apiCallCount = 0;
function searchAPI(query) {
apiCallCount++;
console.log("API call #" + apiCallCount + ': searching for "' + query + '"');
}
const debouncedSearch = debounce(searchAPI, 300);
// Simulate a user typing "javascript" one character at a time
const chars = "javascript".split("");
let totalDelay = 0;
chars.forEach(function(char, i) {
const typed = chars.slice(0, i + 1).join("");
totalDelay += 50; // 50ms between keystrokes
setTimeout(function() {
console.log("Keystroke: " + typed);
debouncedSearch(typed);
}, totalDelay);
});
// After all typing finishes, check results
setTimeout(function() {
console.log("\nTotal keystrokes: " + chars.length);
console.log("Total API calls: " + apiCallCount);
console.log("Saved " + (chars.length - apiCallCount) + " unnecessary requests!");
}, totalDelay + 500);
Predict
Trace this by hand before running it. Five save calls fire back-to-back in the same synchronous burst, then the program pauses. How many times does the handler actually run, and with what value?
function debounce(fn, delay) {
let timer = null;
return function () {
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(null, args);
}, delay);
};
}
let saveCount = 0;
const save = debounce(function (value) {
saveCount++;
console.log("Saved: " + value);
}, 200);
save("h");
save("he");
save("hel");
save("hell");
save("hello");
console.log("Burst done. saveCount so far: " + saveCount);Throttling
While debouncing waits until activity stops, throttling limits a function to run at most once per time interval. This is ideal for scroll and resize events where you need periodic updates but not on every pixel:
function throttle(fn, limit) {
let waiting = false;
let lastArgs = null;
return function() {
const context = this;
lastArgs = arguments;
if (!waiting) {
fn.apply(context, lastArgs);
waiting = true;
setTimeout(function() {
waiting = false;
if (lastArgs) {
fn.apply(context, lastArgs);
lastArgs = null;
}
}, limit);
}
};
}
// Compare unthrottled vs throttled scroll handling
let rawCount = 0;
let throttledCount = 0;
function onScroll(position) {
rawCount++;
}
const throttledOnScroll = throttle(function(position) {
throttledCount++;
console.log("Throttled handler: scroll position = " + position);
}, 200);
// Simulate 50 rapid scroll events over 500ms
for (let i = 0; i < 50; i++) {
const scrollPos = i * 20;
setTimeout(function() {
onScroll(scrollPos);
throttledOnScroll(scrollPos);
}, i * 10);
}
setTimeout(function() {
console.log("\nRaw events fired: " + rawCount);
console.log("Throttled calls: " + throttledCount);
console.log("Reduced by " + Math.round((1 - throttledCount / rawCount) * 100) + "%");
}, 1000);
Memoization
Memoization caches the result of a function call so that repeated calls with the same arguments return instantly without recomputing. The win comes from one place and one place only: how many times the same input arrives. If a workload asks for 200 results but only 10 of them are distinct, a cache turns 200 expensive calls into 10 — so you can predict the speedup before you run it.
function memoize(fn) {
const cache = new Map();
return function(n) {
if (cache.has(n)) {
return cache.get(n);
}
const result = fn(n);
cache.set(n, result);
return result;
};
}
// An expensive pure function, with a counter so you can see how often it runs
let calls = 0;
function slowHash(n) {
calls++;
let h = n;
for (let i = 0; i < 200000; i++) {
h = (h * 31 + i) % 1000003;
}
return h;
}
// 200 requests, but only 10 distinct inputs — the same values keep coming back
const requests = Array.from({ length: 200 }, function(_, i) { return i % 10; });
calls = 0;
const start1 = performance.now();
let sumSlow = 0;
for (const n of requests) {
sumSlow += slowHash(n);
}
const time1 = performance.now() - start1;
console.log("Without memoization: " + time1.toFixed(1) + "ms, slowHash ran " + calls + " times");
const fastHash = memoize(slowHash);
calls = 0;
const start2 = performance.now();
let sumFast = 0;
for (const n of requests) {
sumFast += fastHash(n);
}
const time2 = performance.now() - start2;
console.log("With memoization: " + time2.toFixed(1) + "ms, slowHash ran " + calls + " times");
console.log("Same answers: " + (sumSlow === sumFast));
console.log("200 calls over 10 distinct inputs, so expect about " +
(requests.length / 10) + "x — measured " + (time1 / time2).toFixed(1) + "x");
The call counter is the honest measure here: slowHash runs 200 times without the cache and 10 times with it, so the expected speedup is 20x — and the measured number lands near it (17x to 21x across runs on Node 22; the gap is the cache's own bookkeeping plus timer noise). Note what memoization did not do: slowHash is exactly as slow as it ever was. A cache never makes a function faster, it only makes you call it less often. That is also why a cache is worthless for a function whose arguments are almost always new — 200 distinct inputs would mean 200 misses and no speedup at all. Whether those misses also make the program measurably slower depends on what you wrapped: against slowHash the bookkeeping is lost in the noise (under 1% on Node 22, since a 200,000-iteration body dwarfs a cache miss), but wrap something cheap like n => n * 2 and the same all-miss workload runs several times slower than calling it directly. Cache the expensive and repeated; never the cheap.
Beware benchmarks that claim thousandfold memoization wins, usually with a recursive Fibonacci. Those are real speedups but they are not the cache's doing: memoizing fib turns exponential recursion into linear, roughly 30 million calls at fib(35) down to about 36. That is a change in algorithmic complexity, which is why the "speedup" keeps growing as n grows — and why an ordinary loop, with no cache at all, beats the recursive version just as thoroughly.
Recall
Without scrolling up: debouncing here stops you from FIRING a search request on every keystroke — it waits until typing pauses. But in the previous lesson (Web APIs and Fetch) you met a tool for a different problem: a request has ALREADY been sent, and now a newer one should replace it. What was that tool, and how is its job different from debouncing?
Lazy Loading and Deferred Computation
Lazy loading means you only compute or load something when it is actually needed, not upfront. This reduces initial load time and memory usage:
// Lazy property computation
function createLazyUser(rawData) {
let processedProfile = null;
return {
get name() { return rawData.name; },
get profile() {
if (processedProfile === null) {
console.log(" Computing profile (expensive operation)...");
// Simulate expensive computation
processedProfile = {
displayName: rawData.name.toUpperCase(),
initials: rawData.name.split(" ").map(function(w) { return w[0]; }).join(""),
joinedYear: new Date(rawData.joined).getFullYear(),
activityScore: rawData.posts * 2 + rawData.comments
};
}
return processedProfile;
}
};
}
const users = [
{ name: "Alice Johnson", joined: "2022-03-15", posts: 45, comments: 120 },
{ name: "Bob Smith", joined: "2023-01-20", posts: 12, comments: 85 },
{ name: "Charlie Lee", joined: "2021-08-10", posts: 230, comments: 450 }
];
const lazyUsers = users.map(createLazyUser);
// Names are cheap to access
console.log("User names (no heavy computation):");
lazyUsers.forEach(function(u) { console.log(" " + u.name); });
// Profile is only computed when accessed
console.log("\nAccessing first user's profile:");
console.log(" ", lazyUsers[0].profile);
console.log("\nAccessing first user's profile again (cached):");
console.log(" ", lazyUsers[0].profile);
console.log("\nSecond user profile never computed - zero cost!");
requestAnimationFrame
When updating the DOM for animations, requestAnimationFrame synchronizes your updates with the browser's repaint cycle (typically 60fps — measured at 60–61 callbacks per second in Chrome on a 60.00 Hz display). The point is not raw speed but alignment. A timer fires whenever its delay happens to elapse, with no relationship to the display; rAF is scheduled to run immediately before the next repaint, so your update is phase-aligned with painting in a way a free-running timer is not. setTimeout(fn, 0) fires around 228 times a second on that same display, against 60 painted frames — roughly three of every four updates you compute are thrown away before anyone sees them. rAF also pauses on its own in a background tab, where a timer keeps burning CPU on frames nobody is looking at:
// Simulating requestAnimationFrame behavior
// In a real browser, rAF syncs with the display refresh rate
function simulateAnimation() {
const totalFrames = 20;
let frame = 0;
const positions = [];
const startTime = performance.now();
function animate() {
frame++;
const progress = frame / totalFrames;
// Easing function: smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
const x = Math.round(eased * 300);
positions.push(x);
// Build a visual bar
const bar = "|" + "=".repeat(Math.floor(x / 10)) + ">" + " ".repeat(30 - Math.floor(x / 10)) + "|";
console.log("Frame " + String(frame).padStart(2) + ": " + bar + " x=" + x);
if (frame < totalFrames) {
// In real code: requestAnimationFrame(animate)
setTimeout(animate, 16); // ~60fps
} else {
const elapsed = performance.now() - startTime;
console.log("\nAnimation complete in " + elapsed.toFixed(0) + "ms");
console.log("Frames rendered: " + frame);
console.log("Average FPS: " + (frame / (elapsed / 1000)).toFixed(1));
}
}
animate();
}
console.log("Smooth animation with easing:");
simulateAnimation();
Web Workers Overview
JavaScript is single-threaded, so a heavy computation blocks everything else: UI updates, event handlers, animations. Web Workers let you run code in a separate background thread. While we cannot create actual Worker files in this playground, understanding the concept and API is essential:
// Web Workers concept demonstration
// In real code, you create a Worker from a separate file:
// const worker = new Worker("heavy-task.js");
// worker.postMessage({ data: largeArray });
// worker.onmessage = function(e) { console.log(e.data); };
// Simulating the main thread vs worker thread pattern
function simulateMainThread(task) {
console.log("[Main] Starting heavy task on main thread...");
const start = performance.now();
const result = task();
const time = performance.now() - start;
console.log("[Main] Done in " + time.toFixed(2) + "ms - UI was BLOCKED!");
return result;
}
function simulateWorkerThread(task) {
return new Promise(function(resolve) {
console.log("[Main] Sending task to worker thread...");
console.log("[Main] UI remains responsive!");
const start = performance.now();
// Simulate worker processing in background
setTimeout(function() {
const result = task();
const time = performance.now() - start;
console.log("[Worker] Completed in " + time.toFixed(2) + "ms");
console.log("[Main] Received result from worker");
resolve(result);
}, 100);
});
}
// Heavy computation: finding primes
function findPrimes(limit) {
const primes = [];
for (let n = 2; n <= limit; n++) {
let isPrime = true;
for (let d = 2; d <= Math.sqrt(n); d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) primes.push(n);
}
return primes;
}
// Without worker: blocks the main thread
const primes1 = simulateMainThread(function() { return findPrimes(10000); });
console.log("Found " + primes1.length + " primes\n");
// With worker: main thread stays free
simulateWorkerThread(function() { return findPrimes(10000); }).then(function(primes2) {
console.log("Found " + primes2.length + " primes");
console.log("\nWhen to use Web Workers:");
console.log(" - Image/video processing");
console.log(" - Large data parsing (CSV, JSON)");
console.log(" - Complex calculations (crypto, physics)");
console.log(" - Sorting/filtering very large datasets");
});
Transfer
You just saw why a heavy computation on the main thread blocks the UI, and how a Web Worker moves it to a background thread. New requirement: a photo-editing app applies a slow blur filter to a large image, and right now clicking the filter button freezes every other control for two seconds. Of these, which change actually keeps the UI responsive during the blur — and why?
Memory Management and Avoiding Leaks
JavaScript uses garbage collection to automatically free memory, but certain patterns prevent objects from being collected, causing memory leaks that degrade performance over time:
// Common memory leak patterns and fixes
// LEAK 1: Forgotten timers
console.log("--- Leak: Forgotten Timers ---");
function leakyTimer() {
const hugeData = new Array(10000).fill("data");
// This interval keeps hugeData alive forever!
const id = setInterval(function() {
// uses hugeData
}, 1000);
// FIX: always clear intervals when done
return function cleanup() {
clearInterval(id);
console.log(" Timer cleared, memory can be freed");
};
}
const cleanup1 = leakyTimer();
cleanup1();
// LEAK 2: Detached DOM references (conceptual)
console.log("\n--- Leak: Event Listener Accumulation ---");
function demonstrateListenerLeak() {
const listeners = [];
// BAD: adding listeners without removing them
function addLeakyListener(name) {
const handler = function() { console.log(name + " clicked"); };
listeners.push(handler);
}
// GOOD: track listeners and provide cleanup
function addCleanListener(name) {
const handler = function() { console.log(name + " clicked"); };
listeners.push(handler);
return function remove() {
const index = listeners.indexOf(handler);
if (index > -1) {
listeners.splice(index, 1);
console.log(" Removed listener: " + name);
}
};
}
addLeakyListener("button1");
addLeakyListener("button2");
console.log(" Listeners without cleanup: " + listeners.length);
const remove3 = addCleanListener("button3");
const remove4 = addCleanListener("button4");
console.log(" Listeners after adding more: " + listeners.length);
remove3();
remove4();
console.log(" Listeners after cleanup: " + listeners.length);
}
demonstrateListenerLeak();
// LEAK 3: Closures holding large references
console.log("\n--- Leak: Closures Retaining Large Data ---");
function processData() {
const rawData = new Array(100000).fill("raw");
// BAD: the returned function keeps all of rawData alive
// return function() { return rawData.length; };
// GOOD: extract only what you need
const length = rawData.length;
const summary = "Processed " + length + " items";
// rawData can now be garbage collected
return function() { return summary; };
}
const getSummary = processData();
console.log(" " + getSummary());
// WeakMap for cache that doesn't prevent garbage collection
console.log("\n--- WeakMap for GC-friendly caching ---");
const cache = new WeakMap();
function expensiveCompute(obj) {
if (cache.has(obj)) {
console.log(" Cache hit!");
return cache.get(obj);
}
const result = { computed: obj.value * 2, timestamp: Date.now() };
cache.set(obj, result);
console.log(" Computed and cached");
return result;
}
let myObj = { value: 42 };
expensiveCompute(myObj);
expensiveCompute(myObj);
// When myObj is set to null, both the key and value are eligible for GC
myObj = null;
console.log(" Object released - WeakMap entry will be garbage collected");
Try It Yourself
Reading about these patterns is not the same as building them. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — a memoize, a "latest call wins" reducer, and a batch splitter — and the checks below them fail until each one behaves correctly. Run it as-is and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.
Notice how the checks avoid clocks entirely. The whole point of these patterns is timing behaviour, but a timing benchmark is not reproducible — so instead the checks observe the SHAPE of the behaviour. Memoization is verified with a call counter (a correct cache runs the wrapped function once per distinct argument, not once per call); the debounce rule is modelled as a synchronous burst of data whose LAST value wins; and worker-style batching is checked by the chunks it produces. The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const assert = require('assert');
// A call counter proves caching WITHOUT a clock: if the cache works, the wrapped
// function body runs once per distinct argument, no matter how often you call it.
let slowSquareCalls = 0;
function slowSquare(n) {
slowSquareCalls++;
return n * n;
}
// A synchronous burst of save events, modelled as plain data instead of real
// timers. Each object is one call in the burst, in the order it happened.
const burst = [
{ value: "h" },
{ value: "he" },
{ value: "hel" },
{ value: "hell" },
{ value: "hello" },
];
// TODO 1: return a memoized version of fn that caches by its single argument.
// Calling it twice with the same n must run fn only ONCE.
// memoize(slowSquare) -> a function; calling it twice with 4 -> slowSquareCalls === 1
function memoize(fn) {
// your code here
}
// TODO 2: collapse a synchronous burst of calls into its LAST value — the
// debounce rule "latest call wins", modelled over data with no timers.
// latestWins([{ value: "h" }, { value: "hello" }]) -> "hello"
function latestWins(events) {
// your code here
}
// TODO 3: split items into batches of at most size, in order, for chunked
// processing (the shape a Web Worker consumes one batch at a time).
// chunkArray([1, 2, 3, 4, 5], 2) -> [[1, 2], [3, 4], [5]]
function chunkArray(items, size) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const fastSquare = memoize(slowSquare);
assert.strictEqual(
typeof fastSquare,
"function",
"TODO 1: memoize must return a function that wraps fn",
);
assert.strictEqual(
fastSquare(4),
16,
"TODO 1: memoized fn must still return the real result (4 -> 16)",
);
fastSquare(4);
fastSquare(4);
assert.strictEqual(
slowSquareCalls,
1,
"TODO 1: memoize must cache — slowSquare should run once for repeated arg 4, not on every call",
);
assert.strictEqual(
fastSquare(5),
25,
"TODO 1: a new argument (5) must compute a fresh result",
);
assert.strictEqual(
slowSquareCalls,
2,
"TODO 1: a new argument must run fn exactly once more (now twice total)",
);
assert.strictEqual(
latestWins(burst),
"hello",
"TODO 2: latestWins must return the last value of the burst ('hello'), the debounce winner",
);
assert.deepStrictEqual(
chunkArray([1, 2, 3, 4, 5], 2),
[[1, 2], [3, 4], [5]],
"TODO 3: chunkArray must split items into ordered batches of at most size",
);
console.log("All checks passed.");
console.log("slowSquare actually ran " + slowSquareCalls + " times (once per distinct argument)");
console.log("Latest wins:", latestWins(burst));
console.log("Batches:", chunkArray([1, 2, 3, 4, 5], 2));Expected output: All checks passed.
slowSquare actually ran 2 times (once per distinct argument)
Latest wins: hello
Batches: [ [ 1, 2 ], [ 3, 4 ], [ 5 ] ]
Once it passes, try two variations and predict each before running:
- Number vs. string key collision. Below the checks, create a FRESH memoized function —
const fresh = memoize(slowSquare);— and setslowSquareCalls = 0(the build's own checks already warmedfastSquare's cache, so you need an empty one). Now callfresh(4), thenfresh(4.0), thenfresh("4"), loggingslowSquareCallsafter. Predict how many timesslowSquareactually runs — the cache is a plain object, and object keys are coerced to strings, so4,4.0, and"4"all collapse to the key"4"and the wrapped function runs exactly once for all three. (Output-changing: you are predicting the final call count.) - Batch-size edge cases. Call
chunkArray([1, 2, 3, 4, 5], 1)and thenchunkArray([1, 2, 3, 4, 5], 10), and predict each result before running — size 1 gives five single-item batches, while a size larger than the list gives one batch holding the whole array, becauseslicepast the end just stops at the last element. Which of these two shapes would you hand a worker that can only process one item per message? (Output-changing: you are predicting both returned batch structures.)
Key Takeaways
- Always measure before optimizing: use
performance.now()and run multiple iterations for accurate benchmarks - Debouncing delays execution until input stops, ideal for search fields and form validation
- Throttling limits execution to once per interval, ideal for scroll and resize handlers
- Memoization caches function results to avoid redundant computation on repeated calls
- Lazy loading defers work until it is needed, reducing initial load time
- requestAnimationFrame runs your update exactly once per displayed frame (60fps on a 60 Hz display), phase-aligned with the repaint, and pauses in background tabs
- Web Workers run heavy computation in a background thread so the UI stays responsive
- Prevent memory leaks by clearing timers, removing event listeners, and avoiding closures that capture large data unnecessarily
- Use
WeakMapandWeakSetfor caches where entries should be garbage-collected when no longer referenced
Pro Tip: The biggest performance wins almost never come from micro-optimizations. They come from doing less work: fewer network requests, fewer recomputations, less rendering. Before reaching for a clever trick, ask yourself: "Can I avoid this work entirely?" Note that "less work" is not the same as "fewer calls" — as
10-dom-and-eventsshowed, shuffling the same DOM writes into one batch changes nothing measurable, while genuinely not doing them does. Debouncing a search input from 10 requests down to 1 removes 90% of the work outright — the kind of win you rarely get from tuning the code inside a single request.
Next Steps
Now that you know how to make JavaScript fast, it is time to put everything together. The capstone project will have you build a complete application combining the core concepts you have learned so far.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.