Sending email from serverless functions: pitfalls and patterns
Functions that freeze after the response, time out mid-send or retry twice break email in odd ways. The pitfalls of serverless sending and patterns that work.

On this page(12 sections)
- How serverless differs
- Pitfall 1: the function ends before the send completes
- Pitfall 2: sending inside the request path
- Pitfall 3: retries cause duplicates
- Pitfall 4: timeouts shorter than the send
- Pitfall 5: connection overhead and cold starts
- Pitfall 6: bursts hit rate limits
- Pitfall 7: secrets and environments
- Pitfall 8: no visibility
- A pattern that works
- Testing
- Key takeaways
Serverless functions are a natural place to send email. A user signs up, a function runs, a welcome message goes out. There is no server to manage, and it scales without effort.
Then the odd failures begin. A confirmation email sometimes never arrives. A receipt arrives twice. A function takes eight seconds because the email provider was slow, and the user sees a timeout. None of these are bugs in the email provider. They come from how serverless runtimes behave, and they have known solutions. This guide covers the pitfalls and the patterns that avoid them.
It builds on queueing outbound email, which explains why sends should be asynchronous in any architecture. Serverless makes that advice more important.
How serverless differs
A few properties of function platforms matter for email:
- Short lifetimes. Functions have time limits, often seconds to minutes. The platform may freeze or stop the function as soon as it returns a response.
- No persistent connections. Each invocation may start with a cold environment, with no open connection to anything.
- Automatic retries. Many platforms retry a function that fails or times out, sometimes without telling you.
- Concurrency bursts. A spike in events can start hundreds of functions at once.
- Statelessness. There is no local memory or disk you can rely on between runs.
Each of these has an email consequence.
Pitfall 1: the function ends before the send completes
A common pattern is to start the send without waiting for it, return a response and move on:
handler:
sendEmail(...) // not awaited
return response
On a traditional server this often works. In a function, the platform may freeze the process the instant the response is sent, and the send silently never finishes.
Fix: wait for the send to complete before returning, or hand the work to something that runs outside the request, such as a queue or the platform's own background-task feature. Do not rely on unfinished work after the response.
Pitfall 2: sending inside the request path
If the function waits for the email provider before replying, the user waits too. Provider latency, rate limits and outages become your latency and your errors.
Fix: put the message on a queue and return immediately. A separate worker, or another function triggered by the queue, does the sending. The user sees a quick response, and a provider slowdown does not break sign-up. See queueing outbound email.
Pitfall 3: retries cause duplicates
A function times out after the provider accepted the message but before your code recorded success. The platform retries, the function sends again, and the customer gets two emails. This is worst for receipts, password resets and one-time codes.
Fix: make sends idempotent.
- Generate a stable key for each logical email, such as
order-1042-receipt, not a random value per attempt. - Send that key with the request if the provider supports it, so repeats are treated as one. See idempotency keys for email sends.
- Also record sends in your own store before and after, with a unique constraint on the key, so you can detect a repeat yourself.
Pitfall 4: timeouts shorter than the send
If your function limit is five seconds and the provider occasionally takes six, those messages fail even though they may eventually be sent.
Fix:
- Set an explicit client timeout shorter than the function limit, so you can fail cleanly and retry.
- Keep the function's own limit comfortable.
- Treat a timeout as unknown, not as failure: the message may have been sent. Use idempotency keys when you retry.
Pitfall 5: connection overhead and cold starts
With SMTP, every invocation may need to connect, negotiate encryption and authenticate before sending. That adds latency, and connections cannot be reused across cold starts. See SMTP or API for sending email.
Fix:
- Prefer an HTTP API, which needs one request per message and works well with short-lived processes.
- Create the client outside the handler so warm invocations reuse it.
- If you must use SMTP, use a queue worker that runs longer and keeps connections open, not the request function.
Pitfall 6: bursts hit rate limits
A marketing event or a bug can trigger thousands of functions at once, each trying to send. The provider answers with rate-limit errors, and your retries make it worse.
Fix:
- Limit concurrency on the sending function or worker to a number your provider accepts.
- Handle
429responses with backoff and jitter. See handling rate limits and outbound retry and backoff. - Use a queue to smooth bursts instead of letting every event send immediately.
Pitfall 7: secrets and environments
Functions often run in several environments, and a staging function with a production key can email real customers.
Fix:
- Store keys in the platform's secret manager, not in code or build logs. See email API key security.
- Use separate keys per environment and per service, scoped to sending.
- In staging, use recipient allowlists or a sandbox. See keeping staging from emailing real customers.
Pitfall 8: no visibility
When functions fail, logs are scattered, and sends are hard to trace.
Fix:
- Log a message id and your own correlation id on every send, plus the outcome.
- Record each message's status in a table, updated by webhooks when it is delivered, bounced or complained about.
- Alert on error rates and on queue depth.
A pattern that works
For most products, this layout is reliable:
- An event happens (signup, payment, schedule).
- The handler writes an outbox row with the email type, recipient, template data and an idempotency key, in the same transaction as the event if you can.
- It returns quickly. The user is not waiting on email.
- A worker function or a queue consumer reads outbox rows, sends them through the provider's API with the key and updates the status.
- Retries use backoff, with a cap and a dead-letter state for repeated failure.
- Webhooks update delivery status, and a small dashboard shows pending, sent and failed.
If you need a delayed send, such as "tomorrow at 9", schedule it in your own system rather than relying on a function that sleeps. See scheduling emails across time zones.
Testing
- Simulate a timeout after success and check that a retry does not send twice.
- Simulate a provider error and check backoff.
- Fire many events at once and check the concurrency limit.
- Kill a worker mid-send and check that the row is picked up again.
Key takeaways
- A function may stop as soon as it returns, so never leave a send unfinished after the response.
- Move sending out of the request path with an outbox and a queue.
- Make every send idempotent, because platforms retry and timeouts hide success.
- Use an HTTP API, reuse clients, set explicit timeouts and limit concurrency.
- Keep secrets, environments and logs tidy so failures are visible and safe.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


