DEV Community

Posting Dude
Posting Dude

Posted on

1 of 41 Cold Emails Bounced. That's 2.4% or 1.9%, Depending on What You Divide By

On Nov 9 I sent 41 first emails for cold outreach for Posting Dude, a backlink service I run. A day later my sending tool said 40 were delivered and 1 bounced.

1 in 41 is 2.4%. My pause line is 2%. So I should stop, right?

Except that batch wasn't the whole stream. Counting every first email I'd sent in that sequence so far, it was 1 bounce in 53. That's 1.9%. Under the line.

Same bounce, two answers. Here is how I decided, the small script I now run before any follow-up goes out, and the two DNS cases that would have bounced if I hadn't looked.

Why bother before the follow-up

Follow-ups are where a dirty list hurts twice. A bounced address in step 1 is one bad send. Leave it in the sequence and it bounces again on day 4, and again on day 11. Google's sender guidelines literally say to "automatically unsubscribe recipients who have multiple bounced messages" (Google's email sender guidelines). Mailbox providers keep score, and repeat bounces are the cheapest way to lose it.

So the check runs the day after the first batch, before day 4.

The 5 steps

1. Send the first email to a small batch. 41 people, not the whole list. If the list is bad, you find out on 41 addresses instead of 400.

2. A day later, pull the delivery status of every message. Not "sent". Sent only means your tool accepted it. You want delivered / bounced per address, from your sending tool's API or export. Mine gave 40 delivered, 1 bounced.

3. Divide by first emails only. Follow-ups go to people who already received mail from you, so they barely bounce (mine: 0). If you put them in the denominator your rate looks better than your list really is. Batch: 1/41 = 2.4%. Stream: 1/53 = 1.9%.

4. Set the pause line before you look. Mine is 2%, decided before the first send. Under it, keep going. Over it, stop and clean the list before anything else goes out. Deciding after you see the number is how 2.4% turns into "basically 2".

5. Never follow up a bounced address. Pull it out of the sequence the same day, so day-4 only goes to inboxes that actually took mail.

So which number won?

Both, for different decisions.

  • Stream-wide (1.9%) decides whether the sequence keeps running. It was under the line, so day-4 follow-ups stay scheduled for Nov 13.
  • Batch (2.4%) decides how fast I grow. That batch was over the line, so the next batch stayed at about 40 instead of going up. I only raise it if the next batch comes back with 1 bounce or fewer.

The one bounce was a role inbox (a support@ address), and it's suppressed now. Also, to be clear about results: zero human replies so far. One auto-ticket reply, which doesn't count. A clean list doesn't make people answer; it just stops you from burning the domain while you find out.

The script

No dependencies, Node 18+. Feed it a CSV export: email,step,status,code, where step is first or followup and code is the SMTP enhanced status code if your tool gives one.

// node bounce-check.mjs sends.csv [pauseLine=0.02]
// sends.csv columns: email,step,status,code   (step = first | followup)
import { readFileSync, writeFileSync } from "node:fs";

const [file, pauseArg = "0.02"] = process.argv.slice(2);
const pauseLine = Number(pauseArg);
const rows = readFileSync(file, "utf8").trim().split("\n").slice(1)
  .map((l) => { const [email, step, status, code = ""] = l.split(",").map((s) => s.trim());
    return { email: email.toLowerCase(), step, status: status.toLowerCase(), code }; });

const firsts = rows.filter((r) => r.step === "first");
const bounced = (r) => r.status === "bounced";
const hard = (r) => bounced(r) && !r.code.startsWith("4"); // 5.x.x or no code = treat as permanent
const firstBounces = firsts.filter(bounced);
const rate = firstBounces.length / firsts.length;

console.log(`first emails: ${firsts.length}, delivered: ${firsts.filter((r) => r.status === "delivered").length}, bounced: ${firstBounces.length}`);
console.log(`bounce rate vs first emails: ${(rate * 100).toFixed(1)}%  (pause line ${(pauseLine * 100).toFixed(1)}%)`);
console.log(rate > pauseLine ? "STOP: over the pause line. Clean the list before anything else goes out." : "OK: under the pause line.");

