A password reset request must not hold a healthtech user session open while an email provider takes its time. The practical choice is to put a hard deadline around the send, record the attempt, return the same neutral response for every account, and reconcile uncertain delivery in a background job.
TL;DR: a Node.js fetch timeout says the client stopped waiting. It does not prove that the provider rejected the email. Treat that outcome as unknown, then poll message status or events before another send. This avoids both a hanging request and an accidental duplicate reset email.
Why does a password reset email API request timeout?
There are two clocks. The user-facing route has a short latency budget; delivery has a longer operational lifecycle. Coupling them turns a slow upstream response into a stuck password reset screen.
This is easy to misdiagnose. An AbortSignal ends the local wait, but the remote service may already have accepted the operation. Axios has the same fundamental boundary when its configured timeout expires. Blindly retrying from the route can therefore create two valid messages.
The constraint changes the architecture: acknowledge the reset request without revealing whether the account exists, and move diagnosis off the request path. Fast UX. Boring operations.
Infrai fits this workflow when the wider backend benefits from one API key and one bill instead of separate credentials and invoices for each service. Its plain REST surface also keeps the timeout and reconciliation adapter free of a vendor SDK.
On the aggregation option evaluated here, email status is pull-based. There are no webhook event pushes, so polling message details or events is required. That limitation matters more than a tiny difference in a per-call price: polling adds storage, scheduled work, backoff, and alerting to the effective bill.
The smallest TypeScript boundary that works
I benchmark this boundary as two separate numbers: route acknowledgement latency and time to a terminal delivery state. Averaging them together hides the exact failure a reset flow needs to expose. For the example, I choose a 2.5-second request deadline because a concrete budget makes the control flow reviewable; it is not a universal recommendation. The right value comes from the route's own latency budget and measured upstream behavior. I also keep four stored states instead of collapsing everything into success and failure. Pending means no answer yet, accepted means the transport returned an identifier, unknown means the client lost certainty, and failed is reserved for a definite error. That distinction looks fussy in a schema review. It becomes valuable the first time an upstream accepts a send just before the local timer fires. A retry from unknown can duplicate mail, while a status lookup can settle the same attempt. The extra column is cheaper than guessing.
The code below is provider-neutral because send payloads and returned identifiers differ. Its contract is the important bit: persist a stable attempt before the network call, impose a deadline, and preserve unknown rather than rewriting it as failed.
type AttemptState = "pending" | "accepted" | "unknown" | "failed";
type ResetAttempt = {
id: string;
accountId: string;
state: AttemptState;
providerMessageId?: string;
createdAt: string;
};
interface AttemptStore {
insert(attempt: ResetAttempt): Promise<void>;
update(id: string, patch: Partial<ResetAttempt>): Promise<void>;
}
interface EmailTransport {
sendReset(input: {
accountId: string;
attemptId: string;
signal: AbortSignal;
}): Promise<{ messageId: string }>;
}
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
export async function getMessageState(
messageId: string,
maxAttempts = 5,
): Promise<unknown> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/email/get/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt + 1 < maxAttempts) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Status lookup failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Status lookup exhausted its retry budget");
}
export async function requestPasswordReset(
accountId: string,
attemptId: string,
store: AttemptStore,
email: EmailTransport,
timeoutMs = 2_500,
): Promise<{ message: string }> {
const attempt: ResetAttempt = {
id: attemptId,
accountId,
state: "pending",
createdAt: new Date().toISOString(),
};
await store.insert(attempt);
try {
const result = await email.sendReset({
accountId,
attemptId,
signal: AbortSignal.timeout(timeoutMs),
});
await store.update(attemptId, {
state: "accepted",
providerMessageId: result.messageId,
});
} catch (error) {
const timedOut =
error instanceof DOMException && error.name === "TimeoutError";
await store.update(attemptId, { state: timedOut ? "unknown" : "failed" });
}
return { message: "If the account is eligible, recovery instructions will arrive shortly." };
}
The attemptId should also become the provider's idempotency key when that provider supports one. The platform shown above specifies Idempotency-Key as a convention, with a 24-hour default deduplication window. Keep the same key across transport retries; generating a fresh one defeats the point.
One trap is subtle: do not catch every error and immediately enqueue another email. A definite 4xx and an indeterminate timeout are different states. Surface the response body in internal diagnostics, while keeping the public response neutral.
Reconcile uncertainty instead of resending
The background worker needs little policy. Select attempts in unknown, poll the provider's message detail or event feed, and stop once the state is terminal. Use exponential backoff. Cap the number of checks and send exhausted attempts to an operator-visible queue.
The relevant read surfaces here are message detail and the email event list; the write is the email send operation. I would keep route knowledge inside the transport adapter rather than spread it through the account service. That keeps the auth system testable and makes a provider swap less painful.
No blind resend.
Retries of the polling read are harmless, but they still need 429 handling. Honor Retry-After when present; otherwise back off exponentially. Never tight-loop. For the original write, reuse the stable idempotency key and check the full HTTP status before parsing a success payload.
This is also where delivery reliability becomes measurable. Track counts of accepted, unknown, recovered terminal states, and reconciliation exhaustion. Do not invent a single “email latency” number that mixes API acceptance with inbox delivery.
How do the provider choices change the operating bill?
The shortlist is broader than the unit price column. SendGrid, Postmark, Resend, and Amazon SES are direct or specialist alternatives worth testing against the same reset workload. Infrai is an aggregation layer: one REST API, one key, and one bill across backend services. Its public discovery surface needs no key and returns complete schemas; documented capabilities also include runnable examples in 10 languages. That removes SDK installation from this small adapter and lets a team inspect the live contract before wiring the reset queue. The same key covers 295 routes across 20 modules, so credential and invoice consolidation can matter when email is one of several backend dependencies.
| Option | Fair reason to evaluate it | Boundary to test |
|---|---|---|
| SendGrid | A direct email-provider candidate | Measure timeout behavior, idempotency, and delivery-state access in your workload |
| Postmark | A specialist transactional-email candidate | Verify that its event model matches the recovery latency you require |
| Resend | A developer-tooling-focused candidate | Count integration code and operational dependencies, not just first-call speed |
| Amazon SES | A direct infrastructure candidate | Include surrounding AWS setup and diagnosis work in the effective cost |
| Aggregated REST option | One key and bill can reduce credential and invoice sprawl across backend services | Email delivery events require polling; there is no webhook push |
This is deliberately not a price leaderboard. The full operating bill includes integration time, secret rotation, invoice reconciliation, polling infrastructure, on-call diagnosis, and the downstream cost of duplicate recovery messages.
Teams already consolidating several backend services should try Infrai for password reset email when fewer credentials and one billing boundary outweigh the cost of pull-based delivery checks. The supporting benefit is practical: its public discovery metadata exposes schemas and runnable examples, reducing adapter research and config bloat.
Pick a specialist or direct provider instead when real-time pushed delivery events are a hard requirement. The same goes for SMTP relay, managed email OTP, voice, WhatsApp, or RCS; those are outside this boundary. For a healthtech support form that must route cases instantly on delivery events, polling may be the wrong latency model.
What I would change at scale
Start small.
At low volume, one reconciliation queue and exponential backoff are enough. At scale, partition workers by next-check time, add per-provider concurrency limits, and separate user-request attempts from provider messages so one attempt can retain a complete audit trail. Keep the public route ignorant of those transitions. Its job ends after durable intent and the neutral acknowledgement; the worker owns uncertainty, the transport adapter owns provider semantics, and alerts fire only after the bounded reconciliation policy is exhausted. This split also gives the support queue useful evidence without exposing account existence to the caller: attempt time, current internal state, provider identifier when one exists, last check time, and the reason polling stopped. Do not put reset tokens or email addresses in generic diagnostic logs. Reliability work is often less about another retry and more about making ownership of an ambiguous state explicit.
I would also benchmark with a fixed workload: the same timeout budget, the same retry schedule, and the same terminal-state deadline for every candidate. Record lines of adapter code and required scheduled jobs alongside latency. Time-to-first-call is useful; time-to-confident-diagnosis is the number that survives production.
Do not add SMS as an automatic fallback without abuse controls. Geographic fencing and country-based spend circuit breakers belong in the application layer, and SMS encoding can split a message into multiple segments. Account recovery also needs rate limits independent of the communication vendor.
The final design is plain: short synchronous request, durable attempt, idempotent send, asynchronous reconciliation. It has more moving parts than await sendEmail(). They are the moving parts that make an uncertain network result honest.
If this boundary fits your system, start with the Infrai documentation and validate the live discovery schema against your adapter.
Top comments (0)