10 Must-Know Node.js Best Practices for Scalable and Maintainable Code
Nodejs is one of the best runtime to make a powerful, fast and scalable applications. It is widely used in many fields and is powering a lots of application. It is very versatile and non opinionated so developers can use it as they like. But saying this the code or the architecture of the application can become very messy and hard to maintain and lots of performance issues could arise. So noting the issues on hand, I have addressed some topics which can help developers minimize messy code and performance issues and maximize the quality of the code. So lets dive in.
1. Use Asynchronous Code Wisely
Node.js is single-threaded and relies heavily on non-blocking I/O operations. Always prefer asynchronous methods over synchronous ones to avoid blocking the event loop. Synchronous code can block the event loop, causing delays in handling other requests. Asynchronous code ensures your app remains responsive.
If you don't like the callback system, you can always wrap the function in promise and then resolve it.
Example:
// Bad: Synchronous file read
const fs = require('fs');
const data = fs.readFileSync('file.txt'); // Blocks the event loop
console.log(data);
// Good: Asynchronous file read
fs.readFile('file.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
---------------------------------------------------------------------------
// Read file with promise
const readFile=()=>{
return new Promise((resolve,reject)=>{
fs.readFile('file.txt',(err,data)=>{
if(err) reject(err);
resolve(data);
})
})
}
readFile().then((data)=>{
console.log(data)
}).catch((err)=>{
console.log(err)
})
2. Use Environment Variables for Configuration
Hardcoding configuration values (like API keys or database credentials) is a bad practice. Use environment variables to manage configurations securely. This keep sensitive information out of your codebase and make it easier to manage different configurations for development, testing, and production. Also use constants whenever possible to store repetitive information or variables such as PAGINATION_DEFAULT_LIMIT, PAGINATION_DEFAULT_SORTING etc.
Example:
// Install dotenv package: npm install dotenv
require('dotenv').config();
const dbPassword = process.env.DB_PASSWORD;
console.log(`Database password: ${dbPassword}`);
3. Handle Errors Gracefully
Unhandled errors can crash your Node.js application. Always use try-catch
blocks or .catch()
for promises to handle errors properly. Proper error handling ensures your app doesn't crash unexpectedly and provides meaningful feedback to users. Also alternatively you can wrap a custom middleware to catch errors also if you don't like writing try catch everywhere in the code.
Example:
// Bad: Unhandled error
app.get('/data', async (req, res) => {
const data = await fetchData(); // If fetchData fails, the app crashes
res.send(data);
});
// Good: Handle errors
app.get('/data', async (req, res) => {
try {
const data = await fetchData();
res.send(data);
} catch (error) {
console.error(error);
res.status(500).send('Something went wrong!');
}
});
-------------------------------------------------------------------
// Middleware function
export const asyncWrapper=(req,res,next)=>{
return async (req, res, next) => {
try {
await handler(req, res, next);
} catch (error) {
console.log(error);
}
};
}
// And wraping our apis
app.get("/", asyncWrapper(async (req,res)=>{
const data = await fetchData(); // If fetchData fails, the app crashes
res.send(data);
});
4. Use a Logging Library
Instead of using console.log()
everywhere, use a logging library like Winston or Bunyan for structured and configurable logging. Logging libraries provide better control over log levels, formats, and destinations, making debugging and monitoring easier.
Example:
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
logger.info('This is an info message');
logger.error('This is an error message');
5. Use Middleware for Common Tasks
Middleware functions in frameworks like Express.js can help you handle repetitive tasks like authentication, logging, and error handling. This keeps your code DRY (Don't Repeat Yourself) and makes it easier to manage cross-cutting concerns.
Example:
// Middleware for logging requests
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
// Middleware for authentication
app.use((req, res, next) => {
if (req.headers.authorization === 'valid-token') {
next();
} else {
res.status(401).send('Unauthorized');
}
});
6. Use a Process Manager
For production applications, use a process manager like PM2 to manage your Node.js processes, handle crashes, and scale your app. PM2 ensures your app restarts automatically if it crashes and provides tools for monitoring and scaling. Pm2 can also scale your application horizontally if you want.
Example:
# Install PM2 globally
npm install -g pm2
# Start your app with PM2
pm2 start app.js
7. Optimize Database Queries
Database queries can become a bottleneck if not optimized. Use indexing, caching, and efficient query patterns to improve performance. Optimized queries reduce response times and improve the overall performance of your application. Also if you are using SQL with ORM like sequelize or typeorm, they could easily reduce the speed of query in the background so it is preferred to use raw query where ever you see the bottleneck is happening.
Example:
// Bad: Querying without indexing
db.collection('users').find({ age: { $gt: 30 } }).toArray();
// Good: Create an index for the age field
db.collection('users').createIndex({ age: 1 });
db.collection('users').find({ age: { $gt: 30 } }).toArray();
8. Use Linting and Formatting Tools
Consistent code style improves readability and maintainability. Use tools like ESLint and Prettier to enforce coding standards. Linting and formatting tools catch potential errors and ensure a consistent code style across your team.
Example:
# Install ESLint and Prettier
npm install eslint prettier --save-dev
# Create ESLint configuration
npx eslint --init
9. Modularize Your Code
Break your code into smaller, reusable modules instead of writing everything in a single file. This improves readability and maintainability. Modular code is easier to test, debug, and maintain.
Example:
// Bad: Everything in one file
app.get('/users', (req, res) => { /* ... */ });
app.post('/users', (req, res) => { /* ... */ });
// Good: Modularize routes
// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => { /* ... */ });
router.post('/', (req, res) => { /* ... */ });
module.exports = router;
// app.js
const userRoutes = require('./routes/users');
app.use('/users', userRoutes);
10. Use a Reverse Proxy
In production, use a reverse proxy like Nginx or Apache to handle tasks like SSL termination, load balancing, and serving static files. A reverse proxy improves security, performance, and scalability. And if you have many application running and they are using different ports reverse proxy helps to not expose those ports to public and helps to redirect the traffic into specific applications. Also if you are serving your static content or even reactjs build folder from the express or any kind of frameworks, It is not the best solutions. Let nginx handle serving of these static content for you for the optimal performance.
Example Nginx Configuration:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /public {
autoindex on;
root /opt/my-blog/public;
}
}
These are some of the best practices you can follow to maintain your code for optimal performance and scalability. I hope these topic helped you.
Happy coding.