Reset email request hangs in Node: timeout budgets and diagnosis
Node's fetch waits minutes before giving up. Set a deadline with AbortSignal, split the timeout budget across layers, and tell a slow API apart from a slow mailbox.
If your POST /password-reset handler hangs, the mail API is rarely the thing that’s slow. Node’s built-in fetch ships without a request deadline — undici’s header and body timeouts are measured in minutes — so one stalled TCP connection parks a request handler until your platform’s own limit kills it. On Infrai the send route answered in roughly 700 ms in our testing and the free reads in under 100 ms, which means anything past a couple of seconds deserves a different explanation than “the provider is slow”.
Two questions get mixed together here, and they have different answers. “My request is hanging” is a client-side deadline problem you fix in about four lines. “The email arrived nine minutes late” is a delivery-path question the API can help you answer, but can’t be blamed for.
Give every call a deadline
AbortSignal.timeout() is the whole fix for the first problem:
// send-reset.mjs — Node 22 ESM, no dependencies.
import process from "node:process";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export async function sendReset({ to, link, deadlineMs = 8000 }) {
const started = performance.now();
let res;
try {
res = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
to,
subject: "Reset your Kettle password",
html: `<p>This link works for 30 minutes: <a href="${link}">choose a new password</a>.</p>`,
}),
signal: AbortSignal.timeout(deadlineMs),
});
} catch (err) {
// TimeoutError from AbortSignal.timeout, or a socket-level failure.
return { accepted: null, elapsedMs: Math.round(performance.now() - started), error: err.name };
}
const payload = await res.json().catch(() => ({}));
const elapsedMs = Math.round(performance.now() - started);
if (!res.ok || payload.ok === false) {
const e = payload.error ?? {};
return { accepted: false, elapsedMs, code: e.code ?? `HTTP_${res.status}`, traceId: e.trace_id };
}
return { accepted: true, elapsedMs, messageId: payload.data.message_id, apiMs: payload.metadata?.latency_ms };
}
const out = await sendReset({ to: "dana@example.com", link: "https://kettle.example/r/6f1c9d2a" });
console.log(out);
Two details in there earn their place. AbortSignal.timeout throws a TimeoutError you can branch on instead of a generic abort, and recording your own elapsedMs next to the server’s reported latency_ms is what lets you say later whether the time went into the API or into your network path.
On axios the equivalent needs both settings, because timeout alone doesn’t cover a connection that never establishes:
// send-reset-axios.mjs — Node 22 ESM. Requires: npm i axios
import axios from "axios";
import process from "node:process";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const client = axios.create({
baseURL: "https://api.infrai.cc",
timeout: 8000, // response deadline
signal: AbortSignal.timeout(10_000), // hard ceiling, connection included
headers: { authorization: `Bearer ${KEY}` },
validateStatus: () => true,
});
const { status, data } = await client.post("/v1/email/send", {
to: "dana@example.com",
subject: "Reset your Kettle password",
html: "<p>This link works for 30 minutes.</p>",
});
if (status >= 400 || data.ok === false) {
console.error("send rejected", status, data.error?.code, data.error?.trace_id);
} else {
console.log("accepted", data.data.message_id, "in", data.metadata?.latency_ms, "ms");
}
Budget the timeouts from the outside in
Each layer’s deadline has to be strictly shorter than the one wrapping it, or the outer layer kills the request before the inner one can report anything useful. A reasonable set for a reset endpoint:
| Layer | Deadline | Rationale |
|---|---|---|
| Browser fetch on the login page | 30 s | Matches the platform’s own idle limit; users leave long before this |
| Route handler total | 15 s | Leaves room to render an answer instead of a proxy error page |
| Infrai API call | 8 s | An order of magnitude above the observed ~700 ms send |
| Optional one retry | +8 s | Only for transport failures, never after an accepted send |
Under that budget a hang becomes a fast, legible failure. Blowing the budget is a design decision, not an accident.
The other move — the one that actually removes the class of problem — is to stop making the user’s request wait on the mail at all. Publish an intent and answer 202:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"queue": "kb-reset-mail",
"payload": {"user_id": "usr_4821", "to": "dana@example.com", "token_hash": "6f1c9d2a"},
"idempotency_key": "reset:usr_4821:6f1c9d2a"
}'
A worker drains the queue and does the send, so a vendor slowdown shows up as queue depth rather than as 500s on your login page:
curl -sS "https://api.infrai.cc/v1/queue/stats/kb-reset-mail" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "queue": "kb-reset-mail", "message_count": 0, "available_count": 0, "in_flight_count": 0, "delayed_count": 0, "dlq_count": 0, "oldest_message_age_seconds": 0 }
}
oldest_message_age_seconds is the number to alert on. It answers “are resets going out?” better than any average latency graph.
Diagnosing the other timeout: the mail was slow, not the call
Every response envelope carries a latency_ms for the API’s own work, so a bare curl tells you where time is spent:
curl -sS -w '\ntotal=%{time_total}s connect=%{time_connect}s\n' \
--max-time 10 \
"https://api.infrai.cc/v1/email/list?limit=3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
If total is large while the body’s latency_ms is small, the delay is in the network between you and the edge — DNS, TLS, a saturated NAT gateway — and no provider change fixes it.
For a specific message, the event timeline carries per-step timestamps:
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_FjDRVM4y1dx7xcElLubSlJMF" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T01:32:28.419544Z", "recipient": "dana@example.com", "message_id": "msg_FjDRVM4y1dx7xcElLubSlJMF", "meta": { "vendor_message_id": "9f61a82f-d3d6-44e4-a83f-9f7a411d98c2" } },
{ "type": "queued", "at": "2026-07-26T01:32:28.409408Z", "recipient": "dana@example.com", "message_id": "msg_FjDRVM4y1dx7xcElLubSlJMF", "meta": { "vendor": "resend" } }
],
"count": 2,
"next_cursor": null
}
}
Ten milliseconds between queued and sent. When a user reports a nine-minute delay against a timeline like that, the message left immediately and the wait happened at the receiving end — greylisting, a corporate filter, or a mailbox that batches. That’s a deliverability conversation (SPF, DKIM, sender reputation), not a timeout one, and treating it as a timeout leads teams to shorten deadlines that were never the problem.
Two limitations worth knowing before you build the diagnosis path. The timeline is polled, not pushed — there are no webhooks on this surface — and the state machine is coarse: queued then sent, with mailbox-side outcomes arriving as later events only when the vendor reports them.
When a timeout is your own doing
A malformed recipient — a typo’d address with no @, say — comes back as HTTP 503 VENDOR_DOWN with retryable: true. A retry wrapper that trusts that flag will spend its entire budget on an address that can never work, and from the outside that looks exactly like a hanging endpoint. Validate the address shape before the call, and keep an allow-list of codes you’ll actually retry.
The inverse also holds: an accepted 200 is an acceptance, not a delivery. A well-formed address at a domain that doesn’t resolve is accepted and fails later, asynchronously, which is why the archive read matters more than the send’s own return value.
What the diagnosis costs
Nothing, mostly. Message archive, event history and queue stats are free and rate-limited; the send is metered at $0.000115 per email and a queue publish at $0.00002 per call, both verified 2026-07-26, against $2 of free credit on a new account. Read the current numbers:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'] in ('email.send','queue.publish')])"
Because reads are free, an aggressive polling loop during an incident costs you nothing — which is the point of checking rather than guessing. Rates move downward over time, so expect the live figures to be at or below these.
Alternatives, honestly
If you want a client library that owns retries and deadlines for you, the official SendGrid and Postmark Node SDKs both expose timeout configuration and are perfectly good reasons to stick with a specialist — this article’s approach is plain fetch precisely because there’s no SDK to hide behind. There’s also no Retry-After header on Infrai’s responses today, so your backoff schedule is yours to choose rather than the server’s to dictate.
What you get in exchange is that the fix for a hanging handler — a queue, a worker, an alert on queue age, a scheduled sweep of unresolved sends — needs no new vendor. Same key, same bill, one place to look when the login page starts spinning.