DEV Community

Cover image for Why Nodemailer Doesn't Work on Cloudflare Workers (And What To Do Instead)
Sandeep Singh
Sandeep Singh

Posted on

Why Nodemailer Doesn't Work on Cloudflare Workers (And What To Do Instead)

A short explanation of a wall a lot of developers hit, why it isn't going away, and the five lines that replace it.


You wrote a contact form. It worked locally. You deployed it to a Cloudflare Worker, or a Vercel Edge Function, or Deno Deploy, and got something like this:

TypeError: Class extends value #<Object> is not a constructor or null
Enter fullscreen mode Exit fullscreen mode

Or, if you were luckier and got a useful error:

Module not found: Can't resolve 'net'
Enter fullscreen mode Exit fullscreen mode

Then you spent an hour trying compatibility flags, polyfills, and bundler aliases. I want to save you the rest of that hour.

This isn't a bug, and no amount of configuration will fix it.

The actual reason

Nodemailer's default transport is SMTP. SMTP is a protocol that runs over a raw TCP connection. To open one in Node.js, you call net.createConnection().

Cloudflare Workers don't run on Node.js. They run on V8 isolates — the same engine as Chrome, without the Node runtime around it. Vercel's Edge Runtime and Deno Deploy are built on similar principles.

In that environment, there is no net module, because there are no raw TCP sockets. All networking is handled by managed infrastructure outside the runtime — Cloudflare's own writeup on bringing node:http to Workers is explicit about this: connection pooling, TLS negotiation, and egress IP management are handled at the system level, which is precisely why a subset of Node APIs can never be supported.

So the chain is:

No raw TCP → no net.createConnection() → no SMTP client → no Nodemailer.

There's a second, smaller issue that often gets conflated with this one. Nodemailer issue #1621 points out that Nodemailer imports built-in modules without the node: prefix, which breaks the Workers build step. That one is fixable. But fixing it wouldn't help — you'd just move the failure from build time to runtime, where net still doesn't exist. Issue #1623 covers the broader edge-function problem.

It's worth being clear that none of this is a knock on Nodemailer. It's an excellent library, actively maintained, MIT-0 licensed, with zero runtime dependencies and millions of weekly downloads. It does one job extremely well: constructing a correct MIME message and getting it onto a wire. The edge simply doesn't have the wire.

What to do instead

Use an HTTP API. Every transactional email provider has one, and HTTP is the one thing edge runtimes are exceptionally good at.

Here's a complete Cloudflare Worker that sends email:

export default {
  async fetch(request, env) {
    const res = await fetch("https://api.yourprovider.com/v1/send", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${env.EMAIL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: "hello@yourdomain.com",
        to: "user@example.com",
        subject: "Hello from the edge",
        html: "<p>It works.</p>",
      }),
    });

    if (!res.ok) {
      return new Response(`Send failed: ${res.status}`, { status: 502 });
    }
    return new Response("Sent");
  },
};
Enter fullscreen mode Exit fullscreen mode

That's it. No dependency. No polyfill. No bundler config.

Store the key with wrangler secret put EMAIL_API_KEY so it never lands in your repo or your deploy logs.

The part nobody says out loud

On the edge, you don't need an email library at all.

There's a growing set of runtime-agnostic email libraries built specifically to fill this gap, and they're well made. But be honest with yourself about what you're buying. If you're sending a handful of transactional message types from a Worker, fetch is the whole solution. A wrapper around fetch earns its place when you need provider-swapping, a unified message type across a large codebase, or heavy MIME construction with attachments and inline images.

For a password reset and a welcome email? Ship the five lines.

Runtime cheat sheet

Runtime Nodemailer / SMTP What to use
Node.js (Express, Fastify, NestJS) ✅ Works Nodemailer, or an HTTP SDK
Next.js — route handlers, server actions ✅ Works (Node runtime) Nodemailer, or an HTTP SDK
Next.js — Edge Runtime fetch to an HTTP API
Next.js — middleware ❌ (always edge) Don't send email here at all
AWS Lambda, containers ✅ Works Nodemailer, or an HTTP SDK
Cloudflare Workers fetch to an HTTP API
Deno Deploy fetch to an HTTP API
Bun ⚠️ Mostly works Test it; fetch is safer

Two notes on the Next.js rows, because this trips people up constantly.

Route handlers and server actions run on the Node.js runtime by default — Nodemailer works fine there. You only lose it if you've explicitly opted into export const runtime = 'edge'.

Middleware is always edge. Even setting aside the runtime, middleware runs on every matched request with tight execution limits. Sending email from it is a bad idea for reasons that have nothing to do with SMTP.

While you're in here: the duplicate email bug

This one bites people right after they solve the edge problem, and it's worth fixing in the same sitting.

Serverless platforms retry on timeout. If your function calls the email API, the send succeeds, and then the function times out on something downstream, the platform re-invokes it. The email goes out twice.

Your user gets two password reset emails with two different tokens. Now they're confused, and one of those tokens is a live credential floating in an inbox.

The fix is an idempotency key — a stable identifier sent with the request, which the provider uses to deduplicate for some window. Retry with the same key, get the original result back instead of a second send:

headers: {
  "Idempotency-Key": `pwreset-${userId}-${requestId}`,
}
Enter fullscreen mode Exit fullscreen mode

Derive it from something stable about the request, not from Date.now() or a fresh UUID — those change on retry, which defeats the entire mechanism.

Not every provider supports this. It's worth checking before you pick one, because you cannot build it yourself in a stateless function. Deduplication requires state, and your function doesn't have any.

Summary

  • Nodemailer can't run on edge runtimes because those runtimes have no TCP sockets. This is architectural and permanent, not a bug awaiting a fix.
  • On the edge, call an HTTP API with fetch. It's five lines.
  • In Node — including most Next.js code — Nodemailer is still great.
  • Check your provider supports idempotency keys before serverless retries send your users duplicate emails.

Top comments (0)