Promise That Never Settles
Requests hang when a Promise never resolves or rejects - missing callback, forgotten reject, abandoned DB client, or deadlock waiting on itself. In Express 5, the client times out while the server holds connections and pool slots.
Recipe
Quick-reference recipe card - copy-paste ready.
// BAD: manual Promise missing reject path
function fetchLegacy(): Promise<string> {
return new Promise((resolve) => {
legacyLib.getData((err: Error | null, data: string) => {
if (!err) resolve(data);
// err path never rejects - hangs forever on error
});
});
}
// GOOD: wrap with resolve + reject
function fetchLegacyFixed(): Promise<string> {
return new Promise((resolve, reject) => {
legacyLib.getData((err: Error | null, data: string) => {
if (err) reject(err);
else resolve(data);
});
});
}// Timeout wrapper
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
});
try {
return await Promise.race([p, timeout]);
} finally {
clearTimeout(timer!);
}
}When to reach for this:
- p95 latency spikes with flat CPU (requests waiting, not computing)
- Open connection count grows while RPS is steady
- Integration test hangs until Jest timeout
- After wrapping callback-style libraries in Promises
Working Example
import express from "express";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const app = express();
// BUG: client never released if query throws before release in wrong order
app.get("/orders-bug/:id", async (req, res) => {
const client = await pool.connect();
const result = await client.query("SELECT * FROM orders WHERE id = $1", [req.params.id]);
if (!result.rows[0]) {
// forgot client.release() on early return - pool exhausts over time
return res.status(404).json({ error: { code: "NOT_FOUND" } });
}
client.release();
res.json({ data: result.rows[0] });
});
// FIX: try/finally always releases
app.get("/orders/:id", async (req, res, next) => {
const client = await pool.connect();
try {
const result = await client.query("SELECT * FROM orders WHERE id = $1", [req.params.id]);
if (!result.rows[0]) return res.status(404).json({ error: { code: "NOT_FOUND" } });
res.json({ data: result.rows[0] });
} finally {
client.release();
}
});
// BUG: missing await - fire and forget, errors unhandled
app.post("/notify-bug", (req, res) => {
sendEmail(req.body.email); // returns Promise, not awaited
res.status(202).json({ accepted: true });
});
async function sendEmail(to: string) {
await new Promise((r) => setTimeout(r, 100));
if (!to.includes("@")) throw new Error("invalid email");
}
// FIX
app.post("/notify", async (req, res, next) => {
try {
await sendEmail(req.body.email);
res.status(202).json({ accepted: true });
} catch (err) {
next(err);
}
});What this demonstrates:
- Early return without
client.release()leaks pool connections try/finallyguarantees release even on 404- Missing
awaiton async route handlers causes unhandled rejections - Express 5 async errors need
next(err)or wrapper
Deep Dive
How It Works
- Promise settles fulfilled or rejected; otherwise
awaitblocks forever - Event loop stays alive if pending Promises or active handles exist
- pg pool
connect()waits when all clients checked out and never released - Express does not await returned Promises unless you use async middleware pattern with error propagation
Hang vs Slow
| Signal | Hang (never settles) | Slow (settles late) |
|---|---|---|
| CPU | Low | May be high |
| Pool | Slots never return | Slots return after delay |
| Trace | Flat line on one await | DB query span long |
Debugging Steps
1. node --inspect + pause during hang - inspect async stack
2. Log active pool: pool.totalCount, pool.idleCount, pool.waitingCount
3. trace_unhandled_rejections in staging
4. Add withTimeout around suspect external callsTypeScript Notes
// Express 5: async handler typing still needs explicit error forward
import type { RequestHandler } from "express";
const handler: RequestHandler = async (req, res, next) => {
try {
await work();
res.json({ ok: true });
} catch (e) {
next(e);
}
};Gotchas
- Promise constructor only resolve branch - Callback APIs that error silently hang. Fix: Always
reject(err). forEachwith async callback - Does not await; race and lost errors. Fix:for...ofwith await orPromise.all.- Transaction begin without commit/rollback - Connection held until timeout. Fix:
try { ... commit } catch { rollback } finally { release }. - Deadlock on same connection - Await query while transaction open on same client incorrectly interleaved. Fix: One sequential flow per client.
- Ignoring
pool.waitingCount- Metric hits 10+ while idleCount 0. Fix: Release clients; lower concurrency.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
util.promisify | Node core callback APIs | Complex multi-arg callbacks |
async/await only | Greenfield code | Legacy callback lib without promisify |
p-timeout package | Standard timeout helper | You prefer 10-line wrapper |
| Reactive streams | Complex event pipelines | Simple CRUD routes |
FAQs
Does Express 5 auto-catch async errors?
Improved from Express 4 but still use try/catch + next(err) or express-async-errors pattern for safety.
How to find which request hangs?
Log requestId at start and end of middleware chain; missing "end" log implicates route.
Fastify behavior?
Fastify awaits async handlers and maps rejections to error handler - still release DB clients in finally.
What is pool waitingCount?
Requests queued for a free client. Sustained > 0 means leak or pool too small.
Can AbortSignal help?
Yes - pass signal to fetch and DB drivers that support cancellation when client disconnects.
Promise.race pitfalls?
Losing slow request still runs unless aborted - combine with AbortController.
Jest open handles?
--detectOpenHandles finds timers and servers left open after tests.
graphql hangs?
Resolver awaiting another resolver on same dataloaders without batching - check N+1 and deadlock.
Worker pool promises?
Parent awaits worker.postMessage reply with timeout; terminate worker on hang.
Production mitigation?
HTTP server timeout + load balancer idle timeout + DB statement_timeout.
Related
- Debugging Basics - inspector attach
- Connection Pool Exhaustion - pool metrics
- Unhandled Rejection Production Outage - missing await fallout
- Timeouts Everywhere - boundary timeouts
- Error Handling in Express - async errors
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.