Speed. Performance. Responsiveness. These ideals reign supreme in the world of web development, especially JavaScript and Nodejs. A slow or janky website is the hallmark of an amateur, while a slick, optimized experience delights users and sets professionals apart.
But creating truly high-performance web apps is littered with pitfalls. Mistakes abound that can drag down the pace of your JavaScript without you even realizing it. Tiny oversights that bloat your code and surreptitiously sap speed away bit by bit.
You vigilantly minify your code and leverage caching...yet your site still feels oddly sluggish at times. The UI stutters during scrolling or when buttons are clicked. Pages take eons to load.
What's going on?
Turns out, there are many common ways we inadvertently slow down our JavaScript. Anti-patterns that, over time, can hobble site performance.
The good news? These mistakes can be avoided.
Today we're spotlighting the top 19 performance pitfalls that can secretly slow down JavaScript and Node.js apps. We'll explore what causes them through illustrative examples and actionable solutions to optimize your code.
Identifying and eliminating these hazards is key to crafting buttery smooth web experiences that delight users. So, let's dive in!
When first learning JavaScript, it's tempting to declare all variables globally. However, this causes problems down the road. Let's look at an example:
// globals.js
var color = 'blue';
function printColor() {
console.log(color);
}
printColor(); // Prints 'blue'
This works fine, but imagine if we loaded another script:
// script2.js
var color = 'red';
printColor(); // Prints 'red'!
Because color
is global, script2.js overwritten it! To fix this, declare variables locally inside functions whenever possible:
function printColor() {
var color = 'blue'; // local variable
console.log(color);
}
printColor(); // Prints 'blue'
Now, changes in other scripts won't affect printColor
.
Declaring variables in the global scope when unnecessary is an anti-pattern. Try to limit globals to configuration constants. For other variables, declare them locally in the smallest possible scope.
When updating DOM elements, batch changes instead of manipulating one node at a time. Consider this example:
const ul = document.getElementById('list');
for (let i = 0; i < 10; i++) {
const li = document.createElement('li');
li.textContent = i;
ul.appendChild(li);
}
This appends list items one by one. It is better to build a string first then set .innerHTML
:
const ul = document.getElementById('list');
let html = '';
for (let i = 0; i < 10; i++) {
html += `<li>${i}</li>`;
}
ul.innerHTML = html;
Building a string minimizes reflows. We update the DOM once instead of 10 times.
For multiple updates, build up changes, then apply at the end. Or better yet, use DocumentFragment to batch appends.
Frequent DOM updates can crush performance. Consider a chat app that inserts messages into the page.
Bad:
// New message received
const msg = `<div>${messageText}</div>`;
chatLog.insertAdjacentHTML('beforeend', msg);
This naively inserts on each message. Better to throttle updates:
Good:
let chatLogHTML = '';
const throttleTime = 100; // ms
// New message received
chatLogHTML += `<div>${messageText}</div>`;
// Throttle DOM updates
setTimeout(() => {
chatLog.innerHTML = chatLogHTML;
chatLogHTML = '';
}, throttleTime);
Now, we update at most every 100ms, keeping DOM operations low.
For highly dynamic UIs, consider virtual DOM libraries like React. These minimize DOM manipulation using a virtual representation.
Attaching event listeners to many elements creates needless overhead. Consider a table with delete buttons on each row:
Bad:
const rows = document.querySelectorAll('table tr');
rows.forEach(row => {
const deleteBtn = row.querySelector('.delete');
deleteBtn.addEventListener('click', handleDelete);
});
This adds a listener to each delete button. Better to use event delegation:
Good:
const table = document.querySelector('table');
table.addEventListener('click', e => {
if (e.target.classList.contains('delete')) {
handleDelete(e);
}
});
Now, there's a single listener on the <table>
. Less memory overhead.
Event delegation utilizes event bubbling. One listener can handle events from multiple descendants. Use delegation whenever applicable.
When concatenating strings in a loop, performance suffers. Consider this code:
let html = '';
for (let i = 0; i < 10; i++) {
html += '<div>' + i + '</div>';
}
Creating new strings requires memory allocation. Better to use an array:
const parts = [];
for (let i = 0; i < 10; i++) {
parts.push('<div>', i, '</div>');
}
const html = parts.join('');
Building an array minimizes intermediate strings. .join()
concatenates once at the end.
For multiple string additions, use array joining. Also, consider template literals for embedded values.
Loops often cause performance problems in JavaScript. A common mistake is repeatedly accessing array length:
Bad:
const items = [/*...*/];
for (let i = 0; i < items.length; i++) {
// ...
}
Redundantly checking .length
inhibits optimizations. Better:
Good:
const items = [/*...*/];
const len = items.length;
for (let i = 0; i < len; i++) {
// ...
}
Caching length improves speed. Other optimizations include hoisting invariants out of loops, simplifying termination conditions, and avoiding expensive operations inside iterations.
JavaScript's async capabilities are a key advantage. But beware of blocking I/O! For example:
Bad:
const data = fs.readFileSync('file.json'); // blocks!
This stalls execution while reading from the disk. Instead, use callbacks or promises:
Good:
fs.readFile('file.json', (err, data) => {
// ...
});
Now, the event loop continues while the file is read. For complex flows, async/await
simplifies asynchronous logic. Avoid synchronous operations to prevent blocking.
JavaScript uses a single-threaded event loop. Blocking it stalls execution. Some common blockers:
For example:
function countPrimes(max) {
// Unoptimized loop
for (let i = 0; i <= max; i++) {
// ...check if prime...
}
}
countPrimes(1000000); // Long running!
This executes synchronously, blocking other events. To avoid:
Keep the event loop running smoothly. Profile periodically to catch blocking code.
It's vital to handle errors properly in JavaScript. But beware of performance pitfalls!
Bad:
try {
// ...
} catch (err) {
console.error(err); // just logging
}
This captures errors but takes no corrective action. Unhandled errors often lead to memory leaks or data corruption.
Better:
try {
// ...
} catch (err) {
console.error(err);
// Emit error event
emitError(err);
// Nullify variables
obj = null;
// Inform user
showErrorNotice();
}
Logging isn't enough! Clean up artifacts, notify users, and consider recovery options. Use tools like Sentry to monitor errors in production. Handle all errors explicitly.
Memory leaks happen when memory is allocated but never released. Over time, leaks accumulate and degrade performance.
Common sources in JavaScript include:
For example:
function processData() {
const data = [];
// Use closure to accumulate data
return function() {
data.push(getData());
}
}
const processor = processData();
// Long running...keeps holding reference to growing data array!
The array keeps getting larger but is never cleared. To fix:
Monitor memory usage and watch for growing trends. Proactively eliminate leaks before they pile up.
While npm offers endless options, resist the urge to over-import! Each dependency increases bundle size and attack surface.
Bad:
import _ from 'lodash';
import moment from 'moment';
import validator from 'validator';
// etc...
Importing entire libraries for minor utilities. Better to cherries pick helpers as needed:
Good:
import cloneDeep from 'lodash/cloneDeep';
import { format } from 'date-fns';
import { isEmail } from 'validator';
Only import what you need. Review dependencies regularly to prune unused ones. Keep bundles lean and minimize dependencies.
Caching allows skipping expensive computations by reusing prior results. But it's often overlooked.
Bad:
function generateReport() {
// Perform expensive processing
// to generate report data...
}
generateReport(); // Computes
generateReport(); // Computes again!
Since inputs haven't changed, the report could be cached:
Good:
let cachedReport;
function generateReport() {
if (cachedReport) {
return cachedReport;
}
cachedReport = // expensive processing...
return cachedReport;
}
Now, repeated calls are fast. Other caching strategies:
Memory caches like Redis
HTTP caching headers
LocalStorage for client caching
CDNs for asset caching
Identify cache opportunities - they often provide big speedups!
When interfacing with databases, inefficient queries can bog down performance. Some issues to avoid:
Bad:
// No indexing
db.find({name: 'John', age: 35});
// Unecessary fields
db.find({first: 'John', last:'Doe', email:'[email protected]'}, {first: 1, last: 1});
// Too many separate queries
for (let id of ids) {
const user = db.find({id});
}
This fails to utilize indexes, retrieves unused fields, and executes excessive queries.
Good:
// Use index on 'name'
db.find({name: 'John'}).hint({name: 1});
// Only get 'email' field
db.find({first: 'John'}, {email: 1});
// Get users in one query
const users = db.find({
id: {$in: ids}
});
Analyze and explain plans. Create indexes strategically. Avoid multiple piecemeal queries. Optimize datastore interactions.
Promises simplify asynchronous code. But unhandled rejections are silent failures!
Bad:
function getUser() {
return fetch('/user')
.then(r => r.json());
}
getUser();
If fetch
rejects, exception goes unnoticed. Instead:
Good:
function getUser() {
return fetch('/user')
.then(r => r.json())
.catch(err => console.error(err));
}
getUser();
Chaining .catch()
handles errors properly. Other tips:
Don't ignore promise errors!
Network requests should be asynchronous. But sometimes sync variants get used:
Bad:
const data = http.getSync('http://example.com/data'); // blocks!
This stalls the event loop during the request. Instead, use callbacks:
Good:
http.get('http://example.com/data', res => {
// ...
});
Or promises:
fetch('http://example.com/data')
.then(res => res.json())
.then(data => {
// ...
});
Async network requests allow other processing while waiting for responses. Avoid synchronous network calls.
Reading/writing files synchronously blocks. For example:
Bad:
const contents = fs.readFileSync('file.txt'); // blocks!
This stalls execution during disk I/O. Instead:
Good:
fs.readFile('file.txt', (err, contents) => {
// ...
});
// or promises
fs.promises.readFile('file.txt')
.then(contents => {
// ...
});
This allows the event loop to continue during the file read.
For multiple files, use streams:
function processFiles(files) {
for (let file of files) {
fs.createReadStream(file)
.pipe(/*...*/);
}
}
Avoid synchronous file operations. Use callbacks, promises, and streams.
It's easy to overlook performance until there are obvious issues. But optimization should be ongoing! Measure first with profiling tools:
This reveals optimization opportunities even if performance seems fine:
// profile.js
function processOrders(orders) {
orders.forEach(o => {
// ...
});
}
processOrders(allOrders);
The profiler shows processOrders
taking 200ms. We investigate and find:
We optimize iteratively. The final version takes 5ms!
Profiling guides optimization. Establish performance budgets and fail if exceeded. Measure often and optimize judiciously.
Caching improves speed by avoiding duplicate work. But it's often forgotten.
Bad:
// Compute expensive report
function generateReport() {
// ...heavy processing...
}
generateReport(); // Computes
generateReport(); // Computes again!
The same inputs always produce the same output. We should cache:
Good:
// Cache report contents
const cache = {};
function generateReport() {
if (cache.report) {
return cache.report;
}
const report = // ...compute...
cache.report = report;
return report;
}
Now, repeated calls are fast. Other caching strategies:
Identify cache opportunities - they often give big wins!
Duplicated code harms maintainability and optimizability. Consider:
function userStats(user) {
const name = user.name;
const email = user.email;
// ...logic...
}
function orderStats(order) {
const name = order.customerName;
const email = order.customerEmail;
// ...logic...
}
The extraction is duplicated. We refactor:
function getCustomerInfo(data) {
return {
name: data.name,
email: data.email
};
}
function userStats(user) {
const { name, email } = getCustomerInfo(user);
// ...logic...
}
function orderStats(order) {
const { name, email } = getCustomerInfo(order);
// ...logic...
}
Now, it's just defined once. Other fixes:
Deduplicate whenever you can. It improves code health and optimization.
Optimizing JavaScript application performance is an iterative process. By learning efficient practices and being diligent about profiling, dramatic speed improvements can be achieved.
Key areas to focus on include minimizing DOM changes, leveraging asynchronous techniques, eliminating blocking operations, reducing dependencies, utilizing caching, and removing unneeded duplication.
With attention and experience, you can discover bottlenecks and hone in on optimizations for your specific workload. The result is faster, leaner, and more responsive web apps your users will love.
So be relentless about optimizations - employ these tips and see your JavaScript fly!
If you like my article, feel free to follow me onHackerNoon.
Also published here.