0x55aa
← Back to Blog

Express Middleware Mistakes That Will Crash Your API 🚨

13 min read

Express Middleware Mistakes That Will Crash Your API 🚨

Real confession: The first Express API I deployed lasted exactly 47 minutes in production before it crashed and burned. The culprit? A single middleware function that looked perfectly innocent in my code editor but turned into a memory-eating monster under load! 😱

When I was building Node.js APIs at Acodez, I thought middleware was the easy part. "It's just app.use() and some functions, right?" WRONG. Middleware is where most production bugs hide, waiting to ruin your weekend!

Coming from Laravel where middleware is pretty straightforward, Express taught me some painful lessons. Let me save you from the 3 AM emergency deploys I had to do!

What Even Is Express Middleware? 🤔

Middleware = Functions that run between receiving a request and sending a response.

Think of it like airport security:

  • Request comes in → "Welcome to the airport!"
  • Middleware #1: Check passport (authentication)
  • Middleware #2: Scan bags (input validation)
  • Middleware #3: Security questions (authorization)
  • Controller: "Boarding pass issued!"
  • Response sent → "Have a nice flight!"

The power: Chain multiple functions to handle cross-cutting concerns (auth, logging, parsing, etc.)

The danger: One broken middleware function can crash your ENTIRE server. No pressure! 🔥

Mistake #1: Forgetting to Call next() (The Silent Killer) 💀

The most common mistake that WILL haunt you:

// DON'T DO THIS!
app.use((req, res, next) => {
    console.log('Request received:', req.method, req.path);
    // Missing next()! Request hangs FOREVER!
});

app.get('/api/users', (req, res) => {
    res.json({ users: [] }); // This NEVER runs!
});

What happens:

  1. Request comes in
  2. Your logger runs
  3. Request just... sits there
  4. Client waits... and waits... and waits
  5. Eventually times out
  6. Users think your API is down
  7. You get angry Slack messages

How I discovered this at Acodez:

# Testing the API
curl http://localhost:3000/api/users

# *waits 30 seconds*
# *waits 60 seconds*
# curl: (52) Empty reply from server

# Me: "WHY ISN'T THIS WORKING?!"
# *Checks logs*
# "Request received: GET /api/users"
# ... nothing else

# *Facepalm*

The fix - ALWAYS call next() or send a response:

// GOOD: Calls next()
app.use((req, res, next) => {
    console.log('Request received:', req.method, req.path);
    next(); // Pass control to the next middleware!
});

// ALSO GOOD: Sends response (terminates chain)
app.use('/health', (req, res) => {
    res.json({ status: 'ok' }); // No next() needed - response sent!
});

// GOOD: Conditional logic
app.use((req, res, next) => {
    if (req.path === '/blocked') {
        return res.status(403).json({ error: 'Forbidden' });
    }
    next(); // Continue for other routes
});

Pro tip: Use return before res.send() to prevent accidentally calling next() after sending a response!

// BAD: Might call next() after sending response
app.use((req, res, next) => {
    if (someCondition) {
        res.json({ error: 'Bad request' });
        next(); // OOPS! Called after response sent!
    }
});

// GOOD: return prevents further execution
app.use((req, res, next) => {
    if (someCondition) {
        return res.json({ error: 'Bad request' });
    }
    next();
});

Mistake #2: Middleware Order Matters (And Will Bite You) 🔄

The nightmare scenario I created:

// BAD ORDER - This crashes!
app.use('/api/users', userRoutes); // Uses req.body
app.use(express.json()); // Parses JSON... but AFTER the route!

// Result: req.body is undefined in userRoutes
// POST requests fail silently!

What happened in production:

# Client sends:
POST /api/users
Content-Type: application/json
{"name": "John", "email": "[email protected]"}

# Server receives:
req.body = undefined

# Code tries to access:
const { name, email } = req.body; // Destructuring undefined!
// TypeError: Cannot destructure property 'name' of 'undefined'

# Server crashes! 💥

The correct order - Body parsers BEFORE routes:

// CORRECT ORDER!
const express = require('express');
const app = express();

// 1. Body parsers (parse incoming data)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 2. CORS (if needed)
app.use(cors());

