Open two webmail tabs and wait for the same verification code. One takes eight seconds to show it; the other shows it in under a second. The slow one polls the server every ten seconds, and the code happened to land right after a poll, so it sat invisible until the next tick. The fast one told the server "hold the line and speak when something changes," and the server did.
The whole subject reduces to one mechanism: polling means the client asks the same question over and over; IDLE means the client asks once and the server holds the connection open until it has an answer. Both work. Both have a bill. Polling's bill is queries per second. IDLE's bill is open sockets — one held TCP connection per folder per client, sitting there doing nothing until a message arrives. And that socket cost, not the polling interval, is what breaks first at scale.
For a temp-mail service this is not academic. The user is staring at an ephemeral inbox waiting for a single OTP. Perceived latency between "code sent" and "code visible" *is* the product. So the question — poll, IDLE, or push — is a load-bearing decision, not a tuning knob.
How polling actually works (and why it isn't dumb)
A polling client is stateless and boring, which is its strength. On an interval it issues a STATUS INBOX (MESSAGES UNSEEN) or a SELECT followed by SEARCH/FETCH, compares the result to what it last saw, and renders any delta. Nothing is held between polls.
The arithmetic is trivial and unforgiving. With N clients on an interval I, you generate N / I queries per second, constantly, whether mail is arriving or not:
- 10,000 clients at 15s → ~667 queries/sec
- 10,000 clients at 5s → 2,000 queries/sec
Two costs are baked in. Latency is bounded by the interval: worst case I, average I/2. A 15-second interval means a 7.5-second average wait for a message that already arrived. Load is constant and paid even on a dead-quiet mailbox — you spend CPU and mailstore I/O to repeatedly confirm nothing happened.
The upside is everything operational. Polling survives NAT and proxy idle-timeouts because each request is short. It is load-balancer-friendly: any request can hit any backend. It scales horizontally by adding stateless workers. This is exactly why the big providers' HTTP interfaces (the Gmail API, Microsoft Graph) are poll-or-webhook and never expose IMAP IDLE: at their concurrency, held connections are a liability and stateless requests are free money.
Realistic intervals in the wild: 5s aggressive (an open inbox tab the user is actively watching), 15–60s typical foreground, 5min+ for backgrounded or throttled tabs.
IMAP IDLE: the long-poll that pretends to be push (RFC 2177)
IDLE is defined in RFC 2177, a two-page extension to IMAP4rev1 (RFC 3501, now rev2 / RFC 9051). The flow is small and worth reading literally:
a1 SELECT INBOX
* 3 EXISTS
* 0 RECENT
a1 OK [READ-WRITE] SELECT completed
a2 IDLE
+ idling
* 4 EXISTS
* 1 RECENT
DONE
a2 OK IDLE terminated
a3 FETCH 4 (FLAGS BODY.PEEK[HEADER.FIELDS (FROM SUBJECT)])You SELECT a folder, send IDLE, and the server answers with the continuation + idling. Now the client blocks — it sends nothing and reads. When state changes, the server pushes untagged responses on the same socket: * 4 EXISTS (new message count), * 1 RECENT, * n EXPUNGE on deletion, * n FETCH (FLAGS ...) on a flag change. The client reacts by sending DONE to break out of IDLE, then issues a normal FETCH for the new message. This is the "push" people believe their mail client has. It is not push. It is a held long-poll: latency is near zero, but the server never initiates a connection — it speaks down one the client is holding open.
Which brings the constraint that trips everyone. RFC 2177 says the client MUST re-issue IDLE at least every 29 minutes, because servers and middleboxes silently drop connections they judge idle. In practice you re-arm sooner — around 24 minutes, and lower behind aggressive carrier-grade NAT or corporate proxies that reap at 5–10 minutes. Re-arming means sending DONE, then IDLE again. Miss the window and the connection is a zombie: still open on your side, garbage-collected on theirs, and you stop getting notifications with no error.
Two more facts shape the architecture. IDLE watches exactly one folder per connection. Watching INBOX and a Junk folder is two SELECTs on two sockets. And IDLE is capability-gated — the server must advertise it in CAPABILITY before you rely on it. The multi-folder successor, NOTIFY (RFC 5465), can watch several mailboxes over one connection, but client support is so thin that in 2026 you still design as if it doesn't exist.
The connection cost, quantified
Here is the spine of the whole thing. One IDLE client is one persistent TCP+TLS socket the IMAP server must hold open indefinitely. On Dovecot that means a live imap connection, at least one file descriptor, socket send/receive buffers in kernel memory, TLS session state, plus a mailbox index attach and lock. Call it a few MB RSS per connection depending on config — trivial for one, decisive for ten thousand.
Do the comparison honestly:
- 10,000 IDLE watchers = 10,000 sockets held open, 10,000 FDs, 10,000 sets of socket buffers and TLS state, all resident even when zero mail is flowing. Near-zero query load, high standing footprint.
- 10,000 pollers at 15s = ~667 short-lived queries/sec, each opening and closing, with almost no idle footprint. High churn, low standing footprint.
That is the crossover. IDLE wins on latency and total bytes-on-wire (it doesn't re-ask). Polling wins on the concurrency ceiling and operational simplicity (nothing to hold). Which one you can afford depends on whether your bottleneck is standing memory/FDs or per-second query throughput.
The knobs you will actually hit, in the order they bite:
ulimit -n— the default 1024 open files per process is nowhere near enough; you exhaust it long before RAM.- Dovecot
service imap { client_limit = N }andprocess_limittogether gate concurrent connections; the shipped defaults are conservative, so raise both alongsidedefault_vsz_limitto keep processes from being OOM-killed. fs.file-max— the system-wide FD ceiling.- On any NAT or reverse-proxy node in front:
net.ipv4.ip_local_port_range(ephemeral port exhaustion — the default 32768–60999 range is only ~28k ports) andnet.netfilter.nf_conntrack_max(the conntrack table fills silently and drops new flows well before you approach CPU limits).
And a trap: TCP keepalive is necessary but not sufficient. A middlebox will happily report a dead connection as alive, which is precisely why RFC 2177 mandates *application-level* re-IDLE instead of trusting the transport.
Why phones don't use IDLE anymore
Hold a TCP socket open on a phone and you hold the radio awake, which shreds battery. iOS makes the point moot by killing background sockets outright. So no modern mail app keeps a per-account IMAP IDLE connection running on the device — that era ended years ago.
What actually happens: the provider's server holds the IDLE (or watches an internal event bus) on the user's behalf, and when mail lands it fires a payload-less push through APNs (iOS) or FCM (Android). The push carries no message content — it's a doorbell. The device wakes, opens a fresh connection, and does a normal fetch. The "push notifications" your mail app appears to have are a server-side IDLE fan-out plus a platform push channel, not IMAP push to the handset.
The largest providers skip IMAP for this entirely. Gmail exposes users.watch on the Gmail API, which publishes change notifications to a Cloud Pub/Sub topic. Microsoft Graph uses webhook subscriptions (POST /subscriptions) that expire in roughly three days for mail and must be renewed on a timer. Exchange historically did EAS "ping." None of them make a client sit on a held IMAP socket, for exactly the reasons above.
What we run at evilmail (and how to choose)
For ephemeral inboxes, server-side event push beats both dumb polling and per-client IDLE, and it isn't close. Because we own the mailstore (Dovecot, mail under /var/mail/vhosts/{domain}/{username}/, uid/gid 5000), the delivery path already knows the instant a message lands. A Sieve script or an LMTP/delivery hook publishes an event the moment the message is written, and that event is pushed to the open browser tab over SSE or WebSocket. Result: sub-second delivery-to-visible, and one long-lived connection per open tab regardless of any IMAP internals — no per-user IMAP socket held on the mail server at all. The thing the user is waiting for lights up as fast as the MDA can write it.
If you have no choice but to talk raw IMAP to a mailbox you own, run a small pool of IDLE workers — a handful of connections fanning many users' events onto your bus — never one IDLE socket per end user.
The decision rule, compressed:
- A few hundred concurrent watchers on a mailbox you control, hard low-latency need → per-connection IDLE is fine.
- Thousands of ephemeral, anonymous clients → server-side push over WebSocket/SSE, fed by the MDA. Don't make browsers hold IMAP.
- Watching someone else's mailbox (Gmail/Outlook) → use their webhook/subscription API first, fall back to IDLE, fall back to polling as the last resort.
Checklist
- Check the
IDLECAPABILITY before assuming it exists; never rely on it blindly. - Re-arm IDLE every ≤24 minutes, with jitter, so reconnects don't thundering-herd.
- Send
DONEbefore any other command; never pipeline through an open IDLE block. - One connection per folder — budget FDs accordingly (
ulimit -n, Dovecotclient_limit/process_limit). - Set TCP keepalive and application-level re-IDLE; middleboxes report dead connections as alive.
- Behind NAT/proxies, watch
nf_conntrack_maxand ephemeral ports, not just RAM. - For your own mailstore, push from the MDA (LMTP/Sieve) → WS/SSE; don't make browsers hold IMAP.
- For third-party mailboxes, prefer provider webhooks; IDLE is a fallback, polling the last resort.
To test the raw flow by hand: openssl s_client -connect imap.evilmail.pro:993 -crlf, log in, a SELECT INBOX, then a IDLE and watch the untagged responses roll in when mail arrives. curl can't do IMAP IDLE; reach for imapflow (Node), imaplib IDLE (Python 3.13+), or doveadm if you're driving Dovecot directly.
The interval you pick is a latency knob; the connection model you pick is a scaling ceiling. Set the second one first.


