Express.js in Practice: Routes, Middleware, and the Stuff That Breaks

TL;DR

Express.js is a minimal Node.js framework that gives you routing and middleware without opinions. It’s perfect for building APIs and custom servers when you want control, but it requires discipline because there are no defaults. Learn how to structure Express apps, use middleware correctly, and avoid common patterns that scale terribly.

My team built an Express API that worked fine with 10 users. With 100 users it was slow. With 1000 users it crashed repeatedly. The problem wasn’t Express itself — it was that we used it like a library instead of a framework. No middleware for connection pooling, no rate limiting, no timeout handling, no error recovery. We just strung together endpoints that happened to work. Express didn’t fail us; we failed Express by ignoring the patterns that make it reliable. That was the moment I realized Express is deceptively simple on the surface but brutally unforgiving when you scale.

What Express Actually Is: A Minimal Foundation

Express.js is a lightweight Node.js web framework that handles HTTP routing and middleware, leaving everything else to you. It works by registering route handlers and middleware functions that process requests. The key benefit is control: you decide how authentication works, how errors are handled, how data is validated. No magic, no hidden behavior.

Here’s Express at its most basic:

import express from 'express';

const app = express();
app.use(express.json());

app.post('/users', (req, res) => {
  res.json({ message: 'User created' });
});

app.listen(3000, () => console.log('Server running'));

Seven lines of code. A working HTTP server. That simplicity is Express’s strength and its weakness. The strength is that you’re not fighting a framework. The weakness is that you have to build everything else yourself.

Middleware: The Heart of Express

Everything in Express is middleware. Middleware is just a function that receives a request, optionally modifies it, and passes it to the next middleware. This pattern is simple but powerful:

app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next(); // Pass to next middleware
});

app.use(express.json()); // Built-in middleware for parsing JSON

app.use(authenticateUser); // Custom middleware

app.post('/users', (req, res) => {
  res.json(req.user); // req.user was added by authenticateUser middleware
});

The `authenticateUser` middleware checks the request, adds data to `req`, then calls `next()`. Subsequent handlers see the modified request. This is how you compose functionality in Express — by stacking middleware.

Here’s a realistic middleware example that handles authentication:

