A timeout should end a wait, not erase the history of an edtech payment receipt. For delivery reliability, accept the settled-payment event into a durable local outbox, return without waiting for mail delivery, and trace every later handoff with one receipt ID. The key decision is where durability begins. A longer fetch or Axios timeout merely makes the request hang longer; it cannot tell you whether the remote system accepted the message.
| Pattern | Pick this when | What a timeout means | Main operational cost |
|---|---|---|---|
| Send inside the payment request | A missed receipt can be repaired manually and traffic is tiny | The caller lacks a conclusive response | Payment latency and mail latency share one failure path |
| In-memory background task | Loss during a process restart is acceptable | The live process may still be attempting the call | Very little setup, no durable recovery |
| Transactional outbox plus worker | A settled payment must leave an auditable receipt obligation | The row remains available for reconciliation | A database table, worker, and retention policy |
| Broker-backed worker | The team already operates a durable queue | The job state and provider state must be inspected separately | Another stateful system and another telemetry boundary |
For an order receipt after payment settles, the outbox or an existing durable broker is the serious choice. The rest of this field guide shows the outbox because its commit boundary is easy to explain: payment state and the obligation to send its receipt become durable in one database transaction. Diagram in words: payment settles -> database commit -> worker claims receipt -> mail API accepts -> delivery event arrives. Put a timestamp and trace context on every arrow.
How should a password reset email API request handle a timeout?
Start with three clocks. They answer different questions.
The application deadline limits how long the worker will wait. The remote acknowledgement time measures when the mail API returns an accepted-or-rejected response. The delivery time ends only when a later event says what happened downstream. One timer cannot stand in for all three.
This distinction matters for password resets too. If a Node.js fetch or Axios request times out, the client knows that it stopped waiting. It does not, from that fact alone, know that no message was accepted. Retrying blindly can create duplicates. For a settled-payment receipt, preserve the same message identity across retries and reconcile an ambiguous attempt before creating a fresh obligation. For a password reset, also make the reset action safe against replay and expiration; mail transport success is not proof that a link was used.
Instrument the attempt before tuning the timeout. Record receipt.id, order.id, attempt.id, the message identity, attempt number, start time, deadline, outcome class, HTTP status when one exists, and elapsed milliseconds. Do not put the recipient address, reset token, or full message body in logs. Those values do not help locate a stalled network phase, and they expand the sensitive data exposed to telemetry systems.
A useful outcome vocabulary is small: accepted, rejected, deadline_exceeded, network_error, and unknown. Keep unknown. It is honest, and it prevents a transport ambiguity from being mislabeled as a definite failure.
Pick the durability boundary before the timeout value
Inline sending looks attractive because there is one function call. It also couples the checkout response to DNS, connection establishment, TLS, the remote service, and its response path. Raising a timeout shifts latency around; it does not remove those dependencies.
An in-memory task separates the HTTP response from the send, but a restart can erase work that has not reached durable storage. Use it only when that loss is explicitly acceptable. Payment receipts rarely fit that rule.
A broker is a sound boundary when the team already knows how to monitor, back up, and recover it. Adding one solely for receipt mail creates a second durable system with its own backlog, redelivery, and deployment behavior. A database outbox is often the smaller operational surface when payment records already live in Postgres. This is a trade-off, not a ranking.
The decision test is concrete: after killing the Node.js process at every arrow in the diagram, can an operator determine whether a receipt is owed, attempted, remotely accepted, or delivered? If any answer depends on process memory, move the boundary.
Implement the traced outbox in Node.js
First, create the receipt obligation in the same transaction that marks the order paid. The database enforces one logical receipt per order. The TypeScript types below keep the example focused; db is a generic adapter whose transaction and query methods use parameterized SQL.
type SettledPayment = {
orderId: string;
studentEmail: string;
amountMinor: number;
currency: string;
};
type Database = {
transaction<T>(work: (tx: Database) => Promise<T>): Promise<T>;
query<T>(sql: string, params: unknown[]): Promise<{ rows: T[] }>;
};
async function recordSettlement(db: Database, payment: SettledPayment): Promise<void> {
await db.transaction(async (tx) => {
await tx.query(
`UPDATE orders
SET payment_state = 'settled', settled_at = CURRENT_TIMESTAMP
WHERE id = $1`,
[payment.orderId],
);
await tx.query(
`INSERT INTO receipt_outbox
(id, order_id, recipient, amount_minor, currency, state, attempt_count, next_attempt_at)
VALUES ($1, $2, $3, $4, $5, 'pending', 0, CURRENT_TIMESTAMP)
ON CONFLICT (order_id) DO NOTHING`,
[crypto.randomUUID(), payment.orderId, payment.studentEmail,
payment.amountMinor, payment.currency],
);
});
}
ON CONFLICT (order_id) DO NOTHING assumes a unique constraint on receipt_outbox.order_id. That constraint is the duplicate guard at the business boundary. It is stronger than hoping two payment notifications never overlap.
Now wrap the remote call with an explicit deadline. Modern Node.js exposes a browser-compatible fetch, and AbortSignal.timeout() returns a signal that aborts after the requested milliseconds. Inject the documented endpoint rather than embedding a route. The message key remains stable for the same receipt, while each attempt gets a new ID for tracing.
type Receipt = {
id: string;
orderId: string;
recipient: string;
amountMinor: number;
currency: string;
};
type AttemptResult =
| { kind: 'accepted'; remoteId: string; elapsedMs: number }
| { kind: 'rejected'; status: number; elapsedMs: number }
| { kind: 'deadline_exceeded'; elapsedMs: number }
| { kind: 'network_error'; elapsedMs: number; errorName: string };
async function submitReceipt(
endpoint: URL,
receipt: Receipt,
deadlineMs = 5_000,
): Promise<AttemptResult> {
const started = performance.now();
const attemptId = crypto.randomUUID();
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': `receipt:${receipt.id}`,
'traceparent': currentTraceparent(),
},
body: JSON.stringify({
to: receipt.recipient,
template: 'settled-payment-receipt',
data: {
orderId: receipt.orderId,
amountMinor: receipt.amountMinor,
currency: receipt.currency,
},
metadata: { receiptId: receipt.id, attemptId },
}),
signal: AbortSignal.timeout(deadlineMs),
});
const elapsedMs = Math.round(performance.now() - started);
if (!response.ok) return { kind: 'rejected', status: response.status, elapsedMs };
const body = (await response.json()) as { id: string };
return { kind: 'accepted', remoteId: body.id, elapsedMs };
} catch (error) {
const elapsedMs = Math.round(performance.now() - started);
if (error instanceof DOMException && error.name === 'TimeoutError') {
return { kind: 'deadline_exceeded', elapsedMs };
}
return {
kind: 'network_error',
elapsedMs,
errorName: error instanceof Error ? error.name : 'UnknownError',
};
}
}
Do not assume every API honors an idempotency-key header. That behavior belongs to the remote contract, not to HTTP in general. If the chosen mail API documents no idempotency mechanism or lookup by your message identity, a timeout leaves an acknowledgement gap. Mark the attempt unknown, delay the retry, and reconcile using whatever documented event or query surface exists. If there is none, duplicates cannot be ruled out by the client. Say so in the runbook.
The worker should claim rows without letting several workers send the same row concurrently. PostgreSQL documents SKIP LOCKED as suitable for avoiding lock contention with multiple consumers of a queue-like table, while also warning that it gives an inconsistent view for general-purpose queries. That boundary is exactly why it belongs in a short claim transaction, not in reporting code.
async function claimReceipts(db: Database, limit = 25): Promise<Receipt[]> {
return db.transaction(async (tx) => {
const result = await tx.query<Receipt>(
`WITH claimed AS (
SELECT id
FROM receipt_outbox
WHERE state IN ('pending', 'retry')
AND next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY next_attempt_at
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE receipt_outbox AS r
SET state = 'sending',
attempt_count = attempt_count + 1,
claimed_at = CURRENT_TIMESTAMP
FROM claimed
WHERE r.id = claimed.id
RETURNING r.id,
r.order_id AS "orderId",
r.recipient,
r.amount_minor AS "amountMinor",
r.currency`,
[limit],
);
return result.rows;
});
}
Keep the network call outside that transaction. A worker that holds row locks while waiting on the network turns a mail slowdown into database contention. After the call, use a short second transaction to store the typed outcome. Accepted messages move to accepted. Definite rejections move to rejected or a reviewed retry state according to status and the remote contract. Deadline and network outcomes move to unknown, because either can occur after the server accepted the body but before the client received the response.
Small detail. Big consequence.
Turn spans into a diagnosis, not a dashboard ornament
Use one trace across the payment consumer, outbox insert, claim, remote request, and delivery-event handler. OpenTelemetry's messaging semantic conventions distinguish message creation, sending, receiving, and processing; use the conventions that match the instrumentation version deployed by your team, because the messaging conventions are marked development in the specification. Preserve the receipt ID as an application attribute even when trace sampling drops a trace.
A trace should let an engineer answer this sequence without reading prose in a log line:
- Did the payment-settled event reach the application?
- Did the database transaction commit the order and receipt obligation?
- Did a worker claim it, and how long had it waited?
- Did connection setup, request upload, or response waiting consume the deadline?
- Was a remote message ID recorded?
- Did a later delivery event correlate to that message and receipt?
Emit counters for attempts and typed outcomes, plus histograms for outbox age, request duration, and acceptance-to-delivery duration. Alert first on user impact and stuck work: oldest pending receipt age, unknown outcomes that remain unreconciled, and delivery events that never arrive within the service objective. A high request-duration percentile is diagnostic evidence; by itself, it is not proof that learners missed receipts.
Here is the crisp before/after. Before, the log says send timed out after 5000ms. After, the trace says a receipt waited 840 ms in the outbox, attempt 2 hit its 5,000 ms deadline without an acknowledgement, reconciliation later matched the stable message identity to a remote acceptance, and no new message was created. Those numbers are an illustrative trace shape, not a benchmark or recommended threshold. Pick the actual deadline from measured latency and the product's receipt objective.
Test the state machine, not just the happy-path function. Inject a timeout after the request body leaves the process. Kill the worker after remote acceptance but before the local update. Deliver the payment event twice. Deliver the downstream event twice and out of order. Advance a fake clock across the retry schedule. Each test should assert the final receipt state and the number of logical message identities, not merely the number of function calls.
Know the limits
A transactional outbox joins your payment state and send obligation. It cannot atomically join your database transaction to an unrelated mail system. Stable identity, documented idempotency, later delivery events, and reconciliation narrow that gap; they do not make two independent systems one transaction.
Delivery telemetry also stops short of human behavior. An accepted API response is not delivery. Delivery is not inbox placement. Inbox placement is not reading. Keep those states separate, retain only the operational data your policy permits, and make reset tokens single-purpose and short-lived according to the application's security design.
The practical rule is terse: persist first, bound every wait, name ambiguity, and trace the handoffs. Then a hanging Node.js request becomes a state you can inspect instead of a reason to send another receipt and hope.
References
- https://nodejs.org/api/globals.html#fetch
- https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
- https://www.postgresql.org/docs/current/sql-select.html
- https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/
- https://www.rfc-editor.org/rfc/rfc9110
- https://pages.nist.gov/800-63-4/sp800-63b.html
Top comments (0)