Dev Catchup: Building Reliable Backend Services
Node.js Backend — Standards & Common Pitfalls
Architecture
1. Separate layers properly — Controller / Service / Repository
// ❌ Everything dumped into the controller
app.post('/orders', async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (user.balance < req.body.total) return res.status(400).json({ error: 'Insufficient balance' });
const order = await prisma.order.create({ data: { ... } });
await prisma.user.update({ where: { id: user.id }, data: { balance: { decrement: order.total } } });
await prisma.inventory.update({ ... });
await sendEmail(user.email, 'Order confirmed', ...);
res.json(order);
});
// → Can't test business logic in isolation
// → Need the same logic in a cron job? Duplicate everything
// → File grows to 500+ lines, everyone's afraid to touch it
Each layer has ONE job:
| Layer | Responsibility |
|---|---|
| Controller | Parse HTTP request, call service, return HTTP response |
| Service | Business logic, orchestrate workflow |
| Repository | Query database, return raw data |
// ✅ Controller — only bridges HTTP ↔ Service
app.post('/orders', asyncHandler(async (req, res) => {
const order = await orderService.create(req.userId, req.body);
res.status(201).json({ success: true, data: order });
}));
// ✅ Service — all business logic, knows nothing about HTTP
class OrderService {
constructor(
private orderRepo: OrderRepository,
private userRepo: UserRepository,
) {}
async create(userId: string, input: CreateOrderInput) {
const user = await this.userRepo.findById(userId);
if (!user) throw new NotFoundError('User');
if (user.balance < input.total) {
throw new AppError(400, 'INSUFFICIENT_BALANCE', 'Not enough balance');
}
return this.orderRepo.createWithPayment(userId, input);
}
}
// ✅ Repository — only knows how to read/write database
class OrderRepository {
async createWithPayment(userId: string, input: CreateOrderInput) {
return prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { userId, ...input } });
await tx.user.update({
where: { id: userId },
data: { balance: { decrement: input.total } },
});
return order;
});
}
}
The same service works everywhere — no duplication:
// HTTP API
app.post('/orders', (req, res) => orderService.create(req.userId, req.body));
// GraphQL
createOrder: (_, args, ctx) => orderService.create(ctx.userId, args.input);
// Cron job
await orderService.processExpiredOrders();
// Queue worker
await orderService.create(job.data.userId, job.data.input);
// Unit test — mock the repo, test pure logic
mockUserRepo.findById.mockResolvedValue({ balance: 0 });
await expect(orderService.create('user1', { total: 100 }))
.rejects.toThrow('INSUFFICIENT_BALANCE');
Rules:
- Controller has
iflogic beyond auth/validation, or callsprismadirectly → wrong layer - Service imports
req/res, or importsprisma/mongoosedirectly → wrong layer - Repository throws business errors or calls external APIs → wrong layer
2. All computation logic belongs on the backend
// ❌ Frontend calculates total, discount, tax and sends them up
// POST /orders body: { items: [...], total: 299000, discount: 50000, tax: 24900 }
app.post('/orders', async (req, res) => {
const order = await prisma.order.create({
data: {
total: req.body.total, // whatever frontend sends
discount: req.body.discount, // user edits request → 100% discount
tax: req.body.tax, // tax = 0 → tax evasion
},
});
});
// → User opens DevTools, sets total = 1000, discount = 999999 → free purchase
// ✅ Backend ALWAYS calculates — frontend only sends raw input
class OrderService {
async create(userId: string, input: { items: OrderItemInput[], couponCode?: string }) {
// 1. Get prices from DB (NEVER trust frontend prices)
const products = await this.productRepo.findByIds(input.items.map(i => i.productId));
// 2. Backend calculates subtotal
const subtotal = input.items.reduce((sum, item) => {
const product = products.find(p => p.id === item.productId);
return sum + product.price * item.quantity;
}, 0);
// 3. Backend validates coupon and calculates discount
const discount = input.couponCode
? await this.couponService.calculateDiscount(input.couponCode, subtotal)
: 0;
// 4. Backend calculates tax and total
const tax = (subtotal - discount) * 0.1;
const total = subtotal - discount + tax;
return this.orderRepo.create({ userId, items: input.items, subtotal, discount, tax, total });
}
}
Same applies to reports:
// ❌ Frontend fetches raw data, calculates stats itself
// Problems:
// 1. Web calculates differently than mobile → numbers don't match
// 2. Change the formula → redeploy web + mobile + admin
// 3. Large dataset → load 50K records to client → slow
// ✅ Backend calculates, frontend only displays
class ReportService {
async getRevenue(from: Date, to: Date) {
const result = await prisma.order.aggregate({
where: { status: 'completed', createdAt: { gte: from, lte: to } },
_sum: { total: true, discount: true, tax: true },
_count: { id: true },
});
return {
totalRevenue: result._sum.total,
totalDiscount: result._sum.discount,
orderCount: result._count.id,
averageOrderValue: result._sum.total / result._count.id,
};
}
}
// → Change calculation logic? Update backend once, deploy once
// → Web, mobile, admin all show correct numbers immediately
Rule: Frontend sends input (productId, quantity, couponCode). Backend returns computed results (total, discount, tax, report). NEVER trust numbers from the frontend.
3. God file — one file doing too much
// ❌ user.service.ts — 1500 lines, 40 methods
class UserService {
async register() { ... }
async login() { ... }
async forgotPassword() { ... }
async updateProfile() { ... }
async uploadAvatar() { ... }
async getNotifications() { ... }
async calculateLoyaltyPoints(){ ... }
async syncWithCRM() { ... }
// ... 26 more methods
}
// → 2 devs edit the same file → constant merge conflicts
// → Finding a function means scrolling through 1500 lines
// → Small login bug → scared to deploy because this file touches everything
// ✅ Split by domain/responsibility
class AuthService { async register() {...} async login() {...} async forgotPassword() {...} }
class ProfileService { async updateProfile() {...} async uploadAvatar() {...} }
class NotificationService { async getNotifications() {...} async markAsRead() {...} }
Same for utils:
// ❌ utils.ts — 2000 lines containing everything
export function formatDate() { ... }
export function sendEmail() { ... } // NOT a util — it's a service
export function calculateTax() { ... } // business logic
export function uploadToS3() { ... } // infrastructure
// ✅ Split properly
// utils/date.ts — only date helpers
// utils/string.ts — only string helpers
// services/email.service.ts — service, not util
// infrastructure/storage.ts — S3/storage operations
Rule of thumb: File > 300 lines or > 10 methods → split it. Generic names (utils.ts, helpers.ts, common.ts) → definitely split.
API Documentation
4. Using Swagger/OpenAPI — and remembering to disable it in production
// ❌ No API docs — or docs live in Notion/Postman and drift out of sync within a week
// Frontend constantly pings backend: "what fields does this endpoint take?"
// New devs spend their first week reverse-engineering routes from code
// ✅ Use Swagger/OpenAPI — docs generated from code, always in sync
import swaggerUi from 'swagger-ui-express';
import swaggerJsdoc from 'swagger-jsdoc';
const specs = swaggerJsdoc({
definition: {
openapi: '3.0.0',
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'http://localhost:3000' }],
},
apis: ['./src/routes/*.ts'], // reads JSDoc comments
});
/**
* @openapi
* /orders:
* post:
* summary: Create an order
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [items]
* properties:
* items:
* type: array
* items:
* type: object
* properties:
* productId: { type: string }
* quantity: { type: integer, minimum: 1 }
* couponCode: { type: string }
* responses:
* 201: { description: Order created }
* 400: { description: Validation error }
*/
app.post('/orders', ...);
⚠️ CRITICAL: Disable Swagger UI in production
// ❌ Leaving /api-docs open in production
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
// → Anyone can visit yoursite.com/api-docs and see your ENTIRE API surface:
// every endpoint, every field, every validation rule, every error code
// → Attackers use this as a recon blueprint: IDOR targets, mass-assignment
// candidates, hidden internal endpoints you forgot to mark private
// ✅ Option 1: Only expose in non-production environments
if (process.env.NODE_ENV !== 'production') {
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
}
// ✅ Option 2: If you MUST keep it in production, put it behind auth
app.use(
'/api-docs',
basicAuth({ users: { [process.env.DOCS_USER!]: process.env.DOCS_PASS! }, challenge: true }),
swaggerUi.serve,
swaggerUi.setup(specs),
);
// ✅ Option 3: Serve the raw spec to internal tools only, no public UI
// (generate openapi.json at build time, share via internal portal)
Rules:
- Keep JSDoc/OpenAPI annotations next to the route — they rot the moment they live elsewhere
NODE_ENV !== 'production'guard is the default; add auth only if product needs public docs- Review the generated spec before each release — make sure no admin/internal route leaked in
- Never include real tokens, sample user IDs, or production URLs in examples
Database
5. Redundant queries — batch what you can into fewer SQL calls
The classic "N+1" is querying inside a loop, but the real principle is broader: if you can get the data in 1 query, don't split it into N separate ones.
// ===== CASE 1: Query inside a loop (classic N+1) =====
// ❌ GET /orders — list orders with user info
const orders = await prisma.order.findMany(); // 1 query
for (const order of orders) {
order.user = await prisma.user.findUnique({ where: { id: order.userId } }); // N queries
}
// 100 orders = 101 queries → should be 1-2
// ✅ Include / Join — one round trip
const orders = await prisma.order.findMany({
include: { user: { select: { id: true, name: true } } },
});
// ===== CASE 2: Multiple separate queries that could be one =====
// ❌ Dashboard API — 4 separate queries, sequential = slow
const totalUsers = await prisma.user.count();
const totalOrders = await prisma.order.count();
const totalRevenue = await prisma.order.aggregate({ _sum: { total: true } });
const newUsersToday = await prisma.user.count({ where: { createdAt: { gte: startOfDay } } });
// ✅ Run them in parallel — same 4 queries, 1 round trip time
const [totalUsers, totalOrders, totalRevenue, newUsersToday] = await Promise.all([
prisma.user.count(),
prisma.order.count(),
prisma.order.aggregate({ _sum: { total: true } }),
prisma.user.count({ where: { createdAt: { gte: startOfDay } } }),
]);
// ✅ Or even better — one raw SQL if your DB supports it
const dashboard = await prisma.$queryRaw`
SELECT
(SELECT COUNT(*) FROM users) AS total_users,
(SELECT COUNT(*) FROM orders) AS total_orders,
(SELECT COALESCE(SUM(total), 0) FROM orders) AS total_revenue,
(SELECT COUNT(*) FROM users WHERE created_at >= ${startOfDay}) AS new_users_today
`;
// ===== CASE 3: Checking related data one by one =====
// ❌ Check stock for each item individually — 10 items = 10 queries
async function validateStock(items: CartItem[]) {
for (const item of items) {
const product = await prisma.product.findUnique({ where: { id: item.productId } });
if (product.stock < item.quantity) throw new Error(`${product.name} out of stock`);
}
}
// ✅ Fetch all products at once, check in memory — 1 query regardless of cart size
async function validateStock(items: CartItem[]) {
const productIds = items.map(i => i.productId);
const products = await prisma.product.findMany({ where: { id: { in: productIds } } });
for (const item of items) {
const product = products.find(p => p.id === item.productId);
if (!product) throw new NotFoundError(`Product ${item.productId}`);
if (product.stock < item.quantity) throw new Error(`${product.name} out of stock`);
}
}
How to detect: Enable query logging in development, count queries per request. If the same table gets hit N times → batch it.
6. Missing or wrong transaction scope
// ===== CASE 1: No transaction → inconsistent data =====
// ❌ User buys Premium plan — one step fails midway
await prisma.payment.create({ data: { userId, amount: 299000, status: 'completed' } }); // ✅ money deducted
await prisma.user.update({ where: { id: userId }, data: { plan: 'premium' } }); // ❌ DB timeout
await prisma.subscription.create({ data: { userId, plan: 'premium', expiredAt } }); // ❌ never runs
// → Money gone but user is still Free. Support has to fix manually.
// ✅ All-or-nothing
await prisma.$transaction(async (tx) => {
await tx.payment.create({ data: { userId, amount: 299000, status: 'completed' } });
await tx.user.update({ where: { id: userId }, data: { plan: 'premium' } });
await tx.subscription.create({ data: { userId, plan: 'premium', expiredAt } });
// If any step fails → ALL rolled back, money not deducted
});
// ===== CASE 2: Transaction scope too wide → locks too long =====
// ❌ External API call inside transaction → DB rows locked for 5-10 seconds
await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: orderData });
const payment = await stripeClient.charges.create({ amount: order.total }); // 3-5s timeout
await tx.order.update({ where: { id: order.id }, data: { paymentId: payment.id } });
});
// ✅ Keep transaction fast — external calls OUTSIDE
// Step 1: Create order as pending (fast transaction)
const order = await prisma.$transaction(async (tx) => {
const order = await tx.order.create({ data: { ...orderData, status: 'pending_payment' } });
await tx.inventory.update({
where: { productId: order.productId },
data: { quantity: { decrement: order.quantity } },
});
return order;
});
// Step 2: Call payment gateway OUTSIDE transaction
const payment = await stripeClient.charges.create({ amount: order.total });
// Step 3: Update status (fast transaction)
await prisma.order.update({
where: { id: order.id },
data: { paymentId: payment.id, status: 'paid' },
});
// If payment fails → order stays pending → cron job cleans up later
7. SELECT * instead of selecting only needed fields
// ❌ Fetching all 30 columns when you only need 3
const users = await prisma.user.findMany(); // all fields including password hash
// ✅ Select only what you need
const users = await prisma.user.findMany({
select: { id: true, name: true, avatar: true },
});
// → Less data transfer, faster, no sensitive field leaks
8. Storing dates as DATE instead of TIMESTAMP
// ❌ Storing startDate, endDate as DATE (date only, no time)
model Promotion {
startDate DateTime @db.Date // 2025-04-22
endDate DateTime @db.Date // 2025-04-30
}
// Promotion ends April 30 — but does "end" mean 00:00 or 23:59?
// User in UTC+7 sees it expire earlier than user in UTC-5
// WHERE date BETWEEN '2025-04-22' AND '2025-04-30'
// → Runs from 00:00 on the 22nd to 00:00 on the 30th (loses entire day 30)
// ✅ ALWAYS store TIMESTAMP with time component
model Promotion {
startAt DateTime @db.Timestamptz // 2025-04-22T00:00:00+07:00
endAt DateTime @db.Timestamptz // 2025-04-30T23:59:59+07:00
}
model Subscription {
// ❌ Wrong
startDate DateTime @db.Date // "starts May 1" — but 00:00 in which timezone?
endDate DateTime @db.Date // "expires May 31" — can user still access at 23:00?
// ✅ Correct
startAt DateTime @db.Timestamptz // 2025-05-01T00:00:00Z — clearly UTC
endAt DateTime @db.Timestamptz // 2025-05-31T23:59:59Z — clearly UTC
}
Best practices:
- Store UTC in database, convert to local timezone in client/API response
- Use
Timestamptz(PostgreSQL) orDATETIME(MySQL) — NOTDATEfor business logic - Compare with:
WHERE startAt <= NOW() AND endAt >= NOW()— always correct regardless of timezone - Naming: use
startAt/endAtinstead ofstartDate/endDateto signal time component
Security
9. Not checking ownership (IDOR)
// ❌ User A can view/edit/delete User B's data
app.get('/orders/:id', authenticate, async (req, res) => {
const order = await prisma.order.findUnique({ where: { id: req.params.id } });
res.json(order); // NOT checking order.userId === req.user.id
// → Anyone who knows an orderId can view it
});
// ✅ Always filter by ownership
app.get('/orders/:id', authenticate, async (req, res) => {
const order = await prisma.order.findFirst({
where: {
id: req.params.id,
userId: req.user.id, // only fetch orders belonging to current user
},
});
if (!order) throw new NotFoundError('Order'); // 404, NOT 403 (avoid leaking existence)
res.json(order);
});
Applies to ALL resources — files, messages, profiles, settings. Rule: Every query fetching data must filter by user ownership or have an explicit permission check.
10. Mass assignment
// ❌ Spreading request body directly into DB
app.patch('/users/me', authenticate, async (req, res) => {
const user = await prisma.user.update({
where: { id: req.user.id },
data: req.body, // Attacker sends { "role": "admin", "balance": 999999 }
});
});
// ✅ Whitelist allowed fields
const updateProfileSchema = z.object({
name: z.string().min(2).max(100).optional(),
avatar: z.string().url().optional(),
bio: z.string().max(500).optional(),
// NO role, balance, isVerified, etc.
});
app.patch('/users/me', authenticate, validate(updateProfileSchema), async (req, res) => {
const user = await prisma.user.update({
where: { id: req.user.id },
data: req.body, // already filtered through schema
});
});
11. Not validating query params / request input
// ❌ Everyone validates body, but FORGETS query params
app.get('/products', async (req, res) => {
const page = req.query.page; // string "abc" → NaN → bug
const limit = req.query.limit; // string "99999" → OOM
const sort = req.query.sort; // "password" → leaks sensitive field
});
// ✅ Validate query params too
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['createdAt', 'name', 'price', '-createdAt', '-name', '-price']).default('-createdAt'),
status: z.enum(['active', 'inactive', 'all']).optional(),
});
Rule: Validate all input — body, query, params, headers. Not just body.
12. Returning too much data in response
// ❌ Return entire object, client only needs a few fields
const user = await prisma.user.findUnique({ where: { id } });
res.json(user); // password hash, internal flags, metadata all leak to client
// ✅ Select explicit or use DTO/serializer
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, avatar: true },
});
13. Leaking information through error messages
// ❌ Tells attacker whether an email exists
POST /auth/login → "Password incorrect" // → email exists
POST /auth/login → "User not found" // → email doesn't exist
// → Attacker can enumerate valid emails
// ✅ Same response for all cases
POST /auth/login → "Invalid email or password"
POST /auth/forgot-password → "If this email exists, we sent a reset link"
// Even timing should be the same → hash a dummy password even if user doesn't exist
14. Exposing sensitive data in logs
// ❌ Logging entire request body — contains passwords, tokens, PII
logger.info({ body: req.body }, 'Request received');
// Output: { body: { email: "user@x.com", password: "MySecret123", ssn: "123-45-6789" } }
// ✅ Redact sensitive fields
const logger = pino({
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.confirmPassword',
'req.body.token',
'req.body.creditCard',
'*.password',
'*.secret',
'*.accessToken',
'*.refreshToken',
],
censor: '[REDACTED]',
},
});
Error Handling
15. Swallowing errors — catch and do nothing
// ❌ Bug silently disappears
try {
await sendEmail(user.email, template);
} catch (err) {
// "fix later" — never fixed
}
// ❌ Catch-all with generic response — no log, nobody knows what happened
try {
await complexBusinessLogic();
} catch (err) {
res.status(500).json({ error: 'Something went wrong' });
}
// ✅ Catch, log, then decide: rethrow or handle gracefully
try {
await sendEmail(user.email, template);
} catch (err) {
logger.error({ err, userId: user.id, template }, 'Failed to send email');
await emailQueue.add('retry-email', { userId: user.id, template }, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}
16. Unhandled Promise Rejections
// ❌ Fire-and-forget async function → crashes app if it throws
app.post('/orders', async (req, res) => {
const order = await orderService.create(req.body);
sendOrderConfirmationEmail(order); // no await, no catch → unhandled rejection
res.json(order);
});
// ✅ Option 1: await + catch
await sendOrderConfirmationEmail(order).catch(err => {
logger.error({ err }, 'Email send failed');
});
// ✅ Option 2: use a queue (better for heavy tasks)
await emailQueue.add('order-confirmation', { orderId: order.id });
// ✅ Safety net (always have it, but DON'T rely on it)
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'Unhandled Rejection — THIS IS A BUG');
});
17. Not distinguishing operational vs programmer errors
Operational errors: expected, handled (invalid input, network timeout, DB down)
Programmer errors: bugs (TypeError, null reference, logic errors)
// ❌ Treating all errors the same
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message }); // leaks internal error messages
});
// ✅ Handle them differently
class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public isOperational = true,
) { super(message); }
}
app.use((err, req, res, next) => {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
success: false,
error: { code: err.code, message: err.message },
});
}
// Programmer error: log details, return generic message
logger.fatal({ err, req: { method: req.method, url: req.url } },
'PROGRAMMER ERROR — needs immediate fix');
res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
});
});
18. Inconsistent error response format
// ❌ Every endpoint returns errors differently → frontend has to guess
res.status(400).json({ message: 'Bad request' }); // endpoint A
res.status(400).json({ error: 'Invalid email' }); // endpoint B
res.status(400).json({ errors: [{ field: 'email' }] }); // endpoint C
// ✅ Always use ONE format, enforced by global error handler
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable message",
"details": [...] // optional
}
}
Performance
19. Not caching rarely-changing data
// ❌ Every request queries config/settings from DB
app.get('/products', async (req, res) => {
const categories = await prisma.category.findMany(); // changes once a week
const settings = await prisma.settings.findFirst(); // changes once a month
const products = await prisma.product.findMany({ ... }); // the data you need
// 3 queries instead of 1
});
// ✅ Cache rarely-changing data
const getCategories = async () => {
const cached = await redis.get('categories');
if (cached) return JSON.parse(cached);
const categories = await prisma.category.findMany();
await redis.set('categories', JSON.stringify(categories), 'EX', 3600); // 1h
return categories;
};
// Invalidate when admin updates
app.put('/admin/categories', async (req, res) => {
await prisma.category.update({ ... });
await redis.del('categories');
});
20. Blocking the event loop
// ❌ CPU-intensive task blocks the entire server
app.post('/reports/generate', async (req, res) => {
const data = await prisma.order.findMany({ ... }); // 100K records
const csv = generateCSV(data); // CPU-bound, blocks event loop for 5 seconds
// During those 5 seconds NO other request gets processed
res.send(csv);
});
// ✅ Offload heavy work to worker thread or queue
app.post('/reports/generate', async (req, res) => {
const jobId = await reportQueue.add('generate-csv', { filters: req.body });
res.status(202).json({
success: true,
data: { jobId, status: 'processing', checkUrl: `/reports/status/${jobId}` },
});
});
// Client polls status or gets notified via WebSocket when done
Quick Reference
| # | Pitfall | Impact | Fix |
|---|---|---|---|
| 1 | Business logic in controller | Can't test/reuse | Controller → Service → Repo |
| 2 | Computation logic on frontend | User edits price, reports mismatch | Backend computes all, FE only displays |
| 3 | God file | Merge conflicts, fear of deploying | Split by domain, < 300 lines per file |
| 4 | Swagger/OpenAPI docs | No docs → drift; public docs in prod → recon blueprint | Use swagger-jsdoc, disable/auth-gate in production |
| 5 | Redundant queries (N+1) | API 10-100x slower | Batch into fewer queries, use includes |
| 6 | Wrong/missing transaction | Money deducted but plan unchanged | All-or-nothing, external calls outside tx |
| 7 | SELECT * | Slow, leaks sensitive fields | Select only needed fields |
| 8 | DATE instead of TIMESTAMP | Timezone bugs, lost hours | Always Timestamptz, store UTC |
| 9 | No ownership check (IDOR) | User sees other's data | Filter by userId in every query |
| 10 | Mass assignment | User sets role=admin | Whitelist fields with Zod |
| 11 | No query/params validation | Injection, OOM, data leak | Zod validate all input |
| 12 | Returning too much data | Leaks password hash, PII | Select explicit / DTO |
| 13 | Info leak via error messages | Attacker enumerates emails | Same response for all cases |
| 14 | Sensitive data in logs | PII leak | Pino redact |
| 15 | Swallowing errors | Hidden bugs, blind debugging | Log + retry/throw |
| 16 | Unhandled Promise Rejections | App crashes | await + catch or use queue |
| 17 | Not distinguishing error types | Leaks internals or hides bugs | Operational vs programmer errors |
| 18 | Inconsistent error format | Frontend guesses format | One format, global error handler |
| 19 | Not caching static data | Unnecessary DB queries | Redis cache + invalidate on update |
| 20 | Blocking event loop | Server freezes | Queue / Worker thread |
Last updated: May 2026 · Maintained by the backend team