Express.js middleware latency under concurrent load: configuring automatic offload of blocking work
0 reputation · 17 Jun 2021, 17:50 UTC
0 reputation · 17 Jun 2021, 17:50 UTC
Goal: Ensure that adding concurrent requests does not increase the latency introduced by CPU‑bound middleware in an Express application.
Constraint: Express executes each middleware synchronously on the Node.js event loop, and the framework does not provide a built‑in mechanism to offload such work to worker threads or to defer it automatically. Developers must decide whether to wrap blocking logic manually, use external libraries, or rely on timers like setImmediate, but the appropriate configuration boundary for automatic offloading remains unspecified.
Questions:
29275 reputation · 18 Jun 2021, 05:31 UTC
Express does not provide a built‑in configuration option to automatically move CPU‑bound middleware to worker threads. The only way to achieve automatic offloading for specific routes without changing the core framework is to wrap the handler in a middleware that delegates the work to a pooled worker thread (e.g., using the worker_threads API or a library such as workerpool).
Both setImmediate and process.nextTick merely schedule the callback for a later tick of the same event loop. The CPU‑bound work still runs on the main thread, so under concurrent load the latency grows linearly with the number of requests.
Create a worker pool at application start (size ≈ number of CPU cores). Example using workerpool:
const workerPool = require('workerpool');
const pool = workerPool.pool(require.resolve('./cpuWorker.js'), { maxWorkers: require('os').cpus().length });
Implement the worker (cpuWorker.js) that receives a serializable payload, performs the blocking computation, and returns the result:
// cpuWorker.js
function heavyTask(data) {
// CPU‑bound logic here
return result;
}
workerPool.worker({ heavyTask });
Write a route‑level wrapper middleware that offloads the original handler:
function asyncOffloadHandler(req, res, next) {
// Extract only serializable data needed by the worker
const payload = { /* … */ };
pool.exec('heavyTask', [payload])
.then(result => {
// Attach result to request or send response
req.offloadedResult = result;
next();
})
.catch(err => next(err));
}
// Usage
app.get('/heavy', asyncOffloadHandler, (req, res) => {
res.json({ result: req.offloadedResult });
});
Ensure that request/response objects are not transferred to the worker; only pass plain JSON‑serializable data.
To decide whether offloading is worthwhile, you need to know the typical CPU‑bound duration of your middleware. If the work consistently takes less than a millisecond, the overhead of worker threads may outweigh any benefit; otherwise, proceed with the pooled‑worker approach.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.