// 3. Logging
app.use(morgan('combined'));

// 4. Authentication
app.use(authMiddleware);

// 5. Routes (use the parsed data)
app.use('/api/users', userRoutes);
app.use('/api/posts', postRoutes);

// 6. Error handlers (LAST!)
app.use(errorHandler);

Think of it as layers of an onion:

  1. Parse the data (body parsers)
  2. Add context (auth, logging)
  3. Route to handlers
  4. Catch errors (error middleware)

Coming from Laravel: In Laravel, middleware order is explicit in Kernel.php. In Express, it's the ORDER YOU CALL app.use()! Easy to mess up!

Mistake #3: Async Middleware Without Error Handling 💣

The production disaster waiting to happen:

// DANGEROUS! Async without try/catch
app.use(async (req, res, next) => {
    const user = await db.findUser(req.headers.authorization);
    req.user = user;
    next();
});

// What happens when db.findUser() throws?
// Unhandled promise rejection!
// Server crashes or hangs!

Real crash I caused at Acodez:

// My "brilliant" auth middleware
app.use(async (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(decoded.userId);
    req.user = user;
    next();
});

// What happened:
// 1. Someone sent request without auth header
// 2. token = undefined
// 3. jwt.verify(undefined) throws error
// 4. Unhandled promise rejection
// 5. Server crashes
// 6. PagerDuty wakes me up at 2 AM
// 7. I update my LinkedIn 😅

The proper fix - Wrap async middleware:

// Option 1: Manual try/catch (tedious but clear)
app.use(async (req, res, next) => {
    try {
        const token = req.headers.authorization?.split(' ')[1];
        if (!token) {
            return res.status(401).json({ error: 'No token provided' });
        }

        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        const user = await User.findById(decoded.userId);

        if (!user) {
            return res.status(401).json({ error: 'User not found' });
        }

        req.user = user;
        next();
    } catch (error) {
        console.error('Auth middleware error:', error);
        res.status(401).json({ error: 'Invalid token' });
    }
});

// Option 2: Async handler wrapper (DRY!)
const asyncHandler = (fn) => (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
};

// Use it like this:
app.use(asyncHandler(async (req, res, next) => {
    const user = await db.findUser(req.headers.authorization);
    req.user = user;
    next();
}));

// Option 3: Use express-async-errors (easiest!)
require('express-async-errors');

// Now async errors automatically get caught!
app.use(async (req, res, next) => {
    const user = await db.findUser(req.headers.authorization);
    req.user = user;
    next();
});

A pattern I use in production:

// utils/asyncHandler.js
module.exports = (fn) => (req, res, next) => {
    Promise.resolve(fn(req, res, next))
        .catch((error) => {
            console.error('Async middleware error:', error);
            next(error); // Pass to error handler
        });
};

// Usage in middleware:
const asyncHandler = require('./utils/asyncHandler');

app.use(asyncHandler(async (req, res, next) => {
    // Your async code here - errors automatically caught!
    const data = await someAsyncOperation();
    req.data = data;
    next();
}));

Mistake #4: Memory Leaks in Middleware 🧠💧

The subtle killer that took down our production server:

// BAD: Creates memory leak!
const requestCache = {}; // Global object that grows FOREVER

app.use((req, res, next) => {
    const key = `${req.method}:${req.path}`;
    requestCache[key] = Date.now(); // Never cleaned up!
    next();
});

// After 1 million requests:
// requestCache has 1 million entries
// Memory usage: 500MB and growing
// Server eventually crashes: "Out of memory"

How I discovered this:

# Day 1: Server memory usage: 200MB - Normal!
# Day 2: Server memory usage: 450MB - Hmm, odd
# Day 3: Server memory usage: 800MB - Getting worried
# Day 4: Server memory usage: 1.2GB - WTF?!
# Day 5: "Error: JavaScript heap out of memory"
# Server crashes. Production down. Boss angry.

The fix - Clean up or use LRU cache:

// Option 1: Use LRU cache (Least Recently Used)
const LRU = require('lru-cache');