// never follow up an address that bounced hard, anywhere in the stream
const dead = new Set(rows.filter(hard).map((r) => r.email));
const soft = new Set(rows.filter((r) => bounced(r) && !hard(r)).map((r) => r.email));

async function mx(domain) {
  const res = await fetch(`https://dns.google/resolve?name=${domain}&type=MX`).then((r) => r.json());
  if (res.Status === 3) return "NXDOMAIN (domain doesn't exist)";
  const recs = (res.Answer || []).filter((a) => a.type === 15).map((a) => a.data);
  if (recs.length === 0) return "no MX (mail falls back to the A record, risky)";
  if (recs.length === 1 && /^0 \.$/.test(recs[0])) return "null MX (domain says it takes no mail)";
  return "ok";
}

const due = firsts.filter((r) => r.status === "delivered" && !dead.has(r.email));
const keep = [];
for (const r of due) {
  const verdict = await mx(r.email.split("@")[1]);
  if (verdict === "ok") keep.push(r.email); else console.log(`skip ${r.email}: ${verdict}`);
}
for (const e of soft) console.log(`hold ${e}: soft bounce (4.x.x), retry later, don't sequence it yet`);
for (const e of dead) console.log(`suppress ${e}: hard bounce`);
writeFileSync("followups.csv", "email\n" + keep.join("\n") + "\n");
console.log(`follow-ups due: ${keep.length} -> followups.csv`);
Enter fullscreen mode Exit fullscreen mode

I used Google's DNS-over-HTTPS endpoint because the box I run this on intercepts plain DNS. If yours doesn't, dns.promises.resolveMx works too.

Run against a made-up 7-row file (fake people, real domains), today:

first emails: 7, delivered: 5, bounced: 2
bounce rate vs first emails: 28.6%  (pause line 2.0%)
STOP: over the pause line. Clean the list before anything else goes out.
skip cara@example.com: null MX (domain says it takes no mail)
skip dev@no-such-domain-2026-11-11.com: NXDOMAIN (domain doesn't exist)
hold eli@outlook.com: soft bounce (4.x.x), retry later, don't sequence it yet
suppress support@shop.example: hard bounce
follow-ups due: 3 -> followups.csv
Enter fullscreen mode Exit fullscreen mode

The parts that are easy to get wrong

Hard vs soft. RFC 3463 splits delivery failures into 4.x.x ("persistent transient failure", a full mailbox, a server that's down) and 5.x.x ("permanent failure", not likely to work if you resend). Suppress 5.x.x for good. Hold 4.x.x and retry later, but don't let it ride the sequence on autopilot. If your tool gives no code, treat it as hard. Guessing soft is how dead addresses get three emails.

"Delivered" doesn't mean the domain takes mail now. Some status APIs report what happened at send time. A day later a startup can be gone. That's why the script re-checks MX right before follow-ups.

Null MX. example.com publishes an MX record of 0 . today. That's RFC 7505's "null MX": the domain is telling you it accepts no mail at all. A naive check that only asks "is there an MX record?" passes it. The script catches it.

No MX at all. Mail then falls back to the domain's A/AAAA record. Sometimes that works, often it's a web server that won't take mail. I skip these for cold follow-ups; you might not.

If you'd rather eyeball one domain than run a script, the free email DNS checker on Uno (a directory I run) shows MX, SPF and DMARC with a plain verdict, including "this domain can't receive mail". And before any of this, your own sending domain needs SPF, DKIM and DMARC set up, which I wrote up in this SPF/DKIM/DMARC check before a launch email. The longer playbook on picking who to email in the first place is in how to send cold emails to your first B2B customers.

What I'd tell myself a week ago

  • Pick the pause line before the first send, and write it down.
  • Bounce rate is per first email. Keep follow-ups out of the denominator.
  • Look at both the batch and the stream. They answer different questions.
  • One bounce in 41 isn't a crisis. One bounce you email three times is a habit.

What pause line do you use for cold email, and do you count it per batch or per sequence? I picked 2% mostly by gut and I'd like to hear where other people landed.

Top comments (0)

⌂ 换网址