SMTP Timeout Errors: Diagnose DNS, Connection, Greeting, and Command Delays
An SMTP timeout does not mean the recipient mailbox is invalid. It means a specific stage did not finish before your timer expired. Record whether the delay happened during DNS resolution, TCP connection, the server's 220 greeting, an SMTP command, TLS negotiation, or the final response after DATA. Each stage has different evidence and a different safe next step.
I would never store one generic smtp_timeout result. It removes the only clue that makes the incident diagnosable, then tempts the application to suppress a real address.
The word “timeout” hides six different states
Start with the last completed boundary. These hypothetical log lines describe different failures even though a dashboard might flatten all of them into “SMTP timed out.”
| Last completed boundary | What is missing | What it proves |
|---|---|---|
| Domain accepted by your application | MX or address lookup | DNS did not produce usable routing evidence in time |
| MX host and IP selected | TCP connection | No socket was established with that address and port |
| TCP connected | 220 greeting | The server accepted a connection but did not announce SMTP readiness in time |
| Greeting received | Reply to EHLO, MAIL, or RCPT | The session exists, but one command has no complete reply yet |
STARTTLS accepted | TLS handshake or post-TLS EHLO | The secure session did not reach the next SMTP state |
| Final dot after message data sent | Final 250 or failure reply | Acceptance is ambiguous; the server may have the message |
A refusal is not a timeout. ECONNREFUSED means the destination actively rejected the TCP connection. An SMTP 421, 451, or 550 is also a reply, not silence. Preserve those outcomes separately.
Put a timer around each SMTP boundary
RFC 5321 requires per-command timeouts instead of one timer for the whole transaction. Its recommended minimums explain why a single aggressive socket timeout can misclassify a healthy but busy server:
- initial
220greeting: 5 minutes; MAIL FROM: 5 minutes;RCPT TO: 5 minutes;DATAinitiation while waiting for354: 2 minutes;- each data block: 3 minutes;
- message termination while waiting for the final reply: 10 minutes.
Those are SMTP implementation recommendations, not a requirement to keep a signup HTTP request open for ten minutes. A web application can use a shorter synchronous budget and move inconclusive work to a queue. The important part is semantic: a local deadline creates an unknown transport result, not proof that the address cannot receive mail.
In Node.js, socket.setTimeout() reports idle time but does not close the connection. Your handler must destroy or end the socket, and it should attach the current phase before doing so. Otherwise every idle event reaches your logs without context.
Separate routing failure from connection failure
SMTP delivery starts before the socket. The sender first finds the destination route, orders MX hosts by preference, and resolves usable addresses. RFC 5321 defines that mail-routing process, while Node.js exposes MX records through dnsPromises.resolveMx().
Log the resolver error, selected MX host, preference, IP family, address, port, and elapsed time. “Domain has no usable mail route” and “first IP did not connect” are different results. The existing MX priority guide owns host ordering and failover.
Port selection matters too. Server-to-server delivery normally targets the destination MX on port 25. Ports 465 and 587 are submission paths for an authenticated client and its provider. The SMTP ports guide explains that boundary. Trying the wrong service can create a perfectly repeatable timeout that says nothing about the mailbox.
A delayed 220 greeting is not a failed TCP connection
RFC 5321 explicitly tells clients to distinguish a failed connection from a delay before the initial 220. A busy server may accept TCP and wait before announcing that it is ready.
12:00:00.000 mx_selected mx1.example.net 203.0.113.24:25
12:00:00.081 tcp_connected
12:00:05.083 timeout phase=greeting expected=220
This hypothetical trace proves that DNS and TCP succeeded. It does not prove the SMTP service is permanently broken, and it never reached a recipient command. Changing the email address or suppressing it would be unrelated to the evidence.
After a complete greeting, capture the command that is waiting and the reply lines already received. SMTP replies can be multiline. The HELO and EHLO guide shows why a client must wait for the terminating reply line instead of treating the first 250- line as completion.
TLS creates another explicit phase
If the server advertises STARTTLS and the client uses it, give the handshake its own state and timer. After a successful handshake, RFC 3207 resets the SMTP session: the client discards the pre-TLS capability list and sends EHLO again.
That produces two distinct failures:
- the TLS handshake itself did not complete;
- TLS completed, but the post-TLS SMTP greeting or command did not.
Keeping them separate stops a certificate, protocol, or network problem from being reported as an SMTP recipient failure.
The final DATA timeout is the dangerous one
After the terminating dot, the server may accept and store the message before its 250 reply reaches you. RFC 5321 gives this phase a longer recommended timeout because a false timeout can create duplicate delivery.
If the connection disappears here, do not record “not sent.” Record an ambiguous handoff. Reconcile with your provider or retry using a stable downstream idempotency key when the interface supports one. The duplicate transactional email guide owns the outbox and crash-window design for this boundary.
Classify the event before changing address state
I use a small state classifier between the transport client and retry policy. The networking layer supplies the phase; the classifier refuses to turn silence into a mailbox verdict.
export function classifySmtpFailure(event) {
if (event.type === 'socket_error' && event.code === 'ECONNREFUSED') {
return { state: 'connection_refused', retry: true }
}
if (event.type === 'timeout') {
const states = {
connect: 'connect_timeout',
greeting: 'greeting_timeout',
command: 'command_timeout',
tls: 'tls_timeout',
data_final: 'data_final_timeout'
}
if (!states[event.phase]) throw new Error('Unknown SMTP phase')
return { state: states[event.phase], retry: true }
}
if (event.type === 'smtp_reply') {
const replyClass = Math.floor(event.code / 100)
if (replyClass === 4) return { state: 'smtp_temporary_failure', retry: true }
if (replyClass === 5) return { state: 'smtp_permanent_failure', retry: false }
if (replyClass === 2) return { state: 'smtp_success', retry: false }
}
return { state: 'review', retry: false }
}
I ran eight deterministic fixtures through the full classifier. Connect, greeting, command, TLS, and final-response timeouts remained retryable transport states. ECONNREFUSED stayed distinct, a 451 became a temporary SMTP failure, and a 550 became a permanent failure for that attempt. All 8 fixtures passed.
The strongest fair objection is that all of these states may lead to the same immediate action: try again later. That is sometimes true. They still need separate names because their alerts, fallback hosts, TLS investigation, duplicate risk, and long-term policy are different.
What should the production log keep?
For each attempt, keep the domain, MX host and preference, resolved address and family, port, phase, command, elapsed time, timeout budget, socket error, complete SMTP reply, TLS outcome, attempt number, and queue ID. Redact message content and credentials. Keep the evidence that explains the state.
Then let the SMTP retry queue decide when another attempt is appropriate. A timeout during one route may justify another MX host or later attempt. It should not silently become mailbox_invalid.
Email verification has the same boundary. A clear recipient rejection can support a confirmed failure. DNS trouble, connection silence, policy blocks, catch-all behavior, and inconclusive SMTP sessions must remain risk or unknown evidence. The API result-handling guide shows how to preserve that distinction in product logic.