Your Node.js API is handling 500 requests per second. A developer pushes a PR with fs.readFileSync() inside an Express handler. The code review passes — it looks fine syntactically. Three weeks later, production starts timing out under load.
We analyzed hundreds of Node.js backends and found 12 recurring patterns that cause production incidents. Most code review tools and linters miss them because they're syntactically valid — the danger is in where they're called, not what they are.
1. readFileSync in a Request Handler
The single most common event loop killer.
// ❌ This blocks EVERY concurrent request
app.get('/config', (req, res) => {
const config = fs.readFileSync('./config.json', 'utf8'); // blocks 50-200ms
res.json(JSON.parse(config));
});
// ✅ Use async version
app.get('/config', async (req, res) => {
const config = await fs.promises.readFile('./config.json', 'utf8');
res.json(JSON.parse(config));
});
Why it's dangerous: readFileSync blocks the entire event loop. While one request reads the file, ALL other requests wait. At 100 concurrent users, response times go from 5ms to 5 seconds.
Where it hides: Config loading, template rendering, certificate reading. Especially common in code generated by AI tools that don't understand the event loop.
2. Synchronous Crypto in Handlers
// ❌ pbkdf2Sync takes 100-500ms and blocks everything
app.post('/login', (req, res) => {
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512');
});
// ✅ Use async version
app.post('/login', async (req, res) => {
const hash = await new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
});
Impact: A single login request blocks the server for 100-500ms. During Black Friday traffic, this cascades into complete API unavailability.
3. N+1 Queries in Loops
// ❌ 100 users = 101 database queries
const users = await User.findAll();
for (const user of users) {
user.posts = await Post.findAll({ where: { userId: user.id } });
}
// ✅ Use eager loading or batch query
const users = await User.findAll({
include: [{ model: Post }]
});
Why it kills: This pattern grows linearly with data. 10 users = 11 queries. 10,000 users = 10,001 queries. Your database connection pool exhausts, requests queue, timeouts cascade.
4. Unbounded findMany on Large Tables
// ❌ On a table with 50M rows, this returns ALL of them
const events = await prisma.event.findMany();
// ✅ Always paginate
const events = await prisma.event.findMany({
take: 20,
skip: page * 20,
orderBy: { createdAt: 'desc' }
});
The hidden variable: This code works perfectly on your dev database with 100 rows. In production with 50 million rows, it allocates gigabytes of memory and crashes the process.
This is why detection must be volume-aware — the same code is safe on a small table and deadly on a large one.
5. ReDoS-Vulnerable Regular Expressions
// ❌ Catastrophic backtracking on crafted input
const emailRegex = /^([a-zA-Z0-9]+\.)*[a-zA-Z0-9]+@([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}$/;
app.post('/validate', (req, res) => {
const isValid = emailRegex.test(req.body.email); // can take 30+ seconds
});
How it works: Certain regex patterns have exponential backtracking. An attacker sends a crafted string and your server freezes for minutes processing a single regex match. This is a known DoS vector.
6. JSON.parse on Unbounded Input
// ❌ 100MB JSON body = server crash
app.post('/import', (req, res) => {
const data = JSON.parse(req.body.payload); // unbounded
});
// ✅ Limit body size + validate
app.post('/import', express.json({ limit: '1mb' }), (req, res) => {
const data = req.body; // already parsed and size-limited
});
7. Synchronous Compression
// ❌ Blocks event loop for large payloads
app.get('/export', (req, res) => {
const compressed = zlib.deflateSync(largeBuffer);
res.send(compressed);
});
// ✅ Use streams
app.get('/export', (req, res) => {
const stream = zlib.createDeflate();
readableStream.pipe(stream).pipe(res);
});
8. CPU-Heavy Loops in Handlers
// ❌ Sorting 1M items blocks for seconds
app.get('/leaderboard', async (req, res) => {
const allUsers = await User.findMany();
allUsers.sort((a, b) => calculateScore(b) - calculateScore(a)); // CPU-bound
res.json(allUsers.slice(0, 100));
});
// ✅ Sort in database
app.get('/leaderboard', async (req, res) => {
const top100 = await User.findMany({
orderBy: { score: 'desc' },
take: 100
});
res.json(top100);
});
9. Fire-and-Forget Promises
// ❌ If sendEmail fails, you'll never know
app.post('/register', async (req, res) => {
await createUser(req.body);
sendWelcomeEmail(req.body.email); // no await = silent failure
res.json({ success: true });
});
// ✅ Await or use a queue
app.post('/register', async (req, res) => {
await createUser(req.body);
await emailQueue.add('welcome', { email: req.body.email });
res.json({ success: true });
});
10. HTTP Calls Without Timeout
// ❌ If payment API hangs, your server hangs
const result = await fetch('https://api.stripe.com/charges', {
method: 'POST',
body: JSON.stringify(charge)
});
// ✅ Always set a timeout
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const result = await fetch('https://api.stripe.com/charges', {
method: 'POST',
body: JSON.stringify(charge),
signal: controller.signal
});
11. Empty Catch Blocks
// ❌ Errors silently disappear
try {
await processPayment(order);
} catch (e) {
// TODO: handle later
}
// ✅ Log or rethrow
try {
await processPayment(order);
} catch (e) {
logger.error('Payment failed', { orderId: order.id, error: e });
throw e;
}
12. Busy-Wait Loops
// ❌ Infinite loop blocks everything forever
while (!isReady) {
// waiting...
}
// ✅ Use async polling
while (!isReady) {
await new Promise(r => setTimeout(r, 100));
}
Why Code Reviews Miss These
These patterns are syntactically valid JavaScript. ESLint won't flag them. SonarQube won't detect the context. Even experienced reviewers miss them because the danger isn't in the code itself — it's in where the code runs.
readFileSync in a setup script? Fine. readFileSync in a request handler? Production incident waiting to happen.
findMany() on a table with 100 rows? Fine. findMany() on a table with 50M rows? OOM crash.
The detection must be scope-aware (handler vs background) and volume-aware (small table vs large table).
Automatic Detection
Technical Debt Radar detects all 12 patterns automatically. It's scope-aware — it knows the difference between a request handler and a cron job. It's volume-aware — it reads your declared table sizes and adjusts severity accordingly.
Try it on your codebase:
npx technical-debt-radar scan .
First scan is free, no account needed. It takes less than 10 seconds.