const requestCache = new LRU({
    max: 1000, // Maximum 1000 entries
    ttl: 1000 * 60 * 5 // 5 minutes TTL
});

app.use((req, res, next) => {
    const key = `${req.method}:${req.path}`;
    requestCache.set(key, Date.now()); // Old entries auto-deleted!
    next();
});

// Option 2: Periodic cleanup
const requestLog = new Map();

setInterval(() => {
    const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
    for (const [key, timestamp] of requestLog.entries()) {
        if (timestamp < fiveMinutesAgo) {
            requestLog.delete(key);
        }
    }
}, 60 * 1000); // Clean every minute

app.use((req, res, next) => {
    requestLog.set(`${req.method}:${req.path}`, Date.now());
    next();
});

// Option 3: Just don't cache in middleware!
// Use Redis or a proper caching layer instead

Pro tip: Use process.memoryUsage() to monitor memory in development!

// Log memory usage every 10 seconds
setInterval(() => {
    const used = process.memoryUsage();
    console.log('Memory:', {
        rss: `${Math.round(used.rss / 1024 / 1024)}MB`,
        heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`
    });
}, 10000);

Mistake #5: Blocking the Event Loop in Middleware ⏰

The performance killer:

// NEVER DO THIS IN MIDDLEWARE!
app.use((req, res, next) => {
    // Synchronous CPU-intensive task
    const hash = crypto.pbkdf2Sync(
        'password',
        'salt',
        100000, // 100k iterations
        64,
        'sha512'
    ); // Takes 500ms - BLOCKS THE ENTIRE SERVER!

    req.hash = hash;
    next();
});

What happens:

  1. Request 1 comes in
  2. Middleware starts hashing (500ms of CPU work)
  3. Request 2 comes in... but waits (blocked!)
  4. Request 3 comes in... waits
  5. Request 100 comes in... still waiting
  6. All requests queued up, server appears "frozen"
  7. Users angry, boss angrier

The fix - Use async versions or worker threads:

// GOOD: Async version
app.use(async (req, res, next) => {
    try {
        const hash = await new Promise((resolve, reject) => {
            crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, key) => {
                if (err) reject(err);
                else resolve(key);
            });
        });
        req.hash = hash;
        next();
    } catch (error) {
        next(error);
    }
});

// EVEN BETTER: Don't do heavy work in middleware!
// Move it to a background job or worker thread

Coming from Laravel/PHP: In PHP-FPM, blocking one request doesn't block others (separate processes). In Node.js, you have ONE event loop - don't block it! 🚫

Mistake #6: Error Middleware in Wrong Place 🎯

The confusing part of Express:

// BAD: Error handler in the wrong place
app.use((err, req, res, next) => {
    res.status(500).json({ error: err.message });
});

app.use('/api/users', userRoutes); // Errors here won't be caught!

// Error handlers MUST come LAST!

The correct order:

// 1. Regular middleware
app.use(express.json());
app.use(authMiddleware);

// 2. Routes
app.use('/api/users', userRoutes);
app.use('/api/posts', postRoutes);

// 3. 404 handler (no route matched)
app.use((req, res) => {
    res.status(404).json({ error: 'Route not found' });
});

// 4. Error handler (MUST have 4 parameters!)
app.use((err, req, res, next) => {
    console.error('Error:', err);
    res.status(err.status || 500).json({
        error: process.env.NODE_ENV === 'production'
            ? 'Internal server error'
            : err.message
    });
});

CRITICAL: Error middleware MUST have 4 parameters (err, req, res, next) or Express won't recognize it!

// WRONG: Only 3 parameters
app.use((err, req, res) => { // Express won't call this!
    res.status(500).json({ error: err.message });
});

// RIGHT: 4 parameters
app.use((err, req, res, next) => { // Express recognizes this!
    res.status(500).json({ error: err.message });
});

Mistake #7: Not Validating Input (Security Nightmare) 🔐

The attack I almost shipped to production:

// DANGEROUS: No validation!
app.post('/api/users', async (req, res) => {
    const user = await User.create(req.body); // Yikes!
    res.json(user);
});

// Attacker sends:
POST /api/users
{
    "name": "Hacker",
    "email": "[email protected]",
    "role": "admin", // Oops! Privilege escalation!
    "isVerified": true // Skipped email verification!
}

The fix - Validation middleware:

// Using express-validator (my favorite!)
const { body, validationResult } = require('express-validator');

const validateUser = [
    body('name').trim().notEmpty().isLength({ min: 2, max: 50 }),
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 }),
    (req, res, next) => {
        const errors = validationResult(req);
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        next();
    }
];

app.post('/api/users', validateUser, async (req, res) => {
    // Only validated fields allowed
    const { name, email, password } = req.body;
    const user = await User.create({ name, email, password });
    res.json(user);
});

Or use Joi for complex validation:

const Joi = require('joi');

const validateRequest = (schema) => (req, res, next) => {
    const { error, value } = schema.validate(req.body);
    if (error) {
        return res.status(400).json({ error: error.details[0].message });
    }
    req.body = value; // Use validated value
    next();
};

const userSchema = Joi.object({
    name: Joi.string().min(2).max(50).required(),
    email: Joi.string().email().required(),
    password: Joi.string().min(8).required()
});

app.post('/api/users', validateRequest(userSchema), async (req, res) => {
    // Safe to use req.body now!
    const user = await User.create(req.body);
    res.json(user);
});

Common Middleware Patterns I Use in Production 🎯

Pattern #1: Request ID Tracking

const { v4: uuidv4 } = require('uuid');

app.use((req, res, next) => {
    req.id = uuidv4();
    res.setHeader('X-Request-ID', req.id);
    next();
});

// Now all logs can include req.id for tracing!
console.log(`[${req.id}] Processing request`);

Pattern #2: Timeout Protection

const timeout = (ms) => (req, res, next) => {
    const timer = setTimeout(() => {
        res.status(504).json({ error: 'Request timeout' });
    }, ms);

    res.on('finish', () => clearTimeout(timer));
    next();
};

app.use(timeout(30000)); // 30 second timeout

Pattern #3: Rate Limiting

const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100, // Limit each IP to 100 requests per windowMs
    message: 'Too many requests, please try again later'
});

app.use('/api/', limiter);

Pattern #4: Request Logging

app.use((req, res, next) => {
    const start = Date.now();

    res.on('finish', () => {
        const duration = Date.now() - start;
        console.log({
            method: req.method,
            path: req.path,
            status: res.statusCode,
            duration: `${duration}ms`,
            userAgent: req.get('user-agent')
        });
    });

    next();
});

Your Express Middleware Checklist ✅

Before you deploy:

  • All middleware calls next() or sends a response
  • Body parsers come BEFORE routes
  • Async middleware wrapped in try/catch or asyncHandler
  • No memory leaks (no unbounded global objects)
  • No blocking CPU-intensive work
  • Error handler has 4 parameters and is LAST
  • Input validation on all user data
  • Request logging and tracing enabled
  • Rate limiting on public endpoints
  • Timeout protection on long-running operations

The Bottom Line

Express middleware is powerful but dangerous. One mistake can crash your entire server!

The essentials:

  1. ALWAYS call next() (or send a response)
  2. Order matters (parsers → auth → routes → error handler)
  3. Handle async errors (wrap in try/catch or use asyncHandler)
  4. Watch for memory leaks (clean up global state)
  5. Never block the event loop (no CPU-intensive sync work)
  6. Validate all inputs (never trust req.body)

When I was building Node.js APIs at Acodez, I learned: Express gives you freedom, but with freedom comes responsibility. Coming from Laravel where middleware is more structured, Express requires discipline. But once you get it right? It's incredibly fast and flexible! 🚀

Think of middleware as the layers of security at a concert - you need bouncers at the entrance (auth), bag checks (validation), wristbands (session), and emergency exits (error handling). Skip one layer and chaos ensues!


Got Express horror stories? Share them on LinkedIn - middleware bugs are the best war stories!

Want to see my Express projects? Check out my GitHub - all properly error-handled, I promise! 😉

P.S. - If you're not wrapping async middleware in try/catch, go fix that RIGHT NOW. Your future self will thank you at 3 AM! 🚨✨