What happens when forty messages arrive at once
Inbound mail does not arrive at a steady rate. It arrives when a newsletter goes out, when a service goes down, when a batch job in someone else's company fires at nine. An agent that handles one message beautifully can fail badly at forty in a minute, and the failures are specific enough to design against.
5 min read
The four failures a burst produces
Double replies. Two runs pick up the same message, usually because a webhook was delivered twice or because a retry overlapped a slow handler. The recipient gets two answers, and the second one is often worse.
Racing on one thread. Two messages from the same person arrive seconds apart. Two runs start, both read the thread as it was, and both answer without seeing the other's reply. The customer gets two half-answers to a conversation that has now forked.
Allowance burned in ninety seconds. The agent works exactly as designed and spends the day's sending in a burst, then refuses real work at two in the afternoon with daily_send_quota_exhausted.
Rate limits hit and made worse. 300 requests a minute per key, and an unbounded fan-out will find that ceiling, get 429s, and, if written naively, retry into them.
Serialise per thread, parallelise across them
The single most useful rule: one run at a time per thread, many threads at once.
A thread is a conversation with state, so concurrent work on one is the race described above. Different threads share nothing, so there is no reason to make them wait for each other. In practice that means a lock or a queue keyed on the thread id, not a global one.
// One in flight per thread; threads run in parallel.
await withThreadLock(event.thread_id, async () => {
if (await handled(event.message_id)) return; // idempotency, still required
const thread = await mail.getThread(event.thread_id);
await answer(thread);
await markHandled(event.message_id);
});If you are on a platform with per-entity concurrency built in, use it: a Durable Object per thread, or an actor, gives you this for free, which is one reason the Agents SDK shape suits email agents. Otherwise a small distributed lock is enough.
Note that idempotency is still needed inside the lock. Locking stops two runs racing; it does not stop the same message being delivered twice five minutes apart. The two mechanisms solve different problems and you want both, per webhooks or polling.
Coalesce, do not queue up, consecutive messages
When someone sends three messages in two minutes, the correct behaviour is usually one reply that addresses all three, not three replies.
A short debounce achieves that: on inbound, wait a few seconds before starting work, and if another message arrives on the same thread, reset the timer and read the thread again. Cheap to implement, noticeably more human in effect, and it saves both a send and a model call.
The same instinct applies to a person who replies to your reply immediately. Answering within a second of their message is not always a virtue; it can read as automation in a way that a fifteen second pause does not.
Shape the outbound side
Inbound bursts are not something you control. What you can control is how fast the answers leave.
- Cap concurrent sends at a level well under your rate limit, so a burst degrades into a slightly slower queue rather than a wall of 429s.
- Prioritise replies over anything cold. If the allowance is going to run out, spend it on people who wrote to you.
- Back off on the reason, not on a guess.
daily_send_quota_exhaustedandmonthly_send_quota_exhaustedcarry a reset, and a 429 carries retry-after in seconds. Waiting exactly that long is both correct and cheaper than exponential guessing. - Alert on the burst itself. Volume moving sharply is worth a page whether the cause is a real spike or a loop, per what to monitor in production.
Ordering, and how much to care
Email is not ordered. Messages can arrive out of sequence, a reply can appear before the message it answers if a relay was slow, and clock skew makes timestamps a weak sort key.
The pragmatic answer: order within a thread using the thread as returned by get_thread, which is assembled from the references rather than from arrival time, and do not attempt to order across threads at all. Cross-thread ordering is almost never a real requirement, and treating it as one produces a system that stalls behind one slow conversation.
Testing a burst before it tests you
Three tests worth having, all cheap:
- Duplicate delivery. The same event twice, asserting one reply.
- Two messages, one thread, simultaneously. Assert one reply that reflects both, or two replies in the correct order, whichever you designed for.
- Fifty messages across fifty threads. Assert no 429 storms, and that total sends stayed inside the allowance you expected.
The setup for all three is in test inboxes for agent development, and the third one is worth running against a whitelisted test identity before any launch that might attract attention.
Questions
- How should an AI agent handle many emails arriving at once?
- Serialise per thread and parallelise across threads, keep an idempotency check inside the lock, cap concurrent sends below the rate limit, and back off using the reason returned rather than guessing.
- Why does my agent sometimes reply twice?
- Either a webhook delivered twice with no idempotency check, or two runs racing on the same thread. They are different bugs and you need both a handled-message record and a per-thread lock.
- Should the agent answer each message separately?
- Usually not. Debounce for a few seconds so consecutive messages in one thread coalesce into a single reply that addresses all of them.
- What about ordering?
- Order within a thread using
get_thread, which assembles from references rather than arrival time. Do not try to order across threads; it is rarely a real requirement and it stalls everything behind one slow conversation. - How do I stop a burst spending the whole daily allowance?
- Cap concurrency, prioritise replies over cold sends, and alert on sharp volume changes. A refusal carries a reset time, so an agent that reads it waits rather than hammering.
- Do I need a queue?
- Some form of one, yes, even if it is a lock plus a retry. The alternative is doing model calls inside webhook handlers, which times out, retries, and produces the duplicates you were trying to avoid.
Give your agent an address it can answer from.
Create an inbox