{"id":474,"date":"2026-10-10T01:30:02","date_gmt":"2026-10-10T07:00:02","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=474"},"modified":"2026-10-10T08:49:07","modified_gmt":"2026-10-10T14:19:07","slug":"sending-email-from-serverless-and-edge-functions","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/sending-email-from-serverless-and-edge-functions\/","title":{"rendered":"Sending Email from Serverless and Edge Functions"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Your function returns <code>200 OK<\/code>. The log shows no error. The user never receives the email.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Or the opposite: the code that worked on your laptop now throws a module resolution error the moment you deploy it, complaining about a package you never imported directly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both failures come from the same misunderstanding. &#8220;Serverless&#8221; describes two completely different runtimes, and they give opposite answers to the question of whether you can speak SMTP at all. Until you know which one your function runs in, you are guessing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: How Do You Send Email from a Serverless Function?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">It depends on the runtime, and there are only two cases:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Node.js serverless<\/strong> (AWS Lambda, Vercel Node functions, Netlify Functions, Cloud Functions) \u2014 SMTP works. Create the transport per invocation, disable pooling, and <code>await<\/code> the send before returning.<\/li>\n\n\n\n<li><strong>Edge runtime<\/strong> (Cloudflare Workers, Vercel Edge, Deno Deploy) \u2014 SMTP libraries will not run. Send over HTTPS with <code>fetch()<\/code> instead.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code># Node.js serverless \u2014 SMTP over the submission port\nSMTP_HOST=smtp.photonrelay.com\nSMTP_PORT=587\nSMTP_USER=your_project_api_user\nSMTP_PASS=your_secret_api_key<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A relay such as <a href=\"https:\/\/www.photonconsole.com\/\">PhotonConsole<\/a> accepts both routes on the same account, so the runtime decides the protocol without changing providers.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Runtime Decides the Protocol<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"512\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow-1024x512.png\" alt=\"Decision diagram showing Node.js serverless runtimes able to use SMTP on port 587 while edge isolate runtimes must use an HTTP email API over fetch\" class=\"wp-image-476\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow-1024x512.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow-300x150.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow-768x384.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow-1536x768.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/Runtime-Protocol-Decision-Flow.png 1774w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">SMTP needs a TCP socket. In Node.js that comes from the built-in <code>net<\/code> module, which every SMTP client depends on underneath.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Edge runtimes are not Node. They are V8 isolates that expose web platform APIs \u2014 <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Window\/fetch\" target=\"_blank\" rel=\"noopener\"><code>fetch()<\/code><\/a>, <code>Request<\/code>, <code>Response<\/code> \u2014 and deliberately omit Node&#8217;s native modules. There is no <code>net<\/code> module to import, which is why a Node SMTP library fails at build or bundle time rather than at send time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Some edge platforms now expose a low-level socket primitive of their own, but no mainstream SMTP client targets it. In practice, edge means HTTP.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Runtime<\/th><th>Examples<\/th><th>SMTP possible<\/th><th>Use<\/th><\/tr><\/thead><tbody><tr><td>Node.js serverless<\/td><td>Lambda, Vercel Node functions, Netlify Functions<\/td><td>Yes<\/td><td>SMTP on 587, no pooling<\/td><\/tr><tr><td>Edge isolate<\/td><td>Cloudflare Workers, Vercel Edge, Deno Deploy<\/td><td>No<\/td><td>HTTP API via <code>fetch()<\/code><\/td><\/tr><tr><td>Container<\/td><td>Cloud Run, Fargate, App Runner<\/td><td>Yes<\/td><td>SMTP with normal pooling<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you are in Next.js specifically, the runtime is a per-route choice and the failure modes are different enough to warrant their own treatment \u2014 our guide to <a href=\"https:\/\/photonconsole.com\/blog\/nextjs-send-email-server-actions\/\">sending email from Next.js<\/a> covers server actions, route handlers and the <code>runtime<\/code> export.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure 1: The Function Freezes the Moment It Returns<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"512\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery-1024x512.png\" alt=\"Comparison showing an unawaited email send abandoned when the serverless function returns, against an awaited send that completes before the response\" class=\"wp-image-477\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery-1024x512.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery-300x150.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery-768x384.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery-1536x768.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/10\/PhotonConsole_-Await-Ensures-Delivery.png 1774w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">An unawaited promise is not slow. It is abandoned the instant the handler returns.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This is the cause of almost every &#8220;returned 200, no email&#8221; report, and it is invisible in testing because a long-running local process finishes the work anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A serverless execution environment is suspended once the handler returns its response. Work still in flight does not continue in the background. It simply stops.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ BROKEN \u2014 the send is abandoned at the return statement\nexport async function handler(event) {\n  transporter.sendMail(message);          \/\/ no await\n  return { statusCode: 200, body: \"ok\" };\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ CORRECT \u2014 the response waits for the send to complete\nexport async function handler(event) {\n  await transporter.sendMail(message);\n  return { statusCode: 200, body: \"ok\" };\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is no error to find in the logs, because nothing failed. The promise was created, the handler returned, and the environment froze with the SMTP conversation half-finished.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Function Returns 200 but No Email Arrives<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Search the handler for <code>sendMail<\/code> or <code>fetch<\/code> without <code>await<\/code><\/li>\n\n\n\n<li>Check for <code>.then()<\/code> chains with no <code>return<\/code> in front of them<\/li>\n\n\n\n<li>Confirm the handler is declared <code>async<\/code> and the send is awaited<\/li>\n\n\n\n<li>Wrap the send in <code>try\/catch<\/code> and log the error branch explicitly<\/li>\n\n\n\n<li>Log the message ID the relay returns \u2014 no ID means the send never completed<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Failure 2: Connection Pooling Does Not Survive<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On a long-running server, a connection pool is the standard optimisation. In serverless it is at best useless and at worst harmful.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Each cold invocation starts a fresh environment, so the pool is empty. It pays the full cost every time: DNS resolution, TCP handshake, TLS negotiation and SMTP authentication before a single byte of your message moves.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Enabling <code>pool: true<\/code> in a serverless function because it is the recommended production setting elsewhere. A pooled transport keeps connections open for reuse that will never come \u2014 the environment freezes with them held, and the relay sees connections that sit idle and then vanish. On a frequently invoked function this can push you into per-account connection limits while delivering no benefit. Use a single unpooled connection per invocation and close it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Node.js serverless \u2014 correct transport settings\nconst transporter = nodemailer.createTransport({\n  host: process.env.SMTP_HOST,\n  port: Number(process.env.SMTP_PORT),\n  secure: false,            \/\/ STARTTLS on 587\n  auth: {\n    user: process.env.SMTP_USER,\n    pass: process.env.SMTP_PASS\n  },\n  pool: false,              \/\/ no pooling in serverless\n  connectionTimeout: 5000,\n  greetingTimeout: 5000,\n  socketTimeout: 8000\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Set your SMTP timeouts comfortably below the function&#8217;s own timeout. If the platform kills the invocation first, you get a generic timeout with no SMTP detail and nothing useful in the log. If your own timeout fires first, you get a catchable error you can log and retry. Function timeouts vary by provider and plan, so check the current limit for yours and work backwards from it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure 3: Concurrency Multiplies Connections<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless scales by running many copies of your function at once. Each copy opens its own SMTP connection, because there is nothing shared between them to coordinate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A traffic spike that triggers two hundred concurrent invocations attempts two hundred simultaneous authenticated connections. Relays apply per-account connection limits, and past that point sends begin to fail \u2014 not because the message is wrong, but because the pattern is.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the point at which a queue stops being optional.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Symptom<\/th><th>Actual cause<\/th><th>Fix<\/th><\/tr><\/thead><tbody><tr><td>200 returned, no email, no error<\/td><td>Send not awaited before return<\/td><td>Add <code>await<\/code><\/td><\/tr><tr><td>Module not found for <code>net<\/code> or <code>tls<\/code><\/td><td>SMTP library on an edge runtime<\/td><td>Switch route to Node, or use HTTP<\/td><\/tr><tr><td>Intermittent timeouts under load<\/td><td>Concurrency exceeding connection limits<\/td><td>Queue, with limited worker concurrency<\/td><\/tr><tr><td>First request slow, later ones fast<\/td><td>Cold start paying the full handshake<\/td><td>Expected \u2014 move the send off the request path<\/td><\/tr><tr><td>Works locally, fails deployed<\/td><td>Missing environment variables<\/td><td>Set them in the platform, not a local file<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Sending from an Edge Runtime<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">On an edge runtime the send is an ordinary HTTPS request. There is no transport object, no handshake to tune and no socket to close.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Edge runtime \u2014 HTTP, not SMTP\n\/\/ Read the endpoint from config; take the exact URL and payload\n\/\/ shape from your provider's API reference.\nexport default async function handler(request) {\n  const res = await fetch(env.EMAIL_API_ENDPOINT, {\n    method: \"POST\",\n    headers: {\n      \"Authorization\": `Bearer ${env.EMAIL_API_KEY}`,\n      \"Content-Type\": \"application\/json\"\n    },\n    body: JSON.stringify({\n      from: \"noreply@yourdomain.com\",\n      to: \"user@example.com\",\n      subject: \"Welcome\",\n      html: \"&lt;p&gt;Thanks for signing up.&lt;\/p&gt;\"\n    })\n  });\n\n  if (!res.ok) {\n    throw new Error(`Send failed: ${res.status}`);\n  }\n\n  return new Response(\"ok\");\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the explicit status check. As <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Window\/fetch\" target=\"_blank\" rel=\"noopener\">MDN documents<\/a>, <code>fetch()<\/code> does not reject on an HTTP error status \u2014 a <code>4xx<\/code> or <code>5xx<\/code> response resolves normally. Code that only catches thrown errors will treat a rejected send as a success.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Take the endpoint and payload shape from your provider&#8217;s current API reference before deploying \u2014 the pattern above shows the structure, not the literal values. The PhotonConsole dashboard lists the endpoint and key for your project alongside the SMTP credentials, so both routes work from one account.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">SMTP or HTTP when you have the choice<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Consideration<\/th><th>SMTP on 587<\/th><th>HTTP API<\/th><\/tr><\/thead><tbody><tr><td>Works on edge runtimes<\/td><td>No<\/td><td>Yes<\/td><\/tr><tr><td>Connection setup cost<\/td><td>Handshake plus auth<\/td><td>One TLS request<\/td><\/tr><tr><td>Portability across providers<\/td><td>High \u2014 a standard protocol<\/td><td>Lower \u2014 provider-specific<\/td><\/tr><tr><td>Existing framework support<\/td><td>Built into most mailers<\/td><td>Usually an SDK or custom code<\/td><\/tr><tr><td>Migration effort later<\/td><td>Change four variables<\/td><td>Rewrite the call<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">On a Node runtime, SMTP is usually the better default precisely because it is portable: the same four credentials work from a container, a VPS or your laptop. On edge, HTTP is the only option, so the decision is made for you.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Add a Queue Before You Need One<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless gives you no durable local state. The filesystem does not persist, nothing is shared between invocations, and a failed send has nowhere to wait for a retry.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That makes an external queue the only place retry logic can live.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Web-facing function \u2014 enqueue and return\nexport async function handler(event) {\n  await queue.send({\n    type: \"welcome_email\",\n    userId: event.userId\n  });\n  return { statusCode: 202, body: \"queued\" };\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># Consumer function \u2014 bounded concurrency\n# Set the consumer's maximum concurrency to a value your\n# relay's connection limit can absorb, not to the default.\n#\n#   enqueue  -&gt; fast, always succeeds\n#   consume  -&gt; slow, retried with backoff<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two benefits follow immediately. The user-facing request stops depending on a third party being reachable, and a transient failure becomes a retry instead of a lost email. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">transactional email queue architecture<\/a> covers backoff, dead-letter handling and idempotency keys.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Works Locally but Times Out When Deployed<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Confirm the environment variables are set in the platform, not in a local <code>.env<\/code><\/li>\n\n\n\n<li>Set <code>pool: false<\/code> and explicit timeouts under the function limit<\/li>\n\n\n\n<li>Verify the route&#8217;s runtime is Node, not edge, if you are using SMTP<\/li>\n\n\n\n<li>Try port 2525 if the platform restricts 587 on outbound<\/li>\n\n\n\n<li>Check whether the function has outbound network access at all in its VPC configuration<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Platform Notes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>AWS Lambda<\/strong> \u2014 Node runtime, so SMTP works. A Lambda attached to a VPC has no internet route by default and needs a NAT gateway or VPC endpoint to reach anything outbound.<\/li>\n\n\n\n<li><strong>Cloudflare Workers<\/strong> \u2014 isolate runtime. Use HTTP. Store the API key as a secret binding rather than a plain variable.<\/li>\n\n\n\n<li><strong>Vercel<\/strong> \u2014 the runtime is per-route. Node functions can use SMTP; edge routes cannot.<\/li>\n\n\n\n<li><strong>Netlify Functions<\/strong> \u2014 Node runtime, SMTP works. Background functions are the right place for sends that outlive a request.<\/li>\n\n\n\n<li><strong>Deno Deploy<\/strong> \u2014 isolate runtime with web APIs. Use HTTP.<\/li>\n\n\n\n<li><strong>Cloud Run and Fargate<\/strong> \u2014 containers, not functions. Normal pooling applies and nothing in this article&#8217;s pooling advice is needed.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Runtime limits and defaults change, so confirm the current timeout, concurrency and networking behaviour in your provider&#8217;s own documentation rather than trusting a figure from any article.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Authentication Still Applies<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>TXT    @                    v=spf1 include:relay.photonconsole.com ~all\nCNAME  photon._domainkey    dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless functions have no fixed outbound address, so SPF cannot authorise the function itself. Authorising the relay&#8217;s domain with an <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc7208\" target=\"_blank\" rel=\"noopener\">SPF <code>include<\/code><\/a> is what makes the record correct and keeps it correct as the platform scales.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Authentication failures are one of the most common causes of email delivery problems, and receiving providers including <a href=\"https:\/\/support.google.com\/mail\/answer\/81126\" target=\"_blank\" rel=\"noopener\">Gmail<\/a> expect aligned SPF and DKIM on transactional mail. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a> explains the records, and the free <a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">email deliverability checker<\/a> shows what your domain publishes today.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Port Reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Port<\/th><th>Purpose<\/th><th>In a serverless function<\/th><\/tr><\/thead><tbody><tr><td>25<\/td><td>Server-to-server relay<\/td><td>Not applicable \u2014 no local mail server<\/td><\/tr><tr><td>587<\/td><td>Authenticated submission<\/td><td>The standard choice on Node runtimes<\/td><\/tr><tr><td>465<\/td><td>Submission over implicit SSL<\/td><td>Works if your library prefers it<\/td><\/tr><tr><td>2525<\/td><td>Unofficial fallback<\/td><td>Useful if the platform restricts 587<\/td><\/tr><tr><td>443<\/td><td>HTTPS<\/td><td>The only option on edge runtimes<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Port 587 is the submission port <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc6409\" target=\"_blank\" rel=\"noopener\">defined in RFC 6409<\/a> for authenticated clients. PhotonConsole accepts submission on 587, 465 and 2525, and an HTTPS endpoint for runtimes that cannot open a socket at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Await everything, then check the return value.<\/strong> A completed promise with an error response is still a failed send.<\/li>\n\n\n\n<li><strong>Log the relay&#8217;s message ID on success.<\/strong> It is the difference between investigating a delivery question in two minutes and in two hours.<\/li>\n\n\n\n<li><strong>Keep timeouts aggressive.<\/strong> Five seconds to connect is generous. A long default guarantees the platform kills the invocation before your code can report anything.<\/li>\n\n\n\n<li><strong>Cap consumer concurrency deliberately.<\/strong> The queue exists to smooth the spike; an unbounded consumer recreates it.<\/li>\n\n\n\n<li><strong>Separate credentials per environment.<\/strong> Preview deployments inheriting production keys send real mail to real people.<\/li>\n\n\n\n<li><strong>Score a real send before launch.<\/strong> <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> catches authentication problems while they are still cheap to fix, and <a href=\"https:\/\/mxtoolbox.com\/\" target=\"_blank\" rel=\"noopener\">MXToolbox<\/a> confirms the published records.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Issues You May Hit Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/nextjs-send-email-server-actions\/\">Sending email from Next.js<\/a> for per-route runtime selection<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/how-to-send-emails-in-node-js-with-nodemailer-a-production-setup-guide\/\">Node.js with Nodemailer<\/a> for the transport options in depth<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/heroku-send-email-smtp\/\">Sending email from Heroku<\/a> where the request timeout is the constraint<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP connection timeouts<\/a> when the handshake hangs rather than fails<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-emails-failing-in-production-but-working-in-dev-a-debugging-guide\/\">Email working in dev but failing in production<\/a> \u2014 the general case<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/emails-sent-but-not-delivered\/\">Emails sent but not delivered<\/a> when the log reports success<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use Nodemailer in a serverless function?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, on a Node.js runtime such as Lambda or a Vercel Node function. Set <code>pool: false<\/code>, add explicit timeouts, and await the send. It will not run on an edge runtime.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my function return success but send no email?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The send was not awaited. The environment is suspended when the handler returns, so an in-flight promise is abandoned rather than completed in the background.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why do I get a module not found error for <code>net<\/code>?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Your route is running on an edge runtime, which does not provide Node&#8217;s native modules. Either move the route to the Node runtime or send over HTTPS instead.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use SMTP or an HTTP API in serverless?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">On edge, HTTP is the only option. On a Node runtime, SMTP is usually the better default because the same credentials work from any other environment without a rewrite.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does connection pooling help in serverless?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. The pool does not survive between invocations and holding connections can push you into per-account connection limits. Use one unpooled connection per invocation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a queue for serverless email?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">For anything user-facing, yes. Without one there is no durable place for a retry to live, and a transient failure becomes a permanently lost email.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does my Lambda time out with no network error?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A Lambda in a VPC has no outbound internet route by default. It needs a NAT gateway or a VPC endpoint before it can reach any external service.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless email problems are rarely about email. They come from an execution model that suspends your code the moment it answers, starts cold more often than you expect, and runs many copies of itself at once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three decisions remove almost all of it. Identify the runtime, because that determines whether SMTP is even available. Await the send before returning, because nothing continues after the response. Put a queue between the request and the relay, because that is the only durable place a retry can live.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A dedicated <a href=\"https:\/\/www.photonconsole.com\/relay.php\">transactional email solution<\/a> fits this environment well, since a function needs nothing more than a credential and a reachable endpoint \u2014 no mail server, no local state, and the same configuration whether the code runs in a function, a container or on a server. <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">Pricing<\/a> starts with 5,000 free emails per month, enough to prove the whole path from invocation to inbox before committing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">Free Email Deliverability Checker<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">Transactional Email Queue Architecture Explained<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/nextjs-send-email-server-actions\/\">Sending Email from Next.js<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP Configuration: Complete Setup Reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Your function returns 200 OK. The log shows no error. The user never receives the email. Or the opposite: the code that worked on your laptop now throws a module resolution error the moment you deploy it, complaining about a package you never imported directly. Both failures come from the same misunderstanding. &#8220;Serverless&#8221; describes two [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":475,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[550,549],"class_list":["post-474","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-email-deliverability","tag-await-and-queue-setup-that-fixes-it","tag-function-returns-200-but-no-email-arrives-the-environment-freezes-when-your-handler-returns-here-is-the-runtime"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/474","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=474"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/474\/revisions"}],"predecessor-version":[{"id":478,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/474\/revisions\/478"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/475"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=474"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=474"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=474"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}