JavaScript and TypeScript course Β· Module 8: Events and the DOM
Performance and Event Optimization
How to ensure high performance with intensive event usage.
"A park with a thousand sensors works only as long as none of them blocks the main communication line," says Ray Arnold. In the browser, that line is the main thread. The same thread runs your JavaScript and handles scrolling, so a heavy listener can make a page on a phone scroll in jerks. In this lesson we gather the tools that take the load off that thread.
Performance Optimizations
The first tool is a passive listener. The { passive: true } option is a promise made to the browser: this listener will never call preventDefault().
// Passive event listeners for better scroll performance
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('touchstart', handleTouch, { passive: true });For events that can stop scrolling, namely touchstart, touchmove, and wheel, the browser would normally have to wait until the listener finishes its work, because it might block scrolling. After such a promise, scrolling starts right away, without waiting for the listener. If a passive listener calls preventDefault() anyway, the browser ignores that call. For scroll itself the flag doesn't speed anything up, because that event can't be canceled anyway, but it does no harm and states the intent clearly.
The options object knows more flags. Each of them is false by default, and they can be combined in a single object:
// Event listener only once
button.addEventListener('click', handleClick, { once: true });
// Capture phase
button.addEventListener('click', handleClick, { capture: true });
// Combination of options
element.addEventListener('scroll', handleScroll, {
passive: true,
capture: false,
once: false
});The once option removes the listener after the first call, so you don't have to remember to clean up. The capture option moves the listener to the capture phase, which you know from the lesson on propagation. By itself it doesn't speed up the code, but it belongs to the same options object. Browsers treat touchstart, touchmove, and wheel on window, document, document.documentElement, and body as passive even without this option. That's why I prefer to always write passive: true explicitly.
Intersection Observer Instead of scroll
The classic way to detect whether an element has appeared on the screen is a scroll listener that measures the element's position on every movement. IntersectionObserver reverses this setup: the browser itself notifies you when the element's visibility changes.
// Intersection Observer instead of scroll events
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Element entered the viewport
loadLazyContent(entry.target);
}
});
}, {
rootMargin: '100px' // Trigger 100px before entering the viewport
});
// Observe elements
document.querySelectorAll('.lazy-load').forEach(el => {
observer.observe(el);
});The callback receives an array of entries, one entry for each element whose visibility has changed. The isIntersecting property tells you whether the element is now in the visible area, that is, in the viewport. The rootMargin: '100px' option extends that area by 100 pixels, so content starts loading before the guest scrolls to it. One observer can watch any number of elements, and your callback doesn't run between changes. Lazy loading of images is built on this: loadLazyContent() can copy the address from the data-src attribute to src and then call observer.unobserve(), because a loaded image needs no further observation.
ResizeObserver Instead of resize
The resize event on window only tells you about a change in the window's size. Meanwhile, a panel can get narrower because a sidebar has expanded next to it, even though the window stayed the same.
// ResizeObserver instead of resize events
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
updateElementLayout(entry.target, width, height);
});
});
resizeObserver.observe(document.getElementById('responsive-element'));ResizeObserver reports a change in the size of a specific element, whatever its cause. From contentRect we read the width and height of the content area, and we adjust the layout only when it has really changed. We'll cover both observers in more detail in the lesson on the advanced DOM API.
The Order of Decisions
My advice when a page slows down because of events: first ask whether the scroll listener is needed at all, because an observer can often replace it. If it is needed, limit it with throttling. Add passive: true to touch and mouse wheel listeners. Instead of hundreds of listeners on cards, use delegation. Every step reduces the work of the main thread.
In the sandbox below you'll see passive listeners and Intersection Observer in action. In the next lesson you'll see how events organize the architecture of a whole application thanks to the Event Bus pattern.
Remember: the fastest sensor is one that doesn't block the communication line and speaks up only when something is really happening.
Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Performance and Optimization</title>
<style>
body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
.demo { background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; margin: 20px 0; }
.scroll-box { height: 200px; overflow-y: auto; background: rgba(255,255,255,0.05); padding: 15px; border-radius: 6px; }
.stat { display: inline-block; margin: 10px; padding: 10px 20px; background: rgba(52, 152, 219, 0.2); border-radius: 6px; }
.dino-item { padding: 20px; margin: 10px 0; background: rgba(255,255,255,0.05); border-radius: 6px; }
</style>
</head>
<body>
<h1>Performance & Optimization</h1>
<div class="demo">
<h3>Passive Event Listeners</h3>
<div class="scroll-box" id="passive-scroll">
<div style="height: 1000px;">
<p>Scroll me! (Passive listener = Better performance)</p>
<p style="margin-top: 200px;">Continue scrolling...</p>
<p style="margin-top: 200px;">Almost there...</p>
<p style="margin-top: 200px;">Good job!</p>
</div>
</div>
<div class="stat" id="scroll-count">Scrolls: 0</div>
</div>
<div class="demo">
<h3>Intersection Observer</h3>
<div class="scroll-box" id="observer-container">
<div class="dino-item" data-dino="T-Rex">T-Rex (not visible)</div>
<div class="dino-item" data-dino="Velociraptor">Velociraptor (not visible)</div>
<div class="dino-item" data-dino="Triceratops">Triceratops (not visible)</div>
<div class="dino-item" data-dino="Brachiosaurus">Brachiosaurus (not visible)</div>
</div>
<div class="stat" id="observer-count">Loaded: 0</div>
</div>
<script>
console.log('Performance optimization demo');
// PASSIVE EVENT LISTENERS
let scrollCount = 0;
const passiveScroll = document.getElementById('passive-scroll');
passiveScroll.addEventListener('scroll', () => {
scrollCount++;
document.getElementById('scroll-count').textContent = `Scrolls: ${scrollCount}`;
}, { passive: true }); // Passive for better performance
// INTERSECTION OBSERVER (instead of scroll events)
let loadedCount = 0;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const dinoName = entry.target.dataset.dino;
entry.target.textContent = `${entry.target.textContent.split('(')[0]}(LOADED!)`;
entry.target.style.background = 'rgba(46, 204, 113, 0.2)';
loadedCount++;
document.getElementById('observer-count').textContent = `Loaded: ${loadedCount}`;
console.log(`${dinoName} entered viewport`);
observer.unobserve(entry.target); // Stop observing after loading
}
});
}, {
threshold: 0.5 // Trigger when 50% of the element is visible
});
// Observe all dinosaur elements
document.querySelectorAll('.dino-item').forEach(item => {
observer.observe(item);
});
// ONCE OPTION - the event listener runs only once
document.body.addEventListener('click', () => {
console.log('This fires only ONCE');
alert('This alert will appear only once!');
}, { once: true });
// RESIZE OBSERVER (instead of resize events)
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`Container resized: ${Math.round(width)}x${Math.round(height)}`);
});
});
resizeObserver.observe(document.getElementById('observer-container'));
console.log('All performance optimizations active');
console.log('Using: Passive listeners, Intersection Observer, Resize Observer');
</script>
</body>
</html>Docs entries used in this lesson
Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the { passive: true } option mean in addEventListener?
2. What is IntersectionObserver used for?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
The index.js file has two functions. setupLazyLoading(images) should observe the images and copy the address from data-src to src only when an image comes into view. setupScrollProgress(target, onProgress) adds a scroll listener that passes scrollTop to onProgress. Fill in the blanks: ___BLANK1___ is the constructor of the visibility observer, ___BLANK2___ is the observer method that stops observing a loaded image, and ___BLANK3___ is the addEventListener option with which the listener promises not to call preventDefault().
- Click in order
Arrange the code fragments to build the options object { passive: true }:
- Click in order
Arrange the syntax of a passive event listener: