Node.js has gone from a bold experiment to the backbone of modern backend development. As of 2025, 48.7% of developers worldwide use Node.js, making it the most widely adopted web framework on the planet, according to the Stack Overflow Developer Survey. Companies like Netflix, PayPal, and Amazon run critical infrastructure on it. The npm ecosystem now holds over 2 million packages. However, none of that popularity protects a codebase from the problems that creep in when no one is looking closely enough.
Sadly, a fast runtime does not make an application safe or maintainable. It only makes problems scale faster. That is why a structured code review is one of the most important investments a Node.js team can make, whether you are preparing for a funding round, onboarding a new engineering team, or shipping a product you plan to support for the next five years.
At Redwerk, we have been building and auditing Node.js for years, and this checklist reflects what our engineers look for during every review. We organized it the same way we run the process ourselves.
Pre-Review Preparation
First of all, starting a review without preparation is like walking into a negotiation without reading the brief. It means you might still get somewhere, but you will miss the things that matter most.
Define the scope and objectives:
- First, identify whether the review covers the full codebase or if you’ll focus on a specific module, an API layer, or a recent release
- Set clear success criteria. It means defining what counts as a passing review. Is it having fewer security issues, better test coverage, or improved response times?
- Note any known problem areas flagged by the team, such as slow endpoints, flaky tests, or recently introduced dependencies
Verify the environment and setup:
- Confirm that the project runs cleanly in both development and production modes without errors or warnings on startup
- Check that the Node.js version in use matches the one pinned in .nvmrc or .node-version, and verify it is an active LTS release (24.x Active LTS (Krypton) from 2025-04-22 to 2028-04-30. 22.x Maintenance LTS (Jod) from 2024-04-24 to 2027-04-30. are the current production-grade recommendations)
- Ensure npm install or yarn install completes without vulnerability warnings; run npm audit and review any flagged issues before proceeding
- Confirm that environment variables are loaded correctly and that no secrets are hardcoded anywhere in the project
Review documentation and git history:
- Ensure the README clearly describes how to set up, run, build, and test the project
- Review recent commit history to understand what changed and why
- Check that open pull requests are rebased against the latest main branch to avoid reviewing stale or conflicting code
Set up static analysis tooling:
- Confirm that ESLint uses eslint-plugin-n to cover Node.js concerns. Enforce no-process-exit, and ensure no-sync is strictly applied to HTTP routes while allowing it for startup scripts or CLI tools
- Confirm that Prettier or an equivalent formatter is in place and consistent across the project
- Verify that linting and formatting checks run automatically in the CI/CD pipeline before any merge
Project Structure and Code Organization
Looking at a well-organized Node.js project, you can tell quite a bit about the team that built it. When modules are scattered, responsibilities are blurred, and files are named by whoever was in a hurry, every future change costs twice as much as it should.
Folder structure and module boundaries:
- Verify that the project follows a consistent structure, such as separating routes, controllers, services, and data access layers into distinct folders
- Confirm that business logic does not live inside route handlers; route handlers should delegate to service functions, not execute queries or process data directly
Bad practice:
// Route handler doing too much
app.post('/orders', async (req, res) => {
const order = await db.query('INSERT INTO orders ...', [req.body]);
await sendEmail(order.userEmail, 'Order confirmed');
await updateInventory(order.items);
res.json(order);
});
Good practice:
// Route handler delegates to a service
app.post('/orders', async (req, res, next) => {
try {
const order = await OrderService.create(req.body);
res.status(201).json(order);
} catch (err) {
next(err);
}
});
Naming conventions and readability:
- Use camelCase for variables and functions, PascalCase for classes, and UPPER_SNAKE_CASE for constants
- Avoid short, cryptic names like fn, tmp, or data. Every function name should describe what it does, not what it is
- Ensure files are named consistently. Meaning, if controllers are named user.controller.js, they should all follow that pattern, not switch to user-controller.js two folders later
Module usage and CommonJS vs ESM:
- Confirm that the project uses one module system consistently, either require/CommonJS or import/ESM; mixing the two without configuration is a source of subtle runtime errors
- Check that internal modules are imported using relative paths and that no circular dependencies exist. Circular dependencies behave differently depending on the module system: in CommonJS they silently produce undefined at runtime, while in ESM, they trigger a fatal ReferenceError
Async Patterns and Event Loop Health
This is where Node.js projects diverge most sharply from applications in other languages. Node.js processes everything on a single thread via an event loop, and according to the official Node.js documentation, each JavaScript callback must complete quickly. Blocking that loop, even briefly, causes every concurrent request to wait.
Avoid blocking the event loop:
- Look for synchronous file system calls in hot paths: fs.readFileSync, fs.writeFileSync, and similar functions block the event loop entirely until the operation completes. Replace them with their async equivalents
- Check for large, synchronous data processing loops. If a function processes thousands of records in a single tick, it should be offloaded to a worker thread
- Review uses of JSON.parse and JSON.stringify on very large payloads, as these are synchronous and can block the loop when the data is large enough
Bad practice:
app.get('/config', (req, res) => {
const config = fs.readFileSync('./config.json', 'utf8'); // blocks all requests
res.json(JSON.parse(config));
});
Good practice ( Avoid blocking the event loop):
app.get('/config', async (req, res, next) => {
try {
const config = await fs.promises.readFile('./config.json', 'utf8');
res.json(JSON.parse(config));
} catch (err) {
next(err);
}
});
Async/await and Promise handling:
- Ensure every async function has proper error handling; an unhandled promise rejection in Node.js will terminate the process in newer versions
- Check for unnecessary sequential await calls where operations could run in parallel using Promise.all
Bad practice:
// Sequential — user and orders fetched one after the other
const user = await fetchUser(id);
const orders = await fetchOrders(id);
Good practice:
// Parallel — both fetched simultaneously
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);
- Confirm that .catch() handlers are attached to all standalone Promises that are not awaited
- Look for async functions being called inside forEach loops. This pattern does not wait for the async work to finish and silently swallows errors. Use Promise.all with .map() instead
Worker threads for CPU-bound tasks:
- Identify any CPU-intensive operations such as image processing, cryptographic calculations, or large data transformations
- Verify that these tasks are offloaded to worker threads using Node.js’s built-in worker_threads module rather than blocking the main thread
Error Handling
An application that handles errors gracefully is an application that operators trust. If yours doesn’t meet that requirement, it’s a production incident waiting to happen.
Consistent error propagation:
- Verify that errors are passed to Express middleware using next(err) rather than handled ad hoc inside each route. A centralized error handler keeps behavior consistent and logs in one place
- Ensure that custom error classes extend the built-in Error object and carry a meaningful message and statusCode
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
Distinguish operational from programming errors:
- Operational errors are expected: a user sends invalid data, a database connection times out, a third-party API returns a 503. These should be caught, logged, and returned to the client with a meaningful response
- Programming errors are bugs: accessing a property of undefined, calling a function that does not exist. These should crash the process and be caught by a process manager like PM2 or a container restart policy
- Confirm that the codebase does not swallow programming errors silently in try/catch blocks with an empty catch body
Unhandled rejections and uncaught exceptions:
- Verify that the application registers handlers for both process.on(‘unhandledRejection’) and process.on(‘uncaughtException’) to log the error before exiting, rather than crashing without a trace
- Ensure the application attempts a graceful shutdown (closing server connections and database pools) before calling process.exit(1) to avoid dropping in-flight requests
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection:', reason);
process.exit(1);
});
Security Best Practices
The truth is that Node.js security issues are a real threat. Therefore, you must plan for them from day one of working on the project. In 2024 alone, high-profile packages, including web3-utils (CVE-2024-21505) and dset (CVE-2024-21529), were found to contain prototype pollution vulnerabilities that could lead to remote code execution. The npm ecosystem’s size is its greatest strength and one of its most significant risk surfaces.
Prototype pollution prevention:
- Look for any code that merges or clones user-supplied objects without validation, such as recursive merge functions and deep-clone utilities on untrusted input, which are a common entry point for prototype pollution
- Ensure objects that handle user input are created with Object.create(null) where appropriate, stripping the prototype chain entirely
- Verify that JSON payloads from external sources are validated against a schema before being processed
Input validation and injection prevention:
- Confirm that all incoming request data, query parameters, headers, and body fields are validated before being used. Libraries like zod, joi, or express-validator are standard tools for this
- Check all database queries for parameterized input as string-concatenated queries are a direct path to SQL injection
- Review any use of eval(), new Function(), or child_process.exec() with dynamic input. These should be treated as critical vulnerabilities unless there is an explicit and well-documented justification
HTTP security headers and HTTPS:
- Confirm that helmet or an equivalent middleware is in place to set security-relevant HTTP headers, including Content-Security-Policy, X-Frame-Options, and Strict-Transport-Security
- Verify that the application enforces HTTPS in production and does not accept plain HTTP requests for authenticated routes
- Check that cookie settings include the Secure, HttpOnly, and SameSite flags
Rate limiting and denial-of-service protection:
- Verify that API endpoints, especially authentication endpoints, have rate limiting in place using middleware such as express-rate-limit
- Look for any regular expressions applied to user input; inefficient patterns can be exploited to consume CPU time in what is known as a ReDoS attack
Secrets and authentication:
- Confirm that JWT secrets, API keys, and database credentials are never hardcoded in source files or committed to version control
- Review token validation logic and ensure that algorithm restrictions are in place for JWTs to prevent the none algorithm vulnerability
- Verify that password hashing uses bcrypt, argon2, or scrypt, not MD5, SHA-1, or plain SHA-256 without salt
Performance Optimization
A Node.js application that runs fast in development often behaves very differently under production load. Performance issues compound quickly when you have many concurrent connections.
Database query efficiency:
- Review all database queries for N+1 patterns. If a route makes one query to get a list of records and then a separate query per record to fetch related data, it will slow to a crawl at scale
- Confirm that database indexes are in place for all fields used in WHERE clauses, JOINs, and ORDER BY expressions
- Check for unbounded queries, as any query that can return an arbitrary number of rows without pagination is a future memory and response-time problem
Caching:
- Identify expensive operations, such as complex database queries or external API calls, that could benefit from caching
- Verify that a caching layer, such as Redis or an in-memory cache, is in place for frequently accessed, slowly changing data
- Check that cache invalidation logic exists because a cache that grows indefinitely, or serves stale data, is worse than no cache at all
Connection pooling and resource management:
- Confirm that database connections use a pool rather than opening a new connection per request. This is a common mistake that causes Node.js applications to exhaust database connection limits under load
- Check that HTTP clients and external service connections are reused rather than created per request
Streaming for large data:
- Review any endpoints that read large files or database result sets into memory before sending them to the client. These should use Node.js streams to pipe data incrementally rather than buffering everything first
Dependency Management
There are over 2 million packages in the npm registry. Through those, every Node.js project carries a dependency tree that extends far beyond what any team directly controls. Therefore, the tree itself is a risk surface that you must manage effectively.
Keeping dependencies current and minimal:
- Run npm audit and verify that all high and critical vulnerabilities have been resolved or have an explicit, documented exception
- Review package.json for dependencies that are no longer in use, as unused packages increase install time and attack surface without providing any value
- Check that version ranges in package.json are not overly permissive. While Semantic Versioning dictates that minor updates must be backward-compatible, a caret range like ^4.0.0 can practically introduce breaking changes if package authors violate the standard; package-lock.json or yarn.lock should be committed to ensure reproducible installs
Evaluating third-party packages:
- For any newly added dependency, check its npm download volume, last publish date, and number of open security advisories
- Prefer packages that are actively maintained and have a clear ownership history because a package last published four years ago with zero activity is a supply-chain risk
- Verify that heavy utility libraries are not imported just for one function; lodash imported wholesale adds kilobytes where a single native method would do the same job
Testing and Code Coverage
Tests are not bureaucracy but rather the mechanism by which a team communicates what the code is supposed to do, and the only reliable way to know whether a change broke something.
Unit and integration test coverage:
- Verify that all service functions and utility modules have unit tests covering the happy path, edge cases, and error conditions
- Confirm that route handlers have integration tests that validate the full request-response cycle, including authentication middleware
- Check that the test suite runs without network or database calls unless those calls are intentional integration tests, as unit tests should use mocks for external dependencies
Test quality, not just coverage numbers:
- Review whether tests assert on meaningful outcomes rather than just verifying that a function was called; a test that only checks toHaveBeenCalled without validating the result is not protecting the behavior
- Ensure test names describe the behavior being tested, for example, it(‘returns 401 when the token is expired’) rather than it(‘handles auth’)
- Confirm that the CI pipeline fails on test failures and that no tests are being skipped with it.skip or xit without a documented reason
Environment Configuration and Secrets
When you see the gap between how a Node.js application behaves in development and in production, you are looking at issues that originate in configuration.
Environment variable management:
- Confirm that all environment-specific configuration, including database URLs, API keys, feature flags, and port numbers, comes from environment variables rather than hardcoded values
- Verify that a .env.example file exists in the repository documenting all required variables without their actual values. The real .env file must be in .gitignore
- Check that the application fails loudly on startup if required environment variables are missing, rather than running in a partially configured state that produces subtle errors later
// Validate required env vars at startup
const required = ['DATABASE_URL', 'JWT_SECRET', 'PORT'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
Production vs. development configuration:
- Verify that NODE_ENV is set correctly in all deployment environments and that the application behaves appropriately in each mode, for example, verbose error messages only in development
- Confirm that debugging tools and verbose logging are disabled in production builds
Logging and Observability
Imagine you get an alert at 2 AM that something went wrong. The difference between a 15-minute fix for this issue and a hours-long investigation will be determined by the quality of your logs.
Structured logging:
- Verify that the application uses a structured logging library such as pino or winston rather than console.log statements scattered throughout the codebase. Structured logs are searchable and compatible with log aggregation tools
- Ensure that log entries include a timestamp, log level, request ID, and enough context to understand what happened without guessing
Meaningful log levels:
- Confirm that log levels are used correctly: info for normal operations, warn for unexpected but non-critical conditions, error for failures that require attention, and debug for detailed diagnostic data that should never appear in production
- Look for console.log calls left in production code. These are a sign that logging was not properly set up and that important information may be going to the wrong place
Request tracing and health checks:
- Verify that the application assigns a unique request ID to each incoming request and propagates it through all log entries for that request. This makes it possible to trace a single user’s request through a distributed system
- Confirm that a health check endpoint exists and returns a meaningful response indicating whether the application and its dependencies are operational
Node.js Code Review with Redwerk
Going through this checklist on your own is a good start, but the next step is to have an external team run it for you. At Redwerk, we have been building and reviewing applications for over two decades. Our team covers the full picture: architecture, async patterns, security, performance, and testing. We flag what is broken, explain why it matters, and give you a clear path to fix it.
Let’s take a look at how this works in practice through our case study for Kooky, a smart reusable cup system we built from scratch. It’s a platform for the Swiss market that runs on a real-time, event-driven backend. At the moment, it handles QR-based cup tracking, live account updates, and instant deposit processing. It operates in partnership with Swiss Federal Railways, Valora, and Coop.
In order to keep that system stable under concurrent load, we had to get every item on this checklist right from day one, including:
- Async patterns that never starve the event loop
- Rate limiting on payment endpoints
- Structured logging that makes production incidents traceable
- Dependency tree that gets audited on every release
With all that in place, Kooky went on to become Switzerland’s number one green tech startup, and good architecture had a lot to do with it.
Over 50 businesses have trusted us to build or audit their products from the ground up. And whether your Node.js project is a startup API or an enterprise platform, our Node.js development experience means we know exactly where the problems tend to hide.
If your codebase is due for a closer look, contact us, and we’ll take it from there.
See what difference code review can make: how we identified 80+ improvements and security risks for a mobile marketplace