async function authenticateUser(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    const user = await verifyToken(token);
    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Use it on routes that need it
app.post('/profile', authenticateUser, (req, res) => {
  res.json({ message: `Hello, ${req.user.name}` });
});

This pattern is elegant but requires discipline. Middleware runs in order. A missing `next()` call halts the chain. An unhandled error crashes the request. You have to think carefully about execution flow.

Routing: Simple Until Your App Gets Complex

Express routing is straightforward: match a method and path, execute a handler. But as your app grows, routing becomes a mess if you don’t structure it:

// Bad: all routes in one file
app.post('/users', createUser);
app.get('/users/:id', getUser);
app.put('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
app.post('/users/:id/posts', createPost);
app.get('/users/:id/posts', getUserPosts);
// ... 100 more routes

// Good: routes organized by resource
import userRoutes from './routes/users.js';
import postRoutes from './routes/posts.js';

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

// routes/users.js
const router = express.Router();
router.post('/', createUser);
router.get('/:id', getUser);
router.put('/:id', updateUser);
router.delete('/:id', deleteUser);
export default router;

Using Express Routers lets you modularize your API. Each resource gets its own router file. Main app just mounts them. This scales to hundreds of endpoints without becoming a monster file.

Error Handling: Where Express Apps Fail

Express has no built-in error handling strategy. You have to build it yourself, and most people get it wrong. Errors in route handlers go uncaught. Unhandled promise rejections crash the server. It’s anarchy until you implement proper error handling:

// Bad: unhandled rejection
app.post('/users', async (req, res) => {
  const user = await db.users.create(req.body);
  res.json(user);
  // If db.users.create() rejects, the promise rejection is unhandled!
});

// Better: explicit try/catch
app.post('/users', async (req, res) => {
  try {
    const user = await db.users.create(req.body);
    res.json(user);
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Best: centralized error handling
app.post('/users', async (req, res, next) => {
  try {
    const user = await db.users.create(req.body);
    res.json(user);
  } catch (error) {
    next(error); // Pass to error handler middleware
  }
});

// Error handler middleware (must be last)
app.use((error, req, res, next) => {
  console.error(error);
  res.status(500).json({ error: 'Internal server error' });
});

The error handler middleware must be registered last, and it must have four parameters `(error, req, res, next)` for Express to recognize it. This is a gotcha that trips up beginners.

Request Validation: Express Makes You Do It

Express doesn’t validate incoming data. You have to do it yourself using a library like `joi` or `zod`:

import { z } from 'zod';

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().positive()
});

app.post('/users', (req, res, next) => {
  try {
    const validData = createUserSchema.parse(req.body);
    // validData is type-safe and validated
    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: error.errors });
  }
});

This is tedious but necessary. Without validation, bad data flows through your system. Hackers send malformed data specifically to exploit this. Django and Rails validate by default. Express makes you remember.

Database Connections: The Silent Killer

Beginners often create a new database connection for every request:

// Terrible: new connection per request
app.get('/user/:id', async (req, res) => {
  const connection = mysql.createConnection(config);
  const user = await connection.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
  connection.end();
  res.json(user);
});

At scale, this is catastrophic. Creating and tearing down connections is expensive. You’ll run out of file descriptors. Your database will reject connections. Use a connection pool instead:

// Create pool once at startup
const pool = mysql.createPool(config);

app.get('/user/:id', async (req, res) => {
  const connection = await pool.getConnection();
  try {
    const user = await connection.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
    res.json(user);
  } finally {
    connection.release(); // Return to pool
  }
});

Connection pooling is non-negotiable in production Express apps. It’s also easy to forget when you’re learning.

When NOT to Use Express

Don’t use Express if you want a complete framework with batteries included. Django, Rails, and Nest.js have built-in solutions for validation, ORM, authentication, and more. Express gives you freedom, which is burden if you want simplicity.

Don’t use Express if you don’t have Node.js expertise. Express requires understanding the Node.js event loop, async/await, middleware chaining, and error handling. A beginner will write insecure, inefficient code. Django or Flask are more forgiving for newcomers.

Don’t use Express if you need rapid prototyping. You’ll spend time building infrastructure instead of features. Next.js, FastAPI, or Django are faster for getting an MVP out.

Common Mistakes: Middleware Order, Async Confusion, and Unhandled Rejections

The biggest mistake is middleware order. Middleware runs sequentially. Put error handling first and authentication handlers won’t work. It’s not obvious until it breaks production:

// Wrong: error handler runs before routes exist
app.use(errorHandler);
app.post('/users', createUser); // Errors from this won't reach the handler!

// Right: routes first, error handler last
app.post('/users', createUser);
app.use(errorHandler); // Catches errors from above

The second mistake is forgetting that middleware without `next()` stops the chain. You return a response or call next — never both, and always one:

// Wrong: calls next AND sends response
app.use((req, res, next) => {
  res.json({ message: 'hi' });
  next(); // req already sent a response!
});

// Right: return or next, not both
app.use((req, res, next) => {
  if (somethingBad) {
    return res.status(400).json({ error: 'Bad' });
  }
  next();
});

The third mistake is using callbacks for async operations then forgetting `.catch()`. Promise rejections go unhandled and crash the server.

Express in 2026: Still Relevant, But Niche

Express dominated Node.js for a decade, but it’s aging. Newer frameworks like Fastify offer better performance with less configuration. Frameworks like Nest.js add structure that Express lacks. But Express is still the default choice if you understand its patterns and constraints.

I’d use Express for small APIs, custom servers, and projects where I need fine-grained control. For larger applications, I’d use Next.js, Fastify, or Nest.js. Express is best as a foundation for developers who know Node.js deeply, not as an entry point for beginners.

FAQ

Is Express still the best Node.js framework?

Express is the most popular, but not necessarily the best. Fastify is faster, Nest.js is more structured, Next.js is more complete. “Best” depends on your use case. Express is best if you want minimal abstraction and maximum control.

Can Express handle production traffic?

Yes, but you need proper infrastructure: connection pooling, error handling, rate limiting, health checks, monitoring, and multiple instances behind a load balancer. Express itself is not the bottleneck — your code is.

Should I use Express or Next.js?

Express for API-only backends where you want control. Next.js for full-stack applications where you want convenience. If you’re building both frontend and backend together, Next.js saves time. If you’re building just an API for external clients, Express is lighter.

How do I structure a large Express application?

Use routers for each resource, separate middleware into files, keep routes in separate folders, use a services layer for business logic, and a data access layer for database queries. Look at “express-starter” templates for reference patterns.

What’s the performance difference between Express and Fastify?

Fastify is significantly faster at high throughput (100,000+ requests per second). Express is adequate for most applications. Unless you’re building a system handling massive scale, the difference is academic. Developer productivity matters more than marginal performance gains.

Can I use Express with TypeScript?

Yes. TypeScript support in Express is solid. Use a type-safe validation library like zod for request validation, and you get type safety end-to-end. It requires setup but is worth the effort.

Is Express suitable for beginners?

Express is dangerous for beginners because it gives you rope to hang yourself. You can write insecure, inefficient code easily. If you’re new to backend development, start with Django or FastAPI. Once you understand databases, authentication, and error handling, Express is manageable.

Facebook
Twitter
LinkedIn
Pinterest

Leave a Reply

Your email address will not be published. Required fields are marked *

DevelopersCodex

Real-world dev tutorials. No fluff, no filler.

© 2026 DevelopersCodex. All rights reserved.