guides
Handling email bounces and suppression the right way
May 12, 2026

You exported a list, sent a campaign, and 4% bounced. Worse, next week's campaign will bounce on the same addresses, because nothing recorded that they are dead. Each repeat send tells Gmail your list is garbage.
Handling bounces correctly means doing two things on every send: classify each failure as permanent or temporary, and maintain a suppression list of addresses you must never mail again. Hard bounces (permanent) go onto that list immediately. Soft bounces (temporary) get a bounded retry, then get suppressed if they keep failing. The suppression list is checked before you send, not consulted after the damage is done. Skip either step and you re-mail dead addresses, your bounce rate climbs, and mailbox providers start treating your domain like a spammer's.
This guide builds the whole thing the way you would by hand: the SMTP and DSN codes that tell you what kind of bounce you got, the classification logic, the retry policy for soft bounces, and the suppression-list schema that ties it together. It is honest about the edge cases, because the edge cases are where reputation gets quietly destroyed. At the end I will show how a Cloudflare-native stack does this automatically, but the logic below applies no matter what you send through.
What is the difference between a hard bounce and a soft bounce?
A hard bounce is a permanent delivery failure; a soft bounce is a temporary one. The distinction decides everything downstream: hard bounces get suppressed forever, soft bounces get retried. Getting it wrong in either direction hurts you, by either re-mailing dead addresses or giving up on recoverable ones.
A hard bounce means the address will never accept mail:
- The mailbox does not exist (
550 5.1.1 User unknown). - The domain has no mail server or does not resolve (
550 5.1.2). - The recipient or their server has blocked you permanently (
550 5.7.1).
A soft bounce means delivery failed but might succeed later:
- The mailbox is full (
452 4.2.2). - The receiving server is temporarily unavailable (
421 4.3.2). - The message was greylisted (a deliberate temporary rejection to deter spam, often
451 4.7.1). - The message was too large for the recipient's limits (sometimes permanent, sometimes a quota you can work around).
The signal that tells them apart is the SMTP reply code class. A 5xx code is a permanent failure (hard). A 4xx code is a transient failure (soft). This is the single most important rule in bounce handling, and it is encoded in the protocol:
2xx success delivery accepted
4xx transient try again later -> soft bounce
5xx permanent do not retry -> hard bounce
The three-digit code is paired with an enhanced status code (RFC 3463), the 5.1.1 style triple, which gives the specific reason. The first digit of the enhanced code mirrors the class (2 success, 4 transient, 5 permanent), and the rest narrows it down. You read both: the basic code for the retry decision, the enhanced code for the human-readable reason and your analytics.
How do I read a bounce message?
You read a bounce from the Delivery Status Notification (DSN), a structured machine-readable report the receiving or relaying server sends back. When delivery fails after the SMTP session (an asynchronous bounce), you do not get a live reply code; you get an email back to your return-path with a multipart/report body containing a message/delivery-status part. That part is what you parse.
A DSN's status part looks like this:
Content-Type: message/delivery-status
Reporting-MTA: dns; mail.example.com
Final-Recipient: rfc822; nobody@recipient.example
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 <nobody@recipient.example> User unknown
The fields you care about:
Action:failed(a real bounce),delayed(still trying, informational), ordelivered(a success receipt, rare). Onlyfailedis a bounce.Status: the enhanced status code. First digit5= hard,4= soft.Final-Recipient: the address that failed. This is what you suppress, not the original envelope recipient if they differ.Diagnostic-Code: the raw SMTP response, your best source for the human reason.
If you receive bounces as real email (running your own return-path mailbox), here is a minimal parser that extracts the verdict:
// Parse a message/delivery-status block into a bounce verdict.
function classifyDsn(deliveryStatusText) {
const get = (field) => {
const m = deliveryStatusText.match(
new RegExp(`^${field}:\\s*(.+)$`, "im")
);
return m ? m[1].trim() : null;
};
const action = get("Action");
if (action !== "failed") {
return { type: "none", reason: action }; // delayed/delivered, not a bounce
}
const status = get("Status") || ""; // e.g. "5.1.1"
const recipient = (get("Final-Recipient") || "").replace(/^rfc822;\s*/i, "");
const diagnostic = get("Diagnostic-Code") || "";
const isPermanent = status.startsWith("5");
return {
type: isPermanent ? "hard" : "soft",
address: recipient.toLowerCase(),
status,
diagnostic,
};
}
This is deliberately small, but it covers the load-bearing case: pull Action, Status, and Final-Recipient, and the first digit of Status decides hard versus soft. Real DSNs vary (some servers omit the enhanced code, some bury it in the diagnostic text), so a production parser falls back to scraping the 5xx/4xx out of the Diagnostic-Code when Status is missing.
How should I classify and act on each bounce?
Map each bounce to one of three actions: suppress now (hard), retry with backoff (soft), or escalate to suppressed after repeated soft failures. The classification is a small decision tree, and the cost of getting it wrong is asymmetric: re-mailing a hard bounce is far more damaging than briefly retrying a soft one.
Here is the full decision in one function:
// Decide what to do with a parsed bounce, given how many times
// this address has soft-bounced before.
function decideAction(bounce, priorSoftBounces) {
if (bounce.type === "hard") {
return { action: "suppress", reason: "hard_bounce", code: bounce.status };
}
if (bounce.type === "soft") {
// Give up on an address that keeps soft-bouncing across sends.
if (priorSoftBounces >= 3) {
return { action: "suppress", reason: "repeated_soft_bounce" };
}
return { action: "retry", reason: "soft_bounce", code: bounce.status };
}
return { action: "none" };
}
Three rules carry the weight:
- Hard bounce, suppress immediately. No retry, no grace. A
5xxsays the address is permanently bad. Mailing it again is pure downside. - Soft bounce, retry within a bounded window. Transient failures usually clear in hours. Retry at, say, 1 hour, 6 hours, then once a day, for up to 72 hours, then stop for this send.
- Repeated soft bounces across sends, suppress. An address that soft-bounces on every campaign for weeks is effectively dead (a permanently full or abandoned mailbox). Track soft bounces per address over time, and after a few consecutive failures across separate sends, promote it to suppressed. This is the rule most homegrown systems forget, and it is why their bounce rate never fully recovers.
One trap: some providers return a 5xx for what is really a transient policy block (a temporary rate limit dressed up as permanent). Pure code-based classification will hard-suppress a recoverable address. In practice the safe default is to trust the code, because the reputation cost of re-mailing true hard bounces outweighs the occasional false suppression. If you send high volume and want to recover those, keep a separate "blocked, not invalid" bucket and re-validate it occasionally rather than mailing it blind.
What goes on a suppression list and how do I use it?
A suppression list holds every address you must not send to: hard bounces, spam complaints, and unsubscribes, each with a reason and timestamp. You query it before every send and drop any recipient that matches. It is a filter applied before mail goes out, which is the entire point: the list only protects you if you consult it first.
A minimal schema:
CREATE TABLE suppressions (
address TEXT PRIMARY KEY, -- normalized: lowercased, trimmed
reason TEXT NOT NULL, -- 'hard_bounce' | 'complaint' | 'unsubscribe' | 'repeated_soft_bounce'
code TEXT, -- enhanced status code, when from a bounce
created_at INTEGER NOT NULL, -- unix epoch
detail TEXT -- raw diagnostic, for auditing
);
CREATE INDEX suppressions_reason_idx ON suppressions (reason);
Inserting on a bounce (idempotent, so a re-bounce does not error):
INSERT INTO suppressions (address, reason, code, created_at, detail)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(address) DO NOTHING;
And the check that runs before a campaign send, filtering recipients in one query rather than looping:
-- Recipients to actually send to: the list minus anyone suppressed.
SELECT r.address
FROM campaign_recipients r
LEFT JOIN suppressions s ON s.address = r.address
WHERE r.campaign_id = ?
AND s.address IS NULL;
A few rules that make a suppression list trustworthy:
- Normalize addresses before storing and before comparing. Lowercase and trim.
User@Example.comanduser@example.comare the same mailbox; if you store them differently, the suppression silently misses on the next send. (Be aware the local part is technically case-sensitive per RFC, but in practice every major provider treats it case-insensitively, so lowercasing is the pragmatic and safe choice.) - Make insertion idempotent. The same address can bounce many times;
ON CONFLICT DO NOTHINGkeeps the first reason and avoids churn. - Never delete from it casually. A suppressed address is a liability you are choosing not to touch. Removal should be a deliberate, logged action (for example, a recipient re-subscribes through a confirmed double opt-in).
- One list per sending identity, not per campaign. Suppression is a property of your relationship with that recipient, not of a single send. A complaint on your newsletter should suppress them across everything you send, unless you deliberately segment by stream.
The complaint side deserves emphasis: when a recipient hits "report spam," you usually learn through a feedback loop (FBL) from the provider or through the one-click List-Unsubscribe-Post flow. Treat a complaint exactly like a hard bounce, suppress immediately and permanently, because complaints damage reputation even more than bounces do. For the broader playbook these rules belong to, see the bounce and suppression best practices that protect your reputation.
Why does sending to bounced addresses wreck deliverability?
Because bounce rate is a reputation signal that mailbox providers score directly, and a high one reads as a bought, scraped, or stale list. Providers assume a legitimate sender knows who their recipients are; a sender who keeps hammering nonexistent addresses looks like a spammer working through a purchased list, so they throttle or block you.
The mechanics are concrete. Every 550 User unknown you generate is logged against your domain at that provider. A pattern of them, especially clustered in one send, tells Gmail or Outlook that you do not maintain your list. Modern bulk-sender requirements make this explicit: Gmail and Yahoo expect bulk senders to keep complaint rates low and to stop sending to addresses that bounce. Cross their thresholds and your mail gets deferred or filed in spam, including to the valid recipients on the same send. One dirty list poisons delivery to your good addresses.
Spam traps make it worse. Some dead addresses are recycled into spam traps by providers and blocklist operators: a former-real address turned into a tripwire. Mailing one is a strong spam signal and can land you on a blocklist. The only defense is to stop mailing addresses that have gone bad, which is exactly what suppression does. If you do end up listed, the remediation path is its own subject; see getting your domain off an email blocklist.
This is why the suppression check belongs before the send and why hard bounces get suppressed with no grace period. The cost of one extra send to a dead address is not zero; it is a small, cumulative tax on your reputation that eventually delivers your real mail to spam.
How do bounces work when I send over an HTTPS API?
Over an HTTPS-based sender there is no live SMTP session for you to read, so bounces arrive asynchronously as webhook events or status records. The sending service attempts delivery on your behalf, captures the remote server's 5xx/4xx response or the later DSN, and reports it back to you as a bounced event carrying the address, reason, and code. Your job shifts from parsing raw SMTP to consuming that event and updating suppression.
This matters specifically on Cloudflare Workers and other edge runtimes, which cannot open outbound port 25 for direct-to-MX SMTP. Native Email Service sending uses a binding or HTTPS API, and delivery outcomes arrive asynchronously as events. A handler looks like this:
// Webhook receiver for delivery events from an HTTPS sender.
export async function onBounceEvent(event, db) {
if (event.type !== "bounced") return;
const isPermanent = String(event.code || "").startsWith("5");
const reason = isPermanent ? "hard_bounce" : "soft_bounce";
if (isPermanent) {
await db
.prepare(
`INSERT INTO suppressions (address, reason, code, created_at, detail)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(address) DO NOTHING`
)
.bind(
event.address.toLowerCase(),
reason,
event.code,
Math.floor(Date.now() / 1000),
event.diagnostic ?? null
)
.run();
} else {
await recordSoftBounce(db, event.address.toLowerCase()); // retry/escalate logic
}
}
The classification logic is identical to the SMTP case; only the transport changed. You are still keying off the first digit of the status code and still writing hard failures to the same suppression table. The reason edge email needs an HTTPS sender in the first place (no port 25 on Workers) is also the reason your bounce pipeline is event-driven rather than synchronous; build for that and the rest of the logic from this guide carries over unchanged.
Or skip the bounce plumbing: let the stack suppress for you
Everything above is real, necessary work: parse DSNs or webhook events, classify by status code, retry soft bounces with backoff, escalate repeated failures, normalize and store suppressions, and filter every send against the list before it goes out. It is the kind of correctness-critical bookkeeping that is easy to get 90% right and quietly wrong in the 10% that matters (the repeated-soft-bounce escalation, the address normalization, the before-send filter).
Tinbox is a self-contained email stack you deploy onto your own Cloudflare account: D1 for storage, R2 for attachments, Queues for sending. Because every send already routes through the HTTPS path and Queues, delivery events flow back as structured records, and the suppression list lives in your own D1 table, the same shape as the schema above. Hard bounces and complaints are suppressed automatically and checked before every campaign send, so a cleaned list stays clean across campaigns without you wiring webhook handlers by hand. You own the suppression data outright; it is rows in your database, not a list trapped behind a vendor's API that you cannot fully export.
One honest caveat: suppression protects your reputation, it does not repair a list that is already bad. If you import a stale list, the first send will still bounce and you still pay that reputation cost once; suppression just guarantees you never pay it twice. The discipline in this guide (send to engaged recipients, separate your streams, watch your rates) still applies, and the list hygiene habits that keep your sender reputation clean are how you keep bounces low before they ever reach the suppression table. See how to warm up a new sending domain for pacing those early sends, why you separate transactional and marketing sending so one bad campaign cannot poison your receipts, and the email deliverability guide for the fundamentals that keep bounce and complaint rates low in the first place.
Deploy this yourself: npx tinbox init example.com. The deploy wires the delivery-event pipeline and the suppression table for you, so hard bounces and complaints are filtered before your next send. See the docs for the campaign sender and feedback-loop setup.
Frequently asked questions
What is the difference between a hard bounce and a soft bounce?
A hard bounce is a permanent failure: the address does not exist, the domain has no mail server, or the recipient blocked you. You must never send to a hard-bounced address again. A soft bounce is a temporary failure: the mailbox is full, the server is down, or the message was greylisted. Soft bounces can be retried for a bounded window before you give up. The SMTP reply code tells you which: 5xx is permanent (hard), 4xx is transient (soft).
What is an email suppression list and why do I need one?
A suppression list is a record of addresses you must not send to: hard bounces, spam complaints, and unsubscribes. You check it before every send and skip any matching recipient. You need one because mailbox providers score your bounce and complaint rates, and re-sending to a known-dead or unsubscribed address damages reputation and can be a legal violation. Without a suppression list, every campaign re-mails addresses you already know are bad.
What bounce rate is too high?
Keep your hard bounce rate under 2%, and ideally under 1%, per send. Gmail and Yahoo bulk-sender rules treat sustained high bounce and complaint rates as grounds for throttling or blocking. A spike above 2% usually means a stale list or a list you bought; either way, stop and clean it before sending more. A well-maintained list with suppression in place typically bounces well under 1%.
How long should I retry a soft bounce before giving up?
Retry a soft bounce for roughly 24 to 72 hours with backoff (for example at 1 hour, 6 hours, then daily), then stop. Most transient problems (mailbox full, server briefly down, greylisting) clear within that window. If the address keeps soft-bouncing across several sends over weeks, treat it as effectively dead and suppress it; a permanently full or abandoned mailbox is not worth the reputation risk.
Should spam complaints go on the same list as bounces?
Yes. A suppression list should hold hard bounces, spam complaints (from feedback loops and the List-Unsubscribe one-click flow), and unsubscribes, all checked before every send. Complaints are even more damaging than bounces to reputation, so a complaining recipient must be suppressed immediately and permanently, exactly like a hard bounce.
How do I get a bounce notification when sending over an HTTPS API instead of SMTP?
HTTPS-based senders surface bounces as asynchronous webhook events or as entries in a status feed, because there is no live SMTP session to read the reply code from. The provider attempts delivery, captures the remote server's response or the delayed bounce (DSN), and reports it to you as a 'bounced' event with a reason and code. You consume that event and update your suppression list, the same way you would parse an SMTP reply if you ran your own MTA.


