Back to Blogs

10 Node.js Best Practices Every Developer Should Know

Node.js has become one of the most popular runtime environments for building server-side applications. Its event-driven, non-blocking I/O model makes it efficient and perfect for data-intensive real-time applications. However, with great power comes great responsibility, and writing Node.js applications that are maintainable, secure, and efficient requires following certain best practices.

In this article, we'll explore 10 essential Node.js best practices that every developer should know and implement in their projects. These practices will help you write cleaner, more efficient, and more maintainable code.

1. Use Async/Await Instead of Callbacks

Callbacks were the original way to handle asynchronous operations in Node.js, but they often lead to callback hell or "pyramid of doom" – deeply nested callbacks that make code hard to read and maintain.

Modern Node.js applications should use async/await syntax, which makes asynchronous code look and behave more like synchronous code:

Using async/await
// Instead of this:
function getUser(id, callback) {
  db.query('SELECT * FROM users WHERE id = ?', [id], (err, users) => {
    if (err) return callback(err);
    
    if (users.length === 0) return callback(new Error('User not found'));
    
    callback(null, users[0]);
  });
}

// Use this:
async function getUser(id) {
  try {
    const users = await db.query('SELECT * FROM users WHERE id = ?', [id]);
    
    if (users.length === 0) {
      throw new Error('User not found');
    }
    
    return users[0];
  } catch (error) {
    throw error;
  }
}

Async/await is built on top of Promises and provides a cleaner, more intuitive way to write asynchronous code. It's easier to read, debug, and maintain.

2. Implement Error Handling Properly

Proper error handling is crucial in Node.js applications. Unhandled errors can crash your application, lead to memory leaks, or leave connections hanging.

Here are some error handling best practices:

  • Use try/catch blocks with async/await
  • Create custom error classes
  • Implement global error handlers for Express apps
  • Handle unhandled promise rejections and uncaught exceptions
Global error handling in Express
// Global error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  
  // Customize the error response based on the error
  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  
  // Default error response
  res.status(500).json({ error: 'Something went wrong on the server' });
});

// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Promise Rejection:', reason);
  // Application specific logic
});

3. Use Environment Variables for Configuration

Never hardcode sensitive information like API keys, database credentials, or other configuration details directly in your code. Instead, use environment variables.

The dotenv package makes it easy to load environment variables from a .env file:

Using dotenv for configuration
// Install: npm install dotenv

// In your main application file
require('dotenv').config();

// Use environment variables
const dbConnection = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME
};

// Example .env file (not committed to version control)
// DB_HOST=localhost
// DB_USER=myuser
// DB_PASSWORD=mypassword
// DB_NAME=mydatabase

Remember to add your .env file to .gitignore to prevent accidentally committing sensitive information to version control.

4. Follow a Consistent Project Structure

Organizing your Node.js project in a consistent and logical way makes it easier to navigate, understand, and maintain. While there's no one-size-fits-all solution, here's a common structure that works well for many applications:

Project structure example
project-root/
├── node_modules/
├── src/
│   ├── config/              # Configuration files
│   ├── controllers/         # Request handlers
│   ├── models/              # Data models
│   ├── routes/              # Route definitions
│   ├── services/            # Business logic
│   ├── utils/               # Utility functions
│   ├── middlewares/         # Express middlewares
│   └── app.js               # Express app setup
├── tests/                   # Test files
├── .env                     # Environment variables
├── .gitignore
├── package.json
└── README.md

This structure separates concerns and makes it clear where different types of code should live. Adjust it based on your project's specific needs.

5. Implement Request Validation

Always validate incoming data in your API endpoints. This helps prevent bugs, security vulnerabilities, and unexpected behavior.

Libraries like Joi or express-validator make validation easy:

Request validation with Joi
const Joi = require('joi');
const express = require('express');
const router = express.Router();

router.post('/users', async (req, res, next) => {
  try {
    // Define validation schema
    const schema = Joi.object({
      username: Joi.string().alphanum().min(3).max(30).required(),
      email: Joi.string().email().required(),
      password: Joi.string().pattern(new RegExp('^[a-zA-Z0-9]{8,30}$')).required()
    });
    
    // Validate request body
    const { error, value } = schema.validate(req.body);
    
    if (error) {
      return res.status(400).json({ error: error.details[0].message });
    }
    
    // Process the validated data
    const user = await createUser(value);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

module.exports = router;
Node.js Development

6. Use a Process Manager for Production

In production, you should use a process manager to keep your Node.js application running, restart it if it crashes, and manage logs and deployments.

PM2 is a popular process manager for Node.js with features like:

  • Process monitoring
  • Automatic restarts
  • Load balancing (cluster mode)
  • Log management
  • Startup scripts
Using PM2
# Install PM2 globally
npm install pm2 -g

# Start application in cluster mode
pm2 start app.js -i max

# Other useful PM2 commands
pm2 list            # List all processes
pm2 monit           # Monitor CPU/Memory usage
pm2 logs            # Display logs
pm2 reload all      # Zero-downtime reload

7. Monitor Your Application

Monitoring is essential for understanding how your application performs in production and for quickly identifying and resolving issues.

Key metrics to monitor include:

  • Server health (CPU, memory, disk usage)
  • Application performance (response times, throughput)
  • Error rates and logs
  • Database performance

Tools like New Relic, Datadog, or the open-source combination of Prometheus and Grafana can help you monitor your Node.js applications.

8. Optimize Your Dependencies

Dependencies can significantly impact your application's security, performance, and maintainability. Follow these practices:

  • Regularly update dependencies to get security patches
  • Use tools like npm audit to check for vulnerabilities
  • Be selective about adding new dependencies
  • Consider the package size and performance impact
  • Lock dependency versions in package.json
Checking for vulnerabilities
# Check for vulnerabilities
npm audit

# Fix vulnerabilities
npm audit fix

# Update dependencies
npm update

9. Use HTTP Compression

Enabling HTTP compression can significantly reduce the size of the response body, resulting in faster data transfer to clients. In Express, you can use the compression middleware:

Using compression in Express
const express = require('express');
const compression = require('compression');
const app = express();

// Use compression
app.use(compression());

// Rest of your app setup

This middleware will compress responses that are larger than the default threshold and for clients that support compression.

10. Implement Authentication & Authorization

Proper authentication and authorization are critical for securing your Node.js applications:

  • Use HTTPS for all communications
  • Implement JWT (JSON Web Tokens) or sessions for authentication
  • Hash passwords using bcrypt or Argon2
  • Use role-based access control for authorization
  • Protect against common security vulnerabilities
Authentication middleware example
const jwt = require('jsonwebtoken');

// Authentication middleware
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Using the middleware to protect routes
app.get('/api/profile', authenticate, (req, res) => {
  // Access user info from req.user
  res.json({ user: req.user });
});

Conclusion

Following these Node.js best practices will help you build applications that are more maintainable, secure, and performant. Remember that best practices evolve over time, so stay up-to-date with the Node.js ecosystem and continually refine your development practices.

What other Node.js best practices do you follow in your projects? Share your thoughts in the comments below!

Comments

Leave a Comment

Recent Comments

Sarah Johnson
July 2, 2023

Great article! I especially like your point about using Async/Await instead of callbacks. It's made my code so much more readable. I'd also add that using TypeScript with Node.js can help catch many errors at compile time.

Michael Lee
June 28, 2023

I've been using PM2 for production deployment as you suggested and it's been a game changer for managing our Node applications. I'd also recommend looking into Docker for containerization if you're working with microservices.