Why nodemailer does not work on Vercel
Your nodemailer code sends mail perfectly on localhost. You deploy to Vercel and the function hangs for ten seconds and then reports a connection timeout. Nothing about your code changed, so the cause is not in your code. It is in what a serverless function is allowed to do and how long it is allowed to live.
4 min read
What SMTP needs, and what a serverless function has
SMTP is a conversational protocol over a long-lived TCP connection. A single send is a sequence of round trips: connect, EHLO, STARTTLS, another EHLO, AUTH, MAIL FROM, RCPT TO, DATA, the message body, QUIT. Each step waits for the server to answer.
A serverless function is the opposite of that. It is created for one request, given a short execution budget, and frozen or destroyed as soon as it responds. There is no connection pool to reuse, because the container that held it is gone.
| SMTP assumes | A serverless function gives you |
|---|---|
| A persistent, reusable connection | A fresh container that may vanish after the response |
| Seconds of handshake latency, amortised over many sends | A short execution budget per invocation |
| Outbound access on ports 25, 465, 587 | Outbound restricted or filtered on many platforms |
| A stable source IP with sending reputation | A shared, rotating egress IP with no reputation of yours |
Four mismatches, and each one is enough on its own.
The three specific failures
Outbound SMTP ports are commonly blocked or unreliable. Port 25 in particular is filtered almost everywhere in cloud environments, because open outbound 25 is how compromised infrastructure sends spam. Ports 465 and 587 are less consistently blocked, but "less consistently" is not something to build on. When the port is filtered you get no error, only a hang, which is why the symptom is a timeout rather than a refusal.
Cold starts eat the budget. The TLS handshake plus the SMTP conversation can take several seconds against a busy mail server. Add a cold start and you are close to the function's limit before the message body is sent. It works when the provider is fast and fails when it is slow, which produces the worst kind of bug: intermittent and unreproducible locally.
Gmail withdrew basic authentication. If your local setup used your Gmail password, it stopped being supported when Google removed less secure app access. You now need an app password, which requires two-step verification on the account, and even then Gmail's SMTP relay has per-day send limits meant for a person, not an application. The details are in why Gmail SMTP fails in production.
There is a fourth, quieter problem. Even when SMTP connects, you are sending from a shared cloud egress IP with no sending reputation attached to you, which is a poor position from which to reach an inbox.
The fix is HTTPS
An HTTPS API turns nine round trips into one. Your function makes a single request, gets a message id back, and returns. The provider holds the queue, the retries, and the reputation. Nothing in that sequence needs a socket you are not allowed to open.
export const runtime = 'nodejs';
export async function POST(request: Request) {
const { to, subject, html, text } = await request.json();
const res = await fetch('https://emails.sh/v1/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ from: 'Acme <hello@acme.com>', to: [to], subject, html, text })
});
if (!res.ok) {
return Response.json({ error: 'send failed' }, { status: 502 });
}
return Response.json(await res.json());
}One request, a few hundred milliseconds, no ports involved. The same code runs unchanged on the Edge runtime, on Cloudflare Workers, and in a Docker container, because fetch exists everywhere and a raw socket does not.
Migrating off nodemailer
The mapping is close to mechanical.
| nodemailer | emails.sh |
|---|---|
createTransport({ host, port, auth }) | Nothing. An API key in the environment |
transporter.sendMail({ from, to, subject, html }) | POST /v1/emails with the same fields |
text option | text field |
replyTo | reply_to |
attachments: [{ filename, content }] | attachments: [{ filename, content_base64 }] |
messageId in the callback | id in the response |
transporter.verify() | A 401 from the first call, immediately |
Before and after, in full.
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
auth: { user: process.env.GMAIL_USER, pass: process.env.GMAIL_PASS }
});
await transporter.sendMail({
from: 'Acme <hello@acme.com>',
to: 'someone@example.com',
subject: 'Reset your password',
html: '<p>Click the link.</p>'
});import { Emailssh } from '@emails.sh/sdk';
const mail = new Emailssh(process.env.EMAILSSH_API_KEY!);
await mail.send({
from: 'Acme <hello@acme.com>',
to: ['someone@example.com'],
subject: 'Reset your password',
html: '<p>Click the link.</p>',
text: 'Click the link.'
});npm install @emails.sh/sdkIf you are keeping nodemailer
Sometimes you cannot change it: a legacy codebase, a library that only accepts a transport, a compliance rule that names SMTP. Two options that actually work.
Move the send off the serverless function. Push a job onto a queue from the function and process it on a long-running worker where a connection pool makes sense. You keep nodemailer and lose the port problem, at the cost of running a worker.
Point nodemailer at a provider's SMTP bridge on port 587 with credentials, not at Gmail. This is still SMTP from a short-lived function and still subject to timeouts, but a provider's relay is faster to hand-shake and does not have a personal daily limit. Treat it as a stopgap.
Check before you blame the code
If a send is timing out, confirm what is reachable before you rewrite anything.
curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST https://emails.sh/v1/emails \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"onboarding@emails.sh","to":["you@example.com"],"subject":"Reachability","text":"ok"}'A 200 from a deployed function means HTTPS works and SMTP was the problem. If you also want the local comparison, try opening port 587 from the same environment and watch it hang.
Next: the full framework walkthrough in how to send email in Next.js with the App Router, or the platform-specific version at Vercel and Next.js.
Questions
- Does nodemailer work on Vercel at all?
- Sometimes, on ports 465 and 587, against a fast relay, when the function does not cold start. That is a lot of conditions, and each one failing produces a timeout rather than a clear error. An HTTPS API removes all of them.
- Why does it work locally but not in production?
- Your laptop has unrestricted outbound access, no execution time limit, and a warm process that can hold a connection. A serverless function has none of those. The code is identical; the environment is not.
- Can I use SMTP on the Edge runtime or Cloudflare Workers?
- No. Those runtimes have
fetchbut no raw TCP sockets, so an SMTP client cannot open a connection at all. See sending email from Cloudflare Workers. - Is the Gmail app password enough to fix this?
- It fixes authentication, not the port, the timeout, or the daily limit. Gmail's SMTP relay is built for a person's mail, not an application's, and it will rate limit you well before your app is large.
Give your agent an address it can answer from.
Create an inbox