Request lifecycle: một HTTP request đi qua đâu, và vì sao "quên gọi next()" làm request treo cho tới khi socket timeout
Một request tới một Express hay Fastify server không phải là "gọi handler rồi trả về response". Nó là một chuỗi các bước theo thứ tự cố định: parse HTTP, chạy qua middleware/hook, match route, gọi handler, serialize, gửi response, đóng. Nếu bất kỳ bước nào không chuyển tiếp — Express không gọi next(), Fastify hook không reply.send() hay không return — request treo cho tới khi client hoặc server timeout đóng socket. Đây là loại lỗi không ném exception, không xuất hiện trong error log, chỉ hiện ra qua p99 latency phình lên và số socket ở trạng thái ESTABLISHED tăng dần. Hiểu chính xác lifecycle này là điều kiện tiên quyết để debug những request "biến mất" và để đặt middleware đúng thứ tự.
Cơ chế hoạt động
Bước dưới cùng giống nhau ở cả hai framework: Node core http.Server nhận TCP connection, parse HTTP request line + headers, phát event request với hai object IncomingMessage (req) và ServerResponse (res). Điểm khác nhau là những gì framework làm giữa lúc nhận request và lúc response ra khỏi socket.
Express dựng một chuỗi middleware qua Router. Mỗi lần app.use(fn) hay app.get(path, fn) được gọi, Express bọc fn vào một Layer với path regex (thông qua path-to-regexp) rồi push vào một stack. Khi request đến, Router.handle duyệt stack tuần tự: với mỗi layer, nếu path match, gọi fn(req, res, next). next() là closure trỏ vào layer kế tiếp; chỉ khi nó được gọi thì layer sau mới chạy. Error handler được nhận diện bằng arity — hàm 4 tham số (err, req, res, next) — và chỉ được duyệt tới khi next(err) được gọi:
import express from 'express'
const app = express()
app.use(express.json({ limit: '1mb' })) // 1. parse body
app.use((req, _res, next) => { // 2. request id
req.id = crypto.randomUUID()
next()
})
app.use((req, _res, next) => { // 3. auth
const token = req.headers.authorization
if (!token) return next(new Error('unauth'))
next()
})
app.get('/orders/:id', async (req, res, next) => { // 4. route handler
try {
const order = await db.orders.findById(req.params.id)
if (!order) return res.status(404).json({ error: 'not_found' })
res.json(order)
} catch (err) { next(err) } // chuyển sang error handler
})
app.use((err, req, res, _next) => { // 5. error handler (arity 4)
res.status(500).json({ error: 'internal', id: req?.id })
})
Fastify thay chuỗi middleware phẳng bằng một pipeline hook có tên và thứ tự cố định, tài liệu ở Fastify docs mục "Lifecycle": onRequest → preParsing → preValidation → preHandler → handler → preSerialization → onSend → response gửi đi → onResponse. Nhánh lỗi rẽ qua onError. Route matching dùng find-my-way (radix trie) thay vì regex tuyến tính. Serialization dùng fast-json-stringify compile schema thành hàm serialize chuyên biệt, thay vì JSON.stringify chung:
import Fastify from 'fastify'
const app = Fastify({ logger: true })
app.addHook('onRequest', async (req) => { // trước khi body được parse
req.id = req.headers['x-request-id'] ?? crypto.randomUUID()
})
app.addHook('preHandler', async (req) => { // sau parse+validate, trước handler
if (!req.headers.authorization) throw app.httpErrors.unauthorized()
})
app.get('/orders/:id', {
schema: {
response: {
200: { type: 'object', properties: { id: { type: 'string' }, total: { type: 'number' } } }
}
}
}, async (req) => {
const order = await app.db.orders.findById(req.params.id)
if (!order) throw app.httpErrors.notFound()
return order // preSerialization -> onSend -> socket
})
Điểm chung: cả hai chỉ tiến sang bước kế tiếp khi bước hiện tại "kết thúc". Ở Express, "kết thúc" nghĩa là next() hoặc gửi response. Ở Fastify async, "kết thúc" nghĩa là promise resolve/reject. Nếu không xảy ra sự kiện đó, pipeline đứng im.
Vấn đề gặp trong production
Failure mode 1: quên next() — request treo tới timeout. Middleware chạy xong phần logic nhưng không gọi next() và cũng không gửi response — chuỗi dừng lại giữa chừng:
// SAI: async middleware "trông có vẻ đủ" nhưng thiếu next()
app.use(async (req, _res) => {
const user = await verifyToken(req.headers.authorization)
req.user = user
// quên next() — không có exception, không có log, request đứng im
})
Không có lỗi nào ném. Client thấy request đang chờ. Sau vài chục giây, hoặc keepAliveTimeout hoặc client-side timeout kết thúc, socket đóng ở trạng thái RST/FIN. Log của server không thấy gì vì không middleware nào tới bước gửi response. Cách sửa là luôn kết thúc middleware bằng một trong ba nhánh rõ ràng: gọi next(), next(err), hoặc gửi response.
// ĐÚNG: bọc async middleware để lỗi và tiếp nối đều chuyển tiếp đúng
const asyncMw = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.use(asyncMw(async (req, _res, next) => {
req.user = await verifyToken(req.headers.authorization)
next()
}))
Express 5 xử lý phần này gọn hơn: nếu handler là async, promise reject sẽ tự bị bắt và forward vào error chain. Trên Express 4 (vẫn đang chạy ở phần lớn service), phải tự bọc như trên.
Failure mode 2: sai thứ tự middleware. Middleware là stack có thứ tự — thứ tự app.use(...) chính là thứ tự chạy. Đặt error handler trước route sẽ khiến nó không bao giờ được gọi (vì next(err) đi xuôi chứ không lùi). Đặt logger sau body parser sẽ khiến logger không thấy được request nào bị reject ở tầng parse. Đặt auth sau route handler thì route đã chạy trước khi auth chặn:
// SAI: route đăng ký trước auth => endpoint mở toang
app.get('/admin/users', listUsers)
app.use(authMiddleware)
// ĐÚNG: middleware nào áp cho route nào phải nằm TRƯỚC route đó trong stack
app.use(authMiddleware)
app.get('/admin/users', listUsers)
Failure mode 3: gửi response hai lần — ERR_HTTP_HEADERS_SENT. Sau khi res.json() hay res.send() chạy, headers đã đi ra socket. Bất kỳ lần ghi tiếp nào đều ném lỗi này. Kịch bản thường gặp: một nhánh if sớm đã res.send, code phía sau vẫn tiếp tục và gọi res.send lần nữa vì thiếu return:
// SAI: thiếu return sau res.status(404).json(...)
app.get('/orders/:id', async (req, res, next) => {
const order = await db.orders.findById(req.params.id)
if (!order) res.status(404).json({ error: 'not_found' }) // không return
res.json(order) // chạy tiếp, ném ERR_HTTP_HEADERS_SENT + gửi payload sai
})
Sửa: luôn return res.json(...) hoặc dùng else. Trong Fastify, mô hình return payload từ handler tránh được lớp lỗi này vì reply được commit bởi framework một lần duy nhất.
Failure mode 4: body parser limit + slow client. express.json({ limit: '1mb' }) mặc định ném 413 Payload Too Large khi vượt limit, nhưng nếu không đặt req.setTimeout hoặc server.requestTimeout, một client chậm có thể giữ socket mở, upload nhỏ giọt và ăn RAM. Fastify có bodyLimit (mặc định ~1MB, theo Fastify docs) và tôn trọng server.requestTimeout của Node core. Với upload thực tế, đặt cả bodyLimit và một timeout dứt khoát ở tầng server.
Cách debug và monitor
Triệu chứng "request biến mất" cần được phân biệt với "request lỗi". Log tầng access chỉ ghi khi response gửi đi; nếu request treo giữa middleware, access log không có dòng nào. Bốn cách tiếp cận:
-
Log từng bước lifecycle. Ở Express, thêm một middleware đầu chuỗi ghi
req.id, timestamp bắt đầu; và một hook cuối gắn vàores.on('finish')+res.on('close')để phân biệt response gửi thành công (finish) và socket đóng khi chưa xong (closemàres.writableEnded === falselà request bị hủy giữa chừng). Ở Fastify, bậtlogger: true— nó dùngpinovà tự log mỗi request + response vớireqId,responseTime. Với các hook trung gian, dùngonRequest/onResponseđể đo chênh lệch thời gian. -
Timeout dứt khoát. Đặt
server.requestTimeoutvàserver.headersTimeoutở tầnghttp.Server(theo Node docshttp.Server;requestTimeoutcó mặc định khác 0 từ Node 18). Không dựa vào client timeout — curl mặc định không timeout. -
Tracing. OpenTelemetry có
@opentelemetry/instrumentation-expressvà@opentelemetry/instrumentation-fastify(theo OpenTelemetry docs mục JavaScript instrumentation). Chúng bọc từng middleware/hook thành span con, nên khi request treo, trace hiện ra chính span đang mở — biết ngay middleware nào là thủ phạm mà không phải rảiconsole.log. -
Metric. Histogram HTTP request duration theo route + status (
http.server.request.durationlà tên theo OpenTelemetry semantic conventions cho HTTP). Alert khi p99 tăng đột biến hoặc khi số request không có response (không match cặp start/finish) khác 0. Kèm gauge số socket đang mở quaserver.getConnections— nếu con số này leo lên trong khi RPS không tăng, có request đang giữ socket.
Rule phòng ngừa: (1) middleware phải luôn kết thúc rõ ràng — hoặc next(), hoặc gửi response, hoặc throw/next(err); (2) return res.send(...) chứ không viết res.send(...) không return; (3) mọi server production đều đặt requestTimeout và headersTimeout khớp với LB phía trước; (4) route đăng ký sau khi mọi middleware toàn cục đã app.use.
Tradeoff
Express linh hoạt vì middleware là hàm arbitrary — có thể chèn bất cứ gì vào bất cứ chỗ nào trong stack. Cái giá là không có ranh giới rõ giữa "trước parse" và "sau validate": một middleware Express về nguyên tắc chạy ở mọi thời điểm trước handler, người viết tự phải giữ thứ tự đúng. Không có schema-based serialization, nên serialize dùng JSON.stringify chung — chậm hơn khi payload lớn. Router dựa trên regex, chi phí tuyến tính theo số route được đăng ký.
Fastify chạy nhanh hơn ở workload JSON điển hình — theo benchmark repo fastify/benchmarks do maintainer Fastify duy trì, throughput trên các route JSON đơn giản cao hơn Express đáng kể (nhiều lần trên cùng phần cứng, cùng payload). Ba nguyên nhân, đều là quyết định thiết kế: (a) find-my-way radix trie router, tra route ở chi phí gần hằng số theo độ dài path chứ không theo số route; (b) fast-json-stringify compile schema thành hàm serialize chuyên biệt, tránh reflection và loại field không có trong schema; (c) pipeline hook cố định giúp Fastify pre-compile chuỗi call, không phải duyệt stack động mỗi request. Đổi lại: schema-based có nghĩa payload phải có schema (hoặc chấp nhận rơi về JSON.stringify chung); mô hình hook cố định khó "chèn ngang" hơn middleware phẳng của Express; ecosystem plugin nhỏ hơn.
Rule of thumb: chọn Express khi ưu tiên độ quen thuộc, plugin ecosystem rộng, hoặc middleware bên thứ ba không có bản Fastify. Chọn Fastify khi payload JSON là hot path, RPS cao, hoặc muốn schema validation + typed reply có sẵn. Đừng chọn Fastify chỉ vì "nhanh hơn" trên một service RPS thấp — chênh lệch throughput ở dưới vài trăm RPS không thấy được, còn chi phí học/refactor thì thấy ngay.
Câu hỏi phỏng vấn
Một HTTP request đi qua những bước nào trong một Express app, và điều gì xảy ra nếu một middleware quên gọi
next()?
Node core http.Server parse HTTP request rồi phát event request với (req, res). Express đưa cặp này vào Router.handle, duyệt tuần tự stack các Layer đã đăng ký qua app.use/app.METHOD. Mỗi layer match path sẽ được gọi (req, res, next); chỉ khi next() được gọi mới sang layer tiếp theo. next(err) nhảy tới middleware error handler gần nhất (nhận diện bằng arity 4: err, req, res, next). Khi tới route handler, nó gửi response qua res.json/res.send; response ghi xong headers rồi body, event finish phát ra và socket có thể quay lại pool keep-alive. Nếu một middleware quên next() và cũng không gửi response, pipeline dừng lại: không có exception, không có log access, request treo cho tới khi server.requestTimeout/headersTimeout hoặc client-side timeout đóng socket. Điểm ăn điểm là gọi tên hậu quả production — p99 latency phình lên nhưng error log không thấy gì, số socket ESTABLISHED tăng dần — và cách phòng: bọc async middleware để reject tự forward qua next, luôn return res.send(...), đặt requestTimeout ở tầng http.Server khớp với LB, dùng OpenTelemetry instrumentation để trace phơi ra chính hook/middleware đang mở khi request treo.
Hands-on
Dựng hai server tối thiểu trong cùng repo và làm chúng vỡ theo đúng failure mode ở trên, rồi sửa.
Chuẩn bị:
mkdir req-lifecycle && cd req-lifecycle
npm init -y
npm pkg set type=module
npm i express fastify pino
npm i -D autocannon
Tạo express-server.mjs:
import express from 'express'
import crypto from 'node:crypto'
const app = express()
app.use(express.json({ limit: '1mb' }))
// LOG mỗi bước lifecycle
app.use((req, res, next) => {
req.id = crypto.randomUUID()
req._t = process.hrtime.bigint()
console.log(JSON.stringify({ id: req.id, step: 'in', url: req.url }))
res.on('finish', () => console.log(JSON.stringify({ id: req.id, step: 'finish' })))
res.on('close', () => {
if (!res.writableEnded) console.log(JSON.stringify({ id: req.id, step: 'aborted' }))
})
next()
})
// Middleware CỐ Ý QUÊN next() — tái hiện request treo
app.use('/broken', (req, _res /*, next */) => {
console.log(JSON.stringify({ id: req.id, step: 'broken-mw-entered' }))
// không next(), không res.send => request treo
})
app.get('/ok', (req, res) => {
res.json({ id: req.id, ok: true })
})
const server = app.listen(3001, () => console.log('express :3001'))
server.requestTimeout = 5000 // 5s: request treo sẽ bị cắt
server.headersTimeout = 6000
Tạo fastify-server.mjs:
import Fastify from 'fastify'
const app = Fastify({ logger: true, bodyLimit: 1_000_000 })
app.addHook('onRequest', async (req) => { req.log.info({ step: 'onRequest' }) })
app.addHook('preHandler', async (req) => { req.log.info({ step: 'preHandler' }) })
app.addHook('onResponse', async (req, reply) => {
req.log.info({ step: 'onResponse', ms: reply.elapsedTime })
})
app.get('/ok', {
schema: { response: { 200: { type: 'object', properties: { ok: { type: 'boolean' } } } } }
}, async () => ({ ok: true }))
// Handler cố ý không return -> Fastify treo cho tới requestTimeout
app.get('/broken', async () => {
await new Promise(() => {}) // pending mãi mãi
})
await app.listen({ port: 3002 })
Chạy song song hai server và quan sát:
node express-server.mjs &
node fastify-server.mjs &
# 1) request bình thường: thấy log các step in / finish
curl -s http://localhost:3001/ok
curl -s http://localhost:3002/ok
# 2) request treo: curl đợi cho tới khi server cắt tại requestTimeout=5s
time curl -sv http://localhost:3001/broken
time curl -sv http://localhost:3002/broken
# 3) bắn tải song song vào /ok trong lúc một request đang treo trên /broken —
# quan sát /ok vẫn phản hồi bình thường (event loop không bị block,
# chỉ có socket của /broken đang giữ)
npx autocannon -c 50 -d 10 http://localhost:3001/ok
npx autocannon -c 50 -d 10 http://localhost:3002/ok
Sau đó sửa /broken ở Express bằng cách thêm next() (hoặc gửi response), và ở Fastify bằng cách return {...} từ handler. Chạy lại time curl và xác nhận response trả về ngay, không còn phải chờ requestTimeout. Cuối cùng bật một OpenTelemetry SDK với @opentelemetry/instrumentation-express hoặc @opentelemetry/instrumentation-fastify (theo hướng dẫn ở OpenTelemetry docs), reproduce lại request treo và xem trace: span của middleware/hook đang treo hiện ra ở trạng thái chưa đóng — đây là cách khoanh vùng thủ phạm trong service thật mà không phải rải console.log.
Top comments (0)