9 JavaScript Techniques That Actually Make Websites Faster in 2026

The techniques that actually speed up a site are debouncing and throttling handlers, passive scroll listeners, IntersectionObserver for lazy loading, and cutting main thread work. Most gains come from doing less JavaScript, not writing cleverer code.
Part of the complete web development guide.
On this page
- 1. Debounce and throttle, because users scroll faster than your handlers can keep up
- 2. IntersectionObserver instead of scroll math
- 3. AbortController for fetch calls that outlive their usefulness
- 4. Optional chaining and nullish coalescing, used with judgment
- 5. Structured cloning instead of JSON round-trips
- 6. Top-level await in modules, without blocking the whole page
- 7. Web Workers for anything CPU-heavy
- 8. ResizeObserver and container queries for layout that adapts to its container
- 9. Cache API and service workers for repeat visits
- What actually moves the needle
- FAQ
- Do I need all nine of these on every project?
- Are these safe to use in 2026, or do I need polyfills?
- Will these make my site score better on performance audits?
Most “JavaScript tricks” listicles are just syntax trivia. This one is the opposite. These are nine things that actually change how a site performs, behaves, or holds up under real traffic, and each one comes with code you can paste into a project this afternoon.
I’ve been building sites for businesses around Commack and the rest of Long Island for years, and the same handful of techniques keep showing up in the work that ages well. Nothing here is exotic. It’s just the stuff that matters.
1. Debounce and throttle, because users scroll faster than your handlers can keep up
A scroll or resize handler that fires on every event will tank a page. Debounce waits for the user to stop. Throttle limits how often it runs. They solve different problems, so know which one you need.
function debounce(fn, wait = 200) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
function throttle(fn, limit = 100) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= limit) {
last = now;
fn(...args);
}
};
}
window.addEventListener('resize', debounce(() => {
// recalculate layout once the user stops dragging
}, 250));
window.addEventListener('scroll', throttle(() => {
// update a sticky header, at most 10x per second
}, 100), { passive: true });
The { passive: true } flag matters. It tells the browser the handler will never call preventDefault(), so scrolling doesn’t have to wait on your code.
2. IntersectionObserver instead of scroll math
Years ago, lazy loading meant comparing getBoundingClientRect() against window.scrollY on every scroll event. That forces layout recalculation constantly. IntersectionObserver does the same job off the main thread and is supported everywhere that matters now.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img);
}
}
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
That rootMargin of 200px starts loading slightly before the image enters the viewport, so it’s usually ready by the time the user sees it. Honest caveat: for plain images, native loading="lazy" is simpler and often better. Reach for IntersectionObserver when you need animation triggers, analytics on visibility, or more control than the native attribute gives you.
3. AbortController for fetch calls that outlive their usefulness
If a user types in a search box and you fire a request per keystroke, responses can arrive out of order. A stale result can overwrite a fresh one. AbortController kills the previous request when a new one starts.
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal
});
const data = await res.json();
render(data);
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
This is also the correct way to cancel work when a component unmounts. If you’re doing this by hand in a framework, check whether the framework already exposes an abort signal before you build your own.
4. Optional chaining and nullish coalescing, used with judgment
These two operators cleaned up a lot of defensive code. The important part is knowing the difference between ?? and ||.
const city = user?.address?.city ?? 'Unknown';
const count = response.total ?? 0;
// The trap:
const qty = input.value || 10; // 0 becomes 10, which is wrong
const qty2 = Number(input.value) ?? 10; // 0 stays 0
|| falls through on any falsy value, including 0 and ''. ?? only falls through on null and undefined. When a zero or empty string is a legitimate value, ?? is the one you want.
5. Structured cloning instead of JSON round-trips
For years, deep-copying an object meant JSON.parse(JSON.stringify(obj)). That breaks on dates, undefined, functions, Map, Set, and circular references, and it silently drops anything it can’t serialize. structuredClone() handles all of that natively now.
const original = {
created: new Date(),
tags: new Set(['a', 'b']),
meta: { views: 0 }
};
const copy = structuredClone(original);
copy.meta.views = 5;
console.log(original.meta.views); // still 0
It doesn’t clone functions or DOM nodes, so it’s not a universal answer, but for plain data it’s faster to write and far less likely to surprise you.
6. Top-level await in modules, without blocking the whole page
Top-level await lets a module wait on a promise before it finishes loading. It’s genuinely useful for config or feature flags, but it blocks every module that imports it, so use it deliberately.
// config.js
const res = await fetch('/api/config');
export const config = await res.json();
// app.js
import { config } from './config.js';
console.log(config.theme);
If that fetch is slow, everything downstream waits. For non-critical data, kick off the fetch and export the promise instead, then await it where you actually need the value.
7. Web Workers for anything CPU-heavy
JavaScript is single-threaded. A tight loop for image processing or parsing a large file will freeze the UI, and no amount of debouncing fixes that. Move the work off the main thread.
// worker.js
self.onmessage = (e) => {
const result = heavyCalculation(e.data);
self.postMessage(result);
};
// main.js
const worker = new Worker('worker.js', { type: 'module' });
worker.postMessage(payload);
worker.onmessage = (e) => updateUI(e.data);
Module workers are supported across current browsers, so you can use import statements inside the worker file. For simple, self-contained tasks you can even skip the separate file with a Blob URL, though a real file is easier to debug.
8. ResizeObserver and container queries for layout that adapts to its container
Media queries respond to the viewport. That’s the wrong question when a card sits in a sidebar on one page and full-width on another. Container queries solve it in CSS, and ResizeObserver handles the JavaScript side.
.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 120px 1fr;
}
}
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
entry.target.classList.toggle('compact', width < 320);
}
});
ro.observe(document.querySelector('.card'));
If you’re maintaining a component library, this is the difference between components that work everywhere and components that need a variant for every context.
9. Cache API and service workers for repeat visits
A service worker that caches your shell and static assets turns a second visit into something close to instant, even on a spotty connection. It’s not just for offline apps.
// sw.js
const CACHE = 'site-v1';
const ASSETS = ['/', '/styles.css', '/app.js', '/logo.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});
Be careful with cache invalidation. Bump the version string when you deploy, and never cache API responses that need to be fresh. A stale service worker is one of the more annoying bugs to diagnose because it survives a hard refresh in some setups.
What actually moves the needle
Of these nine, the ones I reach for most are AbortController, IntersectionObserver, and structuredClone. They prevent real bugs rather than just shortening code. The performance wins come from getting work off the main thread and letting the browser do the scheduling.
If you’re pasting these into a larger build, make sure your bundler isn’t undoing the gains. And if you’d rather hand the whole thing off, this is the kind of work that falls under custom web development I do for local businesses. The code is the easy part. Knowing which technique fits which problem is the rest of it.
FAQ
Do I need all nine of these on every project?
No. Most sites need two or three. Debouncing, IntersectionObserver, and optional chaining cover the majority of everyday cases. The rest depend on what your site actually does.
Are these safe to use in 2026, or do I need polyfills?
All of them are supported in current versions of Chrome, Firefox, Safari, and Edge. If you still support very old browsers, check each feature on caniuse before shipping, and skip the ones that don’t clear your support bar.
Will these make my site score better on performance audits?
Some will, directly. IntersectionObserver and Web Workers usually show up in measurements. Others, like structuredClone and nullish coalescing, are about correctness and maintainability, which audits don’t measure but your future self will appreciate.


