DEV Community

Cover image for 10 Node.js Best Practices for Building Scalable, Maintainable Applications
DevUnionX
DevUnionX

Posted on

10 Node.js Best Practices for Building Scalable, Maintainable Applications

Writing Node.js applications that simply "work" is no longer enough.

As your systems scale and complexity grows, so do the demands on your codebase. These best practices are derived from real-world experience and are designed to help you build secure, maintainable, and production-ready applications.


Use AsyncLocalStorage for Context Management
Avoid passing values like userId or requestId through multiple layers manually.
Node.js’s AsyncLocalStorage allows you to manage request-scoped data efficiently without polluting your function signatures.
Code:Js

const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
app.use((req, res, next) => {
  asyncLocalStorage.run(new Map(), () => {
    asyncLocalStorage.getStore().set('requestId', req.headers['x-request-id']);
    next();
  });
});
// Later
const requestId = asyncLocalStorage.getStore().get('requestId');
Enter fullscreen mode Exit fullscreen mode

Use Case: Logging, tracing, and multi-tenant environments.


Handle Graceful Shutdowns
Applications should shut down cleanly. This means closing database connections and active servers properly to avoid data corruption.

Code: Js
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
async function shutdown() {
  await db.close();
  server.close(() => process.exit(0));
}
Enter fullscreen mode Exit fullscreen mode

Adopt Dependency Injection for Flexibility and Testability
Hard-coded dependencies make unit testing harder. Instead, inject them where needed.

Code: Js

function createUserController({ userService }) {
   return (req, res) => userService.create(req.body);
}
Enter fullscreen mode Exit fullscreen mode

Consider using libraries like awilix for a more structured DI pattern.


Batch or Debounce Expensive Operations
Avoid repeated DB/API calls. Queue and process data in batches.
Code: Js

const queue = [];
function batchInsert(data) {
  queue.push(data);
  if (queue.length >= 10) {
    db.insertMany(queue);
    queue.length = 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Useful for logging, notifications, and bulk inserts.


Use Feature-Based Architecture from Day One
Rather than grouping code by type (e.g., all controllers in one folder), organize by domain.

Code: Bash

/users
   controller.js
   service.js
   repo.js
/orders
   controller.js
   ...
Enter fullscreen mode Exit fullscreen mode

This structure makes the codebase more modular and scalable.


Create Custom Error Classes
Avoid scattering plain error objects. Use structured, reusable error classes.

Code: Js

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
  }
}
Enter fullscreen mode Exit fullscreen mode

Centralize error handling with consistent response formatting.


Use Streams for Large Data Processing
Avoid loading entire files into memory. Use streams to handle large files efficiently.
Code:Js

const fs = require('fs');
const readStream = fs.createReadStream('large-file.csv');
readStream.on('data', chunk => {
  // Process chunk
});
Enter fullscreen mode Exit fullscreen mode

Great for uploads, log processing, and file parsing.


Secure Your Application by Default
Integrate essential middleware from the beginning:

Code: Js

const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
app.use(helmet());
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
Enter fullscreen mode Exit fullscreen mode

Sanitize user inputs to prevent injection attacks.


Use PM2 in Production
Avoid using node app.js in production. Use PM2 for process management.

Code:Js

pm2 start app.js --name my-app
pm2 save
pm2 startup
Enter fullscreen mode Exit fullscreen mode

It provides process monitoring, clustering, and automatic restarts.


Add Correlation IDs to Your Logs
Logs without request context are difficult to trace. Assign a unique ID to each incoming request.

Code: Js

app.use((req, res, next) => {
  req.id = uuid.v4();
  next();
});
Enter fullscreen mode Exit fullscreen mode

Pair with a logger like Winston or Pino, and combine with AsyncLocalStorage for more consistent tracing.


Conclusion:

These practices aren't about writing more code—they’re about writing better code. Applying them will help you maintain clarity, reduce runtime issues, and prepare your applications for real-world demands.

FALLOW ME

https://x.com/DevUnionX

Top comments (0)