Common Mistakes to Avoid
These are the mistakes that appear repeatedly in production incidents, code reviews, and Stack Overflow questions — mistakes that experienced developers still make when moving fast. Each item is a specific, concrete anti-pattern with a diagnosis and fix.
1. Awaiting in a loop when calls are independent
TS
// ❌ Sequential — waits for each call before starting the next: O(n × latency)
for (const userId of userIds) {
const user = await fetchUser(userId) // each call blocks the next
results.push(user)
}
// ✅ Parallel — all calls run concurrently: O(max single latency)
const results = await Promise.all(userIds.map(id => fetchUser(id)))
// ⚠️ But: don't fire 10,000 concurrent requests — use batching:
const BATCH = 20
for (let i = 0; i < userIds.length; i += BATCH) {
const batch = await Promise.all(userIds.slice(i, i + BATCH).map(id => fetchUser(id)))
results.push(...batch)
}2. Not handling rejected promises
TS
// ❌ Fire-and-forget without catch — unhandledRejection crash in Node 15+:
sendEmail(user.email) // if this rejects, the process may crash
// ❌ Also bad — .catch swallows the error silently:
sendEmail(user.email).catch(() => {})
// ✅ Log failures explicitly and decide if they're critical:
sendEmail(user.email).catch(err => logger.error({ err }, 'Failed to send email'))
// ✅ Or await it and handle:
try {
await sendEmail(user.email)
} catch (err) {
logger.error({ err }, 'Failed to send email')
// decide: rethrow? retry? continue?
}3. Mutating req.body without validation
TS
// ❌ Trust and spread request body directly — mass assignment vulnerability:
app.post('/users', async (req, res) => {
const user = await db.users.create({ ...req.body }) // user could inject isAdmin: true
res.json(user)
})
// ✅ Validate and pick explicit fields:
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
app.post('/users', async (req, res) => {
const body = CreateUserSchema.parse(req.body) // throws on invalid input
const user = await db.users.create({ name: body.name, email: body.email })
res.json(user)
})4. Returning sensitive fields in API responses
TS
// ❌ Leaks password hash, internal IDs, admin flags to the client:
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
res.json(user) // includes passwordHash, internalFlags, etc.
})
// ✅ Explicit response DTO — only expose what the client should see:
function toUserDTO(user: User) {
return { id: user.id, name: user.name, email: user.email, createdAt: user.createdAt }
}
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(toUserDTO(user))
})5. Synchronous operations on the main thread
TS
// ❌ Blocks every in-flight request for the duration of the read:
app.get('/config', (req, res) => {
const config = fs.readFileSync('./config.json', 'utf8') // synchronous — blocks event loop
res.json(JSON.parse(config))
})
// ✅ Async I/O — non-blocking:
app.get('/config', async (req, res) => {
const config = JSON.parse(await fs.promises.readFile('./config.json', 'utf8'))
res.json(config)
})
// ✅ Or cache at startup — read once, serve from memory:
let configCache: Config
async function loadConfig() { configCache = JSON.parse(await fs.promises.readFile('./config.json', 'utf8')) }
app.get('/config', (req, res) => res.json(configCache))6. Creating new DB connections per request
TS
// ❌ Opens a new connection for every request — exhausts DB connection limit fast:
app.get('/users', async (req, res) => {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
const result = await client.query('SELECT * FROM users')
await client.end()
res.json(result.rows)
})
// ✅ Create one Pool at module level — reuse connections across requests:
import { Pool } from 'pg'
export const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20 })
app.get('/users', async (req, res) => {
const result = await pool.query('SELECT * FROM users')
res.json(result.rows)
})7. Not setting a timeout on outbound HTTP calls
TS
// ❌ No timeout — a slow third-party API hangs your request handler indefinitely:
const data = await fetch('https://slow-api.example.com/data')
// ✅ Always set a timeout on outbound calls:
const data = await fetch('https://slow-api.example.com/data', {
signal: AbortSignal.timeout(5000), // 5s timeout
})
// ✅ For axios:
const client = axios.create({ timeout: 5000 })8. Logging secrets or PII
TS
// ❌ Logs the entire request body — may include passwords, tokens, card numbers:
logger.info({ body: req.body }, 'Incoming request')
// ❌ Error stack traces that include raw SQL with user data:
logger.error(err) // err.message might contain the full query with values
// ✅ Redact sensitive fields before logging:
const safeBody = { ...req.body }
delete safeBody.password
delete safeBody.creditCard
logger.info({ body: safeBody, path: req.path }, 'Incoming request')
// ✅ Use a logger with built-in redaction (pino supports this):
const logger = pino({ redact: ['body.password', 'body.token', '*.creditCard'] })Logs are often the least-secured data store — they go to log aggregators, are forwarded to multiple teams, and are retained for months; treat log data as public
Application logs frequently end up in aggregation services (Datadog, Splunk, CloudWatch) where access controls are looser than production databases. A secret or PII that appears in a log line is effectively leaked to everyone with log read access, and is retained for the log retention period. Audit your logging code for fields that should be redacted: passwords, tokens, credit card numbers, SSNs, email addresses (in some jurisdictions). Use pino's `redact` option or build a sanitization step into your logging middleware rather than relying on developers to remember not to log sensitive fields.
9. Missing error middleware in Express
TS
// ❌ Errors thrown in async route handlers go unhandled in Express 4:
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id) // if this throws, Express doesn't catch it
res.json(user)
})
// ✅ Wrap async handlers (Express 5 does this automatically):
const asyncHandler = (fn: RequestHandler): RequestHandler =>
(req, res, next) => Promise.resolve(fn(req, res, next)).catch(next)
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id)
res.json(user)
}))
// ✅ Central error middleware at the end of the chain:
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
logger.error({ err }, 'Request error')
res.status(500).json({ error: 'Internal server error' })
})10. Using == instead of === with type coercion surprises
TS
// ❌ Loose equality has surprising coercion:
0 == false // true
'' == false // true
null == undefined // true — but null !== 0 and undefined !== 0
// ✅ Always use strict equality in Node.js code:
0 === false // false — as expected
null === undefined // false
// ❌ Checking for empty string:
if (!userInput) { } // also true for 0, false, null — probably not intended
// ✅ Explicit:
if (userInput === '') { }Next
Test your knowledge with common Node.js questions: [Common Interview Questions](/nodejs/interview-questions).