The Request-Response Cycle
When a request hits your Express server, it follows a well-defined path through the application:
Express App
│
┌───────┴────────┐
│ Request In │
└───────┬────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
Middleware 1 Middleware 2 Middleware 3
│ │ │
└────────────┼────────────┘
│ next()
▼
┌───────────────┐
│ Route Match │
└───────┬───────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
Handler A Handler B Handler C
│
▼ res.send()
┌───────────────┐
│ Response Out │
└───────────────┘
Step by Step: What Happens on Every Request
1. Request Arrives
// A client sends: GET /api/users?sort=name HTTP/1.1
// Host: localhost:3000
// Authorization: Bearer <token>
Express wraps the Node.js http.IncomingMessage into the req object and http.ServerResponse into res.
2. Global Middleware Executes (First In, First Out)
Middleware registered with app.use() runs on every request first:
// Order 1: Security headers
app.use(helmet());
// Order 2: Logging
app.use(morgan('dev'));
// Order 3: Body parsing
app.use(express.json());
// Order 4: URL-encoded parsing
app.use(express.urlencoded({ extended: true }));
// Order 5: CORS
app.use(cors());
3. Path-Specific Middleware
Middleware registered with a path prefix runs when the URL matches:
// Runs only on /api routes
app.use('/api', (req, res, next) => {
req.apiVersion = 'v1';
next();
});
// Runs only on /admin routes
app.use('/admin', authenticate);
4. Route Matching & Method Selection
Express matches the URL pattern and HTTP method:
// GET /api/users?sort=name
// Express checks each registered route in order:
app.get('/api/users', handler); // ✅ Matches - runs handler
app.post('/api/users', handler); // ❌ Wrong method - skip
app.get('/api/users/:id', handler); // ❌ Wrong path - skip
app.put('/api/users', handler); // ❌ Wrong method - skip
5. Route Handler Executes
The matching route handler runs. It can use req and res to send a response:
app.get('/api/users', (req, res) => {
// req.query → { sort: 'name' }
// req.method → 'GET'
// req.path → '/api/users'
const users = getUsers({ sort: req.query.sort });
res.json(users); // ← Response is sent
});
6. Response is Sent
After res.json(), res.send(), or res.end() is called, Express sends the response and the cycle ends.
The Onion Model
Express middleware follows an onion/stack model - code before next() runs inbound, code after next() runs outbound:
app.use((req, res, next) => {
console.log('1 - Before next()');
next();
console.log('1 - After next()');
});
app.use((req, res, next) => {
console.log('2 - Before next()');
next();
console.log('2 - After next()');
});
app.get('/', (req, res) => {
console.log('3 - Handler');
res.send('Hello');
});
// Console output on GET /:
// 1 - Before next()
// 2 - Before next()
// 3 - Handler
// 2 - After next()
// 1 - After next()
Request → [M1 → next → [M2 → next → [Handler] → back] → back] → Response
▲ ▲ ▼ ▼ ▼
inbound inbound response outbound outbound
This pattern is useful for:
- Timing - start a timer before
next(), stop it after - Logging - log request before
next(), log status after - Modifying headers - set headers before
next(), add final headers after - Transactions - open DB transaction before
next(), commit/rollback after
What Happens When No Route Matches
If Express goes through all middleware and route handlers without finding a match:
// Request: GET /nonexistent
app.get('/users', handler); // ❌ Path doesn't match
app.post('/api', handler); // ❌ Path doesn't match
// ... all routes checked, none match
// Falls through to 404 handler:
app.use((req, res) => {
res.status(404).json({
error: 'Not Found',
path: req.originalUrl
});
});
What Happens When an Error is Thrown
When next(err) is called or an error is thrown:
app.get('/risky', (req, res, next) => {
next(new Error('DB connection failed'));
// or: throw new Error('DB connection failed');
});
// All regular middleware is skipped
app.use((req, res, next) => {
// This does NOT run for errored requests
next();
});
// Express looks for the next error handler
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: 'Internal server error' });
});
Routing Layers & Middleware Stack
Express internally maintains a stack (array) of layers. Each layer is either a route or middleware:
const app = express();
app.use(logger); // Layer 1: middleware (no path)
app.use('/api', apiMiddleware); // Layer 2: middleware at /api
app.get('/api/users', listUsers); // Layer 3: route GET /api/users
app.use(errorHandler); // Layer 4: error middleware
Express iterates through this stack sequentially:
- For regular middleware: always executes
- For path-scoped middleware: executes if path matches
- For route middleware: executes if path AND method match
- For error middleware: only executes if
next(err)was called
Request Object Lifecycle
The req object is built progressively as it passes through middleware:
// Middleware 1: Parse body
app.use(express.json());
// req.body is now populated
// Middleware 2: Authenticate
app.use((req, res, next) => {
req.user = { id: 1, role: 'admin' };
next();
});
// Middleware 3: Attach permissions
app.use((req, res, next) => {
req.can = (action) => {
return req.user.role === 'admin' || req.permissions.includes(action);
};
next();
});
// Route handler - has access to everything
app.get('/admin/users', (req, res) => {
console.log(req.body); // from middleware 1
console.log(req.user); // from middleware 2
console.log(req.can); // from middleware 3
res.json({ ok: true });
});
Chaining Multiple Handlers Per Route
You can pass multiple handler functions to a single route:
// Inline
app.get('/profile',
authenticate,
loadProfile,
(req, res) => {
res.json(req.profile);
}
);
// As array
const handlers = [authenticate, loadProfile, sendProfile];
app.get('/profile', handlers);
// Mixed
app.get('/profile', authenticate, [loadProfile, sendProfile]);
request and response Lifecycle Diagram
┌──────────────────────────────────────────────────────────────────┐
│ │
│ REQUEST ENTERS │
│ │ │
│ ▼ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐ │
│ │parse │ │auth │ │log │ │route:get │ │send │ │
│ │body │──│check │──│req │──│/api/users │──│json │ │
│ └──────┘ └──────┘ └──────┘ └──────────┘ └──────┘ │
│ │ │ │ │ │ │
│ │ next() │ next() │ next() │ res.json │ │
│ ▼ ▼ ▼ │ │ │
│ │ │ │
│ on('finish') event fires ───────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ {log response, clean up} RESPONSE LEAVES │
│ │
└──────────────────────────────────────────────────────────────────┘
Understanding next() Behavior
function middleware(req, res, next) {
// Call next() to pass to next matching layer
next();
// Call next('route') to skip to the next route handler
// (only in app.METHOD / router.METHOD, not in app.use)
next('route');
// Call next(err) to skip to error handler
next(new Error('fail'));
// Don't call anything - request hangs until timeout
// (never do this accidentally)
}
Debugging the Lifecycle
Use Express’s built-in debugger to trace middleware execution:
DEBUG=express:* node index.js
This shows which layers are matched, skipped, and executed:
express:router:route new / +0ms
express:router:layer new /api +1ms
express:router:route get /api/users +2ms
express:router:layer new /api/users +0ms
express:router:match found /api/users +1ms
express:router:trim /api/users (/api) /users +0ms
express:router:layer /api → dispatch +1ms
express:router:layer /api/users → dispatch +0ms
Key Takeaways
- Every request flows through middleware in registration order
next()passes control to the next matching layer- The onion model lets code run both before and after downstream handlers
reqis enriched progressively as it passes through middleware- Express’s internal layer stack powers the entire lifecycle
- No match → 404 handler; error thrown → error handler (skips normal middleware)
- Use
DEBUG=express:*to trace the exact lifecycle of any request