Hard Bounces vs Soft Bounces: What They Mean and How to Handle Them
A bounce is the mail system telling you something. Learn to read SMTP reply codes, tell permanent failures from temporary ones, build retry logic that doesn't torch your reputation, and keep your list clean.
EvilMail TeamJune 15, 202612 min read
# Hard Bounces vs Soft Bounces: What They Mean and How to Handle Them
A bounce is not a failure of your code. It's a message — a fairly precise one, if you bother to read it — from a mail server explaining why it wouldn't accept or deliver what you sent. Treat bounces as noise and delete them, and you'll slowly poison your sender reputation until your legitimate mail lands in spam folders you can't see. Treat them as data, and they'll tell you exactly which addresses to drop, which to retry, and when your own infrastructure is the problem.
I've run the bounce-processing side of a few sending systems, and the difference between a healthy sender and a throttled one is almost entirely in how disciplined the bounce handling is. Here's the working knowledge.
What a bounce actually is
There are two moments a delivery can fail, and they produce different artifacts.
A synchronous rejection happens during the live SMTP conversation, before the receiving server accepts the message. Your server issues RCPT TO:<[email protected]>, and the remote replies with a status code. If it's a 5xx, the message is refused on the spot. You know immediately, in-band, and you have the code and text right there.
An asynchronous
bounce happens after the server accepted the message (
250 OK
) but later couldn't deliver it — the mailbox turned out to be full, or the internal forwarding target rejected it. The server then generates a
bounce message
: a new email, sent to your envelope sender (the
Return-Path
), formatted as a Delivery Status Notification (DSN). This is why your bounce mailbox exists, and why it needs a machine reading it.
Either way, the meaningful payload is an SMTP reply code and an enhanced status code. Learn to read both.
Reading SMTP reply codes
SMTP codes are three digits, and the first digit is the whole story at a glance:
2xx — success. 250 is "accepted."
4xx — transient negative. "Not now, try again later." This is a soft bounce.
5xx — permanent negative. "No, and don't bother retrying." This is a hard bounce.
Alongside the classic code, modern servers include an enhanced status code (RFC 3463) of the form class.subject.detail, e.g. 5.1.1. The first digit mirrors the reply class (2 success, 4 transient, 5 permanent). The middle number is the subject — 1 is about the address, 2 about the mailbox, 7 about policy/security. So 5.1.1 is "permanent, address problem, bad destination mailbox" — a dead address. 4.2.2 is "transient, mailbox problem, mailbox full." Once you can decode these, the human-readable text is just confirmation.
The critical judgment: the first digit tells you whether to ever try again. Everything else is diagnostics.
Hard bounces: permanent, remove immediately
A hard bounce means the address is not deliverable and will not become deliverable. The most common causes:
`5.1.1` — user unknown / no such mailbox. The local part doesn't exist. Typo at signup, employee left the company, address invented.
`5.1.2` — bad destination domain. The domain doesn't resolve or has no MX records. Often a typo like gmial.com.
`5.1.10` — null MX / address does not accept mail. The domain explicitly publishes that it receives no mail.
`5.7.1` — delivery refused by policy. Sometimes a block on *you* rather than a dead address — read the text, because this one occasionally masquerades as permanent when it's really reputation-driven.
The rule for hard bounces is unambiguous: suppress the address after a single hard bounce and never send to it again automatically. Not after three, not after a warning — one. Continuing to hammer a 5.1.1 address is one of the fastest ways to signal to mailbox providers that you don't clean your list, and list hygiene is a direct reputation input. Add it to a permanent suppression list and make that suppression check part of your send path, not a nightly cleanup job.
Soft bounces: temporary, retry with a budget
A soft bounce (4xx) is a maybe. The message might go through in an hour. Common causes:
`4.7.x` — greylisting or rate limiting. The receiver is deliberately deferring you. Greylisting specifically expects you to retry after a delay; passing that test is how you get accepted.
Soft bounces get retried — but with a budget and a backoff, not blindly. A sane policy:
1. Retry on an exponential-ish backoff: something like 15 min, 1 hr, 4 hr, then every few hours. 2. Cap the retry window at 24–72 hours. Most MTAs default to a 5-day queue lifetime; for application/transactional mail, a shorter window is usually better — a password reset that arrives 3 days late is worse than useless. 3. If the address is *still* soft-bouncing after the window, treat it as a hard bounce and suppress it. A mailbox that's been full for three days is functionally dead. 4. Track repeated soft bounces across separate campaigns. An address that soft-bounces on every send for weeks isn't temporarily full; it's abandoned. Escalate it to suppression.
The subtlety here is that greylisting looks like a failure but is actually a test you pass by behaving like a well-configured MTA: retry from the same IP, after a reasonable delay, and you're let through. Retry instantly or from a different IP each time and you fail it repeatedly.
SMTP code reference table
The codes you'll actually encounter, and the action each one demands:
| Reply | Enhanced | Meaning | Type | Action | |-------|----------|---------|------|--------| | 250 | 2.0.0 | Accepted for delivery | Success | Done | | 421 | 4.7.0 | Service not available, closing channel | Soft | Retry with backoff | | 450 | 4.2.1 | Mailbox unavailable (busy/blocked) | Soft | Retry | | 451 | 4.3.0 | Local error / processing failure | Soft | Retry | | 452 | 4.2.2 | Insufficient storage (mailbox full) | Soft | Retry, then suppress if persistent | | 421 | 4.7.1 | Greylisted / rate limited | Soft | Retry after delay, same IP | | 550 | 5.1.1 | No such user / mailbox not found | Hard | Suppress immediately | | 550 | 5.1.2 | Domain not found / no MX | Hard | Suppress immediately | | 550 | 5.7.1 | Rejected by policy / blocked sender | Hard* | Investigate — may be reputation | | 551 | 5.1.6 | User not local / relay denied | Hard | Suppress | | 552 | 5.2.2 | Storage exceeded (permanent) | Hard | Suppress | | 553 | 5.1.3 | Invalid mailbox syntax | Hard | Suppress, fix validation | | 554 | 5.7.1 | Transaction failed / spam rejection | Hard* | Investigate content & reputation |
The asterisked 5.7.1 / 554 rows are the ones to be careful with. A permanent code that's really about *your* reputation or *your* content shouldn't be handled by suppressing the recipient — the recipient is fine; your sending is the problem. Read the accompanying text, which frequently contains a URL to the receiver's postmaster page explaining the block.
How bounces wreck (or protect) your reputation
Mailbox providers score senders, and bounce rate is a load-bearing input to that score. The mechanism is simple: a high proportion of hard bounces signals that you're mailing addresses you never verified or never cleaned — which correlates strongly with spammers, who buy lists and blast them. So a rising hard-bounce rate doesn't just cost you those deliveries; it drags down inbox placement for the *valid* addresses too.
Rough guidance from the deliverability world: keep hard bounces under roughly 2% of a send, and ideally under 1%. Blow past 5% and you should expect throttling or temporary blocks from the big providers. Some ESPs will automatically pause a campaign that crosses a bounce threshold, precisely to protect the shared IP pool from your bad list.
A concrete failure I've watched happen: a team imported an old CRM export, mailed 40,000 stale addresses, took an 8% hard-bounce rate in the first hour, and got their sending IP throttled for a week — which broke their *transactional* password-reset mail too, because it shared the same infrastructure. The fix was boring and preventive: verify before you import, and separate transactional from bulk streams so a marketing mistake can't take down your login flow.
List hygiene and retry logic in practice
Here's the operational shape of a system that stays healthy.
Validate at collection time. Syntax-check the address, verify the domain has MX records, and reject obvious garbage before it ever enters your list. This kills a large share of 5.1.2 bounces before they happen. Consider a confirmed opt-in (double opt-in) for marketing lists so every address is proven live once.
Maintain a suppression list and check it on every send. Hard bounces, spam complaints, and unsubscribes all go here. The suppression check belongs in the send path itself — if it only runs nightly, you'll re-mail a dead address several times before cleanup catches it.
Classify bounces by code, not by guessing. Parse the DSN, pull the enhanced status code, branch on the first digit. Rough pseudocode:
function handleBounce(dsn) {
const code = dsn.enhancedStatus; // e.g. "5.1.1"
const cls = code[0];
if (cls === '5') {
if (isReputationBlock(dsn)) {
alertOpsTeam(dsn); // your problem, not the recipient's
} else {
suppress(dsn.recipient); // dead address, remove forever
}
} else if (cls === '4') {
recordSoftBounce(dsn.recipient);
if (softBounceCount(dsn.recipient) >= 5 ||
firstSoftBounceOlderThan(dsn.recipient, hours(72))) {
suppress(dsn.recipient); // chronic soft = effectively dead
} else {
scheduleRetry(dsn, backoff());
}
}
}
Sunset inactive addresses proactively. An address that hasn't opened or clicked in a year, even if it never bounces, is dead weight that lowers engagement metrics — another reputation input. Re-engage or drop them.
Watch your bounce rate as a live metric. Alert when it crosses a threshold on any single send. A sudden spike usually means a bad import or a misconfiguration (wrong Return-Path, broken DKIM causing policy rejections), and catching it in the first hundred sends beats catching it after ten thousand.
One more angle worth naming: a meaningful chunk of hard bounces on consumer lists comes from people giving fake or throwaway addresses at signup — which is entirely rational on their end. If someone hands you a disposable address from a service like EvilMail to get past a forced registration, verifying at collection time and honoring the resulting bounce is the correct response. Don't fight it; clean it. The address was never going to be a customer, and keeping it on your list only costs you reputation with the addresses that will.
The short version
Read the first digit.4xx = retry later; 5xx = stop.
Hard bounce → suppress after one. No second chances for dead addresses.
Soft bounce → retry on backoff, cap the window, then promote chronic soft-bouncers to suppression.
`5.7.1`/`554` are traps — sometimes they're about your reputation, not the recipient. Read the text.
Keep hard bounces under ~2%. Above that, providers start throttling everything you send.
Validate at collection, suppress on send, and separate transactional from bulk so one bad list can't break your login emails.
Bounces are the mail system's most honest feedback channel. The senders who last are the ones who actually listen to it.