DEV Community

Rahul S
Rahul S

Posted on

We audited 163,596 disposable-email domains by last-seen date. Only 867 landed in the middle.

We keep a corpus of disposable / throwaway email domains — the list an API consults when you ask it whether x@mailinator-ish-thing.com is a real mailbox or a burner. On 2026-08-27 it held 163,596 domains.

I went to write a post about the long tail of that list. Instead I spent the afternoon discovering that the number I was about to publish was wrong, and that the interesting finding was hiding in a bucket I nearly ignored.

Here is the distribution of those 163,596 domains by when we last saw them on any source feed:

Last seen Domains Share
under 30 days 75,787 46.3%
30–90 days 303 0.19%
90–180 days 564 0.34%
over 180 days 86,942 53.1%

Look at the middle two rows. 867 domains out of 163,596 — 0.53% — are in the middle. Everything else is either fresh this month or hasn't been re-confirmed in half a year.

Decay processes don't look like this

If disposable domains gradually aged out — registered, used, abandoned, dropped from feeds — you would expect a smooth slope. Lots in the recent buckets, fewer as you go back, a tail. Something monotonic.

A distribution that is 46% at one end, 53% at the other, and 0.5% in between is not one population decaying. It is two populations glued together:

  1. A live set that gets refreshed on every feed pull, so its last_seen is always ~now.
  2. A fossil layer that entered the corpus once and was never re-confirmed by anything.

The gap between them isn't a finding about disposable email. It's a finding about the list — and the same shape will show up in yours, because most blocklists are assembled the same way: union a bunch of sources, add on sight, and never define what removal means.

Three ways I nearly published a wrong number

This is the part worth stealing, because all three are generic.

1. The "second" data point was the first one wearing a hat.

The corpus has an active flag, and separately a last_seen date. 76,090 domains are active. I was going to write "76,090 active, and 53% not seen in 180 days" as two corroborating facts.

They're one fact. (<30d) + (30–90d) = 75,787 + 303 = 76,090, exactly. And (90–180d) + (>180d) = 87,506, which is exactly the inactive count. active is last_seen, thresholded at 90 days. Reporting both is double-counting one variable and calling it agreement.

2. A quarter of the corpus could not physically land in the bucket I was dividing by.

The snapshot is dated 2026-08-27. Domains entered the corpus like this:

Added Domains
2026-01 123,976
2026-03 35,563
2026-04 … 2026-08 4,057 combined

Two bulk imports are 97.5% of everything. Now: a domain first added in March 2026 has a maximum possible observed age of 179 days on the snapshot date. It cannot appear in the ">180 days" bucket. Neither can anything added later. Only the January cohort — 123,976 domains — is even eligible.

So "53%" divides an eligible-only numerator by an everyone denominator. Against the cohort that could actually qualify, the fossil rate is 86,942 / 123,976 = 70.1%.

The published number would have been wrong in the flattering direction, which is the direction you never catch by sniff test.

3. The live half has no measured lifetime at all.

Those 75,787 domains seen within 30 days are still listed. Their time-to-stale is not 30 days — it's unknown, and greater than or equal to however long they've been in the corpus. That's right-censoring, and it means this data cannot support a sentence of the form "a disposable domain lasts N days." Not with a different denominator, not with better phrasing. The observation window is ~8 months old and most of the mass arrived on day one.

What it can support is a stock statement: on this date, this share of our corpus was stale. That's it. That's the whole claim.

What the rest of the structure says

Two cross-sectional numbers, which don't have the duration problem:

75% of the corpus rests on a single source. Counting how many independent feeds list each domain: 122,685 domains (75.0%) appear on exactly one, 28,958 on two, 11,553 on three, 400 on four or five. Three quarters of the list is one source's uncorroborated say-so — and if that source stops refreshing, those domains quietly become fossils without anyone getting an alert.

8.6% of the corpus sits on five free TLDs. .ml 3,276, .ga 3,145, .tk 2,712, .cf 2,651, .gq 2,350 — 14,134 domains on the free-registration TLDs, versus 27,907 on .com (17.1%). Free-registration programs on those TLDs have been widely reported as wound down since 2023; I can't verify that from our data, but if it's right, a meaningful slice of that fossil layer is domains that can no longer be registered at all.

One caveat on a number I'm not leaning on: the corpus groups 10,456 domains under 1,692 parent domains, and the top parents are real burner operations (anonbox.net 938 children, emlhub.com 495, laste.ml 423). But the extraction is naive string-splitting, not Public Suffix List aware, so entries like com.pl, edu.pl, io.vn, my.id and the dynamic-DNS host dynv6.net show up as "parents" when their children are independent registrations. That's 798 domains, 7.6% of the grouped set — bounded, but it means "6.2 subdomains per parent" is an upper bound on operator concentration, not a measurement of it.

If you run a blocklist, three things to check

  1. Are your "two signals" one signal? Check whether your active/enabled flag is derived from your timestamp. If it is, you have one variable.
  2. Can every row reach every bucket? Any duration bucket wider than the age of your newest bulk import silently excludes rows. Divide by what's eligible.
  3. What does a still-present row mean? If your live entries have an unbounded age, you cannot publish a lifetime. Publish a snapshot share and say so.

And the practical one: a fossil layer isn't free. Every stale domain is false-positive surface — a domain that stopped being disposable but never left your list is a real user getting bounced at signup. We don't have data on how often that fires, so I'm not going to pretend to quantify it. But 53% of a list that nothing has re-confirmed in six months is a number worth knowing about your own data.


We do disposable-domain and IP checks as an API — the docs are here if you want to see the shape of it, and there's a free-tier check you can run without an account. Numbers in this post are aggregates over our own threat corpus; no customer data, no raw addresses.

If you've audited your own blocklist by last-seen date, I'd like to know whether you got the same empty middle. My guess is you did.

Top comments (0)