DEV Community

우병수
우병수

Posted on • Originally published at techdigestor.com

AWS SES vs Postmark vs Resend: Which One Actually Works for a Small Business?

TL;DR: Password reset emails were landing in Gmail's spam folder. Not occasionally — consistently.

📖 Reading time: ~31 min

What's in this article

  1. I Needed Reliable Transactional Email — So I Tried All Three
  2. The Setup Reality Check (Before You Pick Anything)
  3. AWS SES: Cheapest by Far, But You're On Your Own
  4. Postmark: The One That Just Worked
  5. Resend: New Kid, Built for Developers
  6. Side-by-Side: The Numbers and Dealbreakers
  7. Real Code: Sending the Same Email on All Three
  8. When to Pick What — Match the Tool to Your Situation

I Needed Reliable Transactional Email — So I Tried All Three

Password reset emails were landing in Gmail's spam folder. Not occasionally — consistently. My small SaaS app had maybe 200 active users at the time, and I was getting support tickets every week from people who never got their confirmation emails. The culprit was SendGrid's free tier, which shares IP pools across thousands of accounts. When one of those accounts sends spam, your deliverability tanks too. That's the shared IP problem nobody mentions until you're living it.

My requirements were narrow: I needed transactional email that works. Password resets, order confirmations, the occasional weekly digest triggered by user activity. Not bulk marketing blasts, not cold outreach sequences — just the emails your app has to send reliably or the product breaks. I was optimizing for three things in this order: deliverability first, setup simplicity second, and observability third. That last one matters more than people think. A silent failure in your email queue at 11pm is worse than a noisy one — at least an alert wakes you up before users start filing tickets.

I spent about six weeks running all three services — AWS SES, Postmark, and Resend — against real traffic on real users before writing any of this. Not synthetic benchmarks. Actual password reset flows, actual order confirmation webhooks, actual delivery logs I had to debug. If you want a broader picture of the email tooling space alongside other infrastructure decisions, the Essential SaaS Tools for Small Business in 2026 guide covers a lot of this adjacent territory.

One scope boundary I want to be upfront about: this comparison is useless if you're trying to send newsletters to 50,000 subscribers or run drip sequences for cold leads. Those use cases have completely different deliverability mechanics, pricing structures, and compliance requirements. What I'm covering here is the transactional side — emails triggered by user actions, sent one at a time or in small batches, where you need a sub-5-second delivery time and a bounce rate you can actually track. If that's your situation, keep reading. If it's not, the tools that matter to you are Mailchimp, Klaviyo, or Customer.io — different animals entirely.

The Setup Reality Check (Before You Pick Anything)

The thing that burned me the first time I touched AWS SES was thinking the service was broken. I'd integrated the SDK, triggered a send, got a 200 back, and nothing arrived. Turned out I was in sandbox mode, where you can only send to email addresses you've manually verified. Not domains — individual addresses. You have to click a confirmation link for each one. This isn't buried in fine print; it's just not where your brain goes when you're moving fast and the API is returning success codes. Getting out of sandbox requires submitting a support request through the AWS console where you describe your sending use case, estimated volume, and how you handle bounces. AWS usually responds within 24 hours, but I've waited up to 48. Postmark and Resend don't do this — you create an account, add a sender, and you're sending to anyone immediately (within their own abuse limits).

Domain authentication isn't optional regardless of which platform you pick, but the order of operations matters. You need SPF, DKIM, and DMARC records in DNS before your deliverability numbers mean anything. Sending without them means your emails are either hitting spam folders or getting silently dropped, and you won't know which because open rates are useless as a signal when Gmail's image proxy pre-fetches pixels. Here's what a minimal but correct DMARC record looks like:

# Add this TXT record to _dmarc.yourdomain.com
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com; sp=none; adkim=r; aspf=r
Enter fullscreen mode Exit fullscreen mode

Start with p=none so you're in monitoring mode — you get aggregate reports emailed to you without any emails being rejected. Once you've confirmed your SPF and DKIM are passing consistently (give it a week of real traffic), move to p=quarantine, then p=reject. All three platforms — SES, Postmark, Resend — generate DKIM keys for you and tell you exactly which DNS records to add. The difference is that Postmark's dashboard will actually refuse to let you send until it detects the records are live, which I found annoying at first and then came to appreciate. Resend does the same. SES will let you send without DKIM if you skip that step, which is a footgun.

Here's my honest time-to-first-real-email benchmark from a cold start on each platform, meaning zero existing account, zero DNS records pre-configured:

  • Resend: ~25 minutes. Sign up, add domain, copy four DNS records (two for DKIM, one SPF, one for the return path), wait for propagation (usually fast if you're on Cloudflare), send via their REST API. Their /emails endpoint is dead simple — a single POST with a JSON body. First email landed in Gmail inbox.
  • Postmark: ~35 minutes. Sign up, create a server, add a sender signature or domain, DNS verification, then send. The UI is more involved than Resend's but you're also getting more hand-holding. First email also landed in inbox.
  • AWS SES: ~3-4 days, minimum. Account creation is instant but sandbox exit takes 24-48 hours. DNS verification is straightforward. The actual sending API or SMTP setup is more complex than either alternative. If you already have an AWS account and have done the sandbox request previously — call it 45 minutes of actual work, just not continuous work.

The Resend API is worth showing because it illustrates why developers pick it first:

# Sending your first email with Resend — this is the entire thing
curl -X POST https://api.resend.com/emails \
  -H "Authorization: Bearer re_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": ["recipient@example.com"],
    "subject": "Test from Resend",
    "html": "It works."
  }'

# Expected response
# {"id":"49a3999c-0ce1-4ea6-ab68-afcd6dc2e794"}
Enter fullscreen mode Exit fullscreen mode

Compare that to SES where you're either constructing raw MIME messages through SMTP or wiring up the AWS SDK with region configs, credential chains, and IAM policies before you can even attempt a send. None of that complexity is SES's fault exactly — it's just what comes with the AWS ecosystem. If you already run your infrastructure on AWS and have IAM figured out, the marginal overhead is low. If you're a two-person SaaS and AWS is new to you, that overhead is real and it compounds when something breaks at 2am.

AWS SES: Cheapest by Far, But You're On Your Own

The SMTP username gotcha tripped me up on my first SES integration and I've seen it trip up nearly every developer I've worked with since. When AWS gives you SMTP credentials, the username is not your IAM access key ID. It's a separate value derived from your secret key through a signing process. AWS generates it for you in the SES console under "SMTP Settings → Create SMTP Credentials" — it looks like a long base64-ish string and has nothing to do with the access key you use for the API. If you skip that step and plug in your regular IAM credentials, you'll get authentication errors that don't explain themselves, and you'll waste an hour debugging the wrong thing.

Pricing is genuinely the main reason to choose SES. You pay per-message at a rate that makes the other providers look expensive by comparison — check their current pricing page since it changes, but the per-1000-emails cost is a fraction of what Postmark or Resend charge. The catch: if you need a dedicated IP for warm reputation, that's a separate monthly charge per IP. For most small businesses sending under 100K emails/month, shared IPs are fine, but you lose some control over deliverability. Also, the first 62,000 emails per month are free if you're sending from an EC2 instance — genuinely useful if your app already lives on AWS.

Getting out of the sandbox takes one manual request. You fill out a form explaining your sending use case, your expected volume, and how you handle bounces and unsubscribes. Mine was approved in about 24 hours, but I've heard of teams waiting 3-4 days. They actually read the form — vague answers like "newsletter" get you follow-up questions. Be specific: "transactional account emails for a SaaS app, under 5,000/month, double opt-in, bounce handling via SNS." That kind of answer gets approved fast.

Here's the Nodemailer config you actually need — note the SMTP port and the credentials format:

const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  host: 'email-smtp.us-east-1.amazonaws.com', // region-specific
  port: 465,
  secure: true, // TLS — use 587 + starttls if 465 is blocked
  auth: {
    user: 'AKIAIOSFODNN7EXAMPLE_SMTP', // NOT your IAM access key ID
    pass: 'BXyWxyzABCDEFGHijklmnopqrstuvwxyz1234567' // from SES SMTP credentials wizard
  }
});

await transporter.sendMail({
  from: 'noreply@yourdomain.com', // must be a verified identity
  to: 'user@example.com',
  subject: 'Your receipt',
  text: 'Thanks for your order.'
});
Enter fullscreen mode Exit fullscreen mode

The IAM policy deserves its own call-out because the lazy move — attaching AdministratorAccess to the role your app runs as — is genuinely dangerous. The minimum you need is this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail"
      ],
      "Resource": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Lock it down further by replacing "Resource": "*" with your verified identity ARN (arn:aws:ses:us-east-1:123456789:identity/yourdomain.com). That way a compromised key can only send from your domain, not spin up EC2 instances.

Bounce and complaint handling is the thing most SES tutorials completely skip, and AWS will pause your sending account if your bounce rate climbs above 5% or your complaint rate exceeds 0.1% without you having monitoring in place. You must create two SNS topics — one for bounces, one for complaints — and configure your SES sending identity to publish to them. Then you wire an SQS queue or Lambda to those topics to process the events and suppress those addresses from future sends. Without this setup, you're flying blind and AWS will shut you down without a particularly helpful warning email. Here's the minimum setup via AWS CLI:

# Create topics
aws sns create-topic --name ses-bounces
aws sns create-topic --name ses-complaints

# Configure SES to publish bounce/complaint notifications
aws ses set-identity-notification-topic \
  --identity yourdomain.com \
  --notification-type Bounce \
  --sns-topic arn:aws:sns:us-east-1:123456789:ses-bounces

aws ses set-identity-notification-topic \
  --identity yourdomain.com \
  --notification-type Complaint \
  --sns-topic arn:aws:sns:us-east-1:123456789:ses-complaints
Enter fullscreen mode Exit fullscreen mode

My honest take: SES is powerful, but it feels like plumbing, not a product. There's no dashboard showing you open rates, no built-in suppression list management with a UI, no one-click bounce handling. Everything is APIs and console spelunking. If your team has DevOps experience and you're already deep in AWS, SES pays for itself quickly. If you're a two-person startup where the "backend developer" is also handling customer support, the operational overhead will cost you more in time than Postmark or Resend would cost in dollars. Budget for the pain before you commit.

Postmark: The One That Just Worked

The thing that sold me wasn't a feature list — it was watching a test email land in under 3 seconds and then opening the activity log to see exactly which MX server accepted it, the timestamp down to the millisecond, and the full SMTP conversation. SES, by default, gives you a CloudWatch metric and a prayer. Postmark gives you a receipt. That difference sounds cosmetic until you're debugging why a transactional email isn't reaching a specific enterprise domain at 2am.

Message Streams: Forced Good Hygiene

Postmark's Message Streams concept is the feature that doesn't get enough credit. Every account has separate stream types — transactional and broadcast — that route through different IP pools. This isn't just organizational. It means your password reset emails are physically separated from your newsletter sends. If someone marks your weekly digest as spam, that reputation hit doesn't bleed over and tank your account confirmation deliverability. I've seen startups ruin their transactional IP reputation by blasting a marketing campaign through the same SMTP credentials. Postmark makes that mistake structurally harder to make.

Actual Setup Code

Installation is one command:

npm install postmark
Enter fullscreen mode Exit fullscreen mode

The send call is genuinely five lines of logic:

import * as postmark from "postmark";

// Use your Server API Token from the Postmark dashboard, not the account token
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);

const result = await client.sendEmail({
  From: "you@yourdomain.com",
  To: "recipient@example.com",
  Subject: "Order confirmed",
  TextBody: "Your order #1042 has shipped.",
  MessageStream: "outbound" // default transactional stream
});

// result looks like:
// {
//   To: "recipient@example.com",
//   SubmittedAt: "2026-01-15T10:23:11.0000000-05:00",
//   MessageID: "b7bc2f4a-e38e-4336-af2d-71cb8a3c6e11",
//   ErrorCode: 0,
//   Message: "OK"
// }
console.log(result.MessageID); // use this to pull up the activity log entry directly
Enter fullscreen mode Exit fullscreen mode

That MessageID in the response is immediately queryable in the dashboard. Paste it in the search bar and you get the full delivery trace. No log aggregation pipeline needed, no waiting for CloudWatch Insights to index. This alone has saved me multiple hours of debugging per incident.

Suppression Management Without the SNS Wiring

Bounces and unsubscribes are handled automatically. When an address hard bounces, Postmark adds it to your suppression list and won't attempt delivery again — no webhook setup, no Lambda function processing SNS notifications, no DynamoDB table to store the suppressed list yourself. The dashboard surfaces everything: bounce type (hard vs soft), the SMTP error code the receiving server returned, and when it happened. With SES you're building that entire pipeline yourself or paying for a layer like Courier to do it. The Postmark approach isn't magic, but the zero-configuration default is genuinely useful when you're a two-person team.

Pricing Reality Check

The trial gives you 100 free credits to start. If you're testing heavily — onboarding flows, resend logic, multiple test accounts — those 100 emails disappear in an afternoon. After that, check their current pricing page because it shifts, but the general shape is: you're paying more per-email than SES at any volume. SES is roughly $0.10 per 1,000 emails. Postmark is structured around monthly plans with included volume, not pure pay-per-message. At 50,000 emails/month you'll feel the cost difference clearly. The honest trade-off: if your email volume is low-to-medium and debugging time is expensive, Postmark's per-message logging and deliverability dashboard pay for themselves. If you're sending millions of emails and have the engineering bandwidth to build proper SES monitoring, the economics flip hard in SES's favor.

My actual take: Postmark is the right default for any small business that doesn't have a dedicated infrastructure engineer. The setup is 20 minutes, the deliverability is excellent, and when something goes wrong you can diagnose it without opening five AWS consoles. Just budget for it properly — the cost is real, and the trial credits will run out before you've finished testing your staging environment.

Resend: New Kid, Built for Developers

The thing that caught me off guard with Resend was how fast the setup actually felt — not "fast" in the marketing sense, but fast in the sense that I had a working send in under five minutes without reading a single doc page beyond the quickstart. That doesn't happen often. The founders clearly built this because they were personally frustrated with every other option, and that frustration shows up as a product that has sharp edges where they count: the API is clean, the errors are readable, and the SDK doesn't make you feel like you're wrapping a legacy SOAP service.

npm install resend

# then in your send file:
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

await resend.emails.send({
  from: 'you@yourdomain.com',
  to: 'customer@example.com',
  subject: 'Your order shipped',
  html: '<p>Your package is on its way.</p>',
});
Enter fullscreen mode Exit fullscreen mode

That's the entire thing. No XML config, no SDK initialization ceremony, no "please refer to the enterprise docs for authentication." If you want to swap the html field for a React Email component, it's one extra import and your email template is now a typed React component with props, conditional rendering, and all the tooling you already have. I've used this on a Next.js 14 app and writing transactional emails as components instead of wrestling with inline style spaghetti is a genuine improvement to my day.

import { render } from '@react-email/render';
import OrderConfirmation from './emails/OrderConfirmation';

const html = render(<OrderConfirmation orderNumber="1042" total="$89.00" />);

await resend.emails.send({
  from: 'orders@yourdomain.com',
  to: customer.email,
  subject: 'Order Confirmed',
  html,
});
Enter fullscreen mode Exit fullscreen mode

The free tier is usable for getting started — check their current pricing page because the numbers change — but you will hit the ceiling in real production. At the time I'm writing this, the free plan is restricted enough that any app with meaningful transactional volume will need a paid tier pretty quickly. That's not a criticism, it's just the reality: free tiers exist for evaluation, not production load. The paid plans are reasonably priced compared to Postmark, but factor in that Resend was founded in 2022, which means they're still discovering edge cases the hard way.

The honest gaps compared to Postmark: the activity log is nowhere near as detailed, which matters when a customer says "I never got my password reset email" and you need to actually debug it. Postmark shows you per-message delivery events with timestamps, SMTP responses, open tracking, the works. Resend's dashboard is cleaner but shallower. Dedicated IPs — which matter if you're sending enough volume that you want your reputation isolated from everyone else on a shared IP — are available but the options are more limited than what Postmark gives you at equivalent pricing tiers. And because the product is younger, I've hit a couple of behaviors that weren't documented anywhere and required a support ticket to resolve. The support response was good, but the fact that I needed it wasn't.

My honest take: Resend has the best developer experience of the three. The API design is good, the React Email integration is genuinely useful if you're in that stack, and setup friction is close to zero. But I wouldn't deploy it as my only sending provider for a production app where email is business-critical — a failed password reset or a missed invoice email directly costs you users or revenue. Use it with a fallback strategy, or wait another year for the rough edges to smooth out. If you're building a side project or an MVP where the worst case is "some emails bounce during an outage," Resend is an easy yes.

Side-by-Side: The Numbers and Dealbreakers

The thing that catches most people off guard with SES isn't the pricing — it's that you're in a sandbox by default, which means you can only send to verified email addresses until you manually request production access. That request goes to AWS Support, takes 24–48 hours, requires you to explain your sending use case, and if your answer isn't specific enough they'll ask follow-up questions. I've seen small teams burn a week on this during a launch sprint. Postmark and Resend put you in production the moment your account is verified. That alone changes the calculus for anyone on a deadline.

# SES sandbox: this will hard-fail if recipient isn't verified
aws ses send-email \
  --from "you@yourdomain.com" \
  --to "unverified@gmail.com" \
  --subject "Test" \
  --text "Hello" \
  --region us-east-1

# Output:
# An error occurred (MessageRejected) when calling the SendEmail operation:
# Email address is not verified. The following identities failed...
Enter fullscreen mode Exit fullscreen mode

Here's a direct comparison of what actually matters for a small business making a real decision:

  • Setup time to first sent email: SES — 2–5 days (domain verification + sandbox exit); Postmark — 30–60 minutes; Resend — 15–30 minutes
  • Sandbox restrictions: SES — hard sandbox with verified-only recipients; Postmark — none, production immediately; Resend — none, production immediately
  • Bounce/complaint webhooks: SES — you wire SNS → SQS or SNS → Lambda yourself; Postmark — built-in webhook UI, fires immediately; Resend — built-in, clean JSON payload
  • Deliverability dashboard: SES — virtually none, you're flying blind unless you add third-party tools; Postmark — detailed per-message open/click/bounce timeline; Resend — basic but improving, open rates visible
  • Dedicated IP: SES — available from $24.95/month per IP; Postmark — available on higher plans; Resend — not available as of mid-2025
  • SDK quality: SES — AWS SDK is bloated and config-heavy; Postmark — excellent official clients for Node, Ruby, Python, PHP; Resend — clean modern SDK, best DX of the three
  • Pricing model: SES — $0.10 per 1,000 emails (plus data transfer, plus SNS costs); Postmark — monthly tiers starting at $15/month for 10K; Resend — hybrid: 3,000/month free, then $20/month for 50K

The free tier reality is messier than the marketing pages suggest. SES gives you 62,000 emails/month free only if you're sending from an EC2 instance. If you're calling the API from a Lambda, your own server, or a CI pipeline, it's $0.10/1K from email one. There's no free tier in the traditional sense outside EC2. Postmark gives you 100 test emails free but requires a card and a paid plan to send to real users at any volume — the "free" plan is essentially a sandbox for development only. Resend is the most honest: 3,000 emails/month free, no card required, no EC2 dependency, and the limit resets monthly. If you're sending fewer than 3,000 transactional emails per month, Resend is the obvious starting point.

On dedicated IPs: most small businesses should not spend time thinking about this. Shared IP pools from reputable ESPs have strong deliverability because the providers actively police abuse. Dedicated IPs actually hurt you initially — a cold IP with low volume looks suspicious to Gmail and Microsoft's filters. You need to warm a dedicated IP gradually over several weeks, maintaining consistent volume. The point where a dedicated IP makes sense is when you're sending tens of thousands of emails per month with consistent patterns, and you want reputation isolation from other senders on the shared pool. At that scale, SES ($24.95/IP/month) and Postmark (available on higher tiers) both support it. Resend's lack of dedicated IPs is a real gap if you're at that volume — it's the product's most significant current limitation.

The biggest dealbreaker per platform, honestly assessed: SES — the bounce handling setup is genuinely painful. You need SNS topics, subscriptions, and something to consume them before you should trust your sender reputation to it. Teams skip this step and tank their domain reputation inside a month. Postmark — pricing at volume. At 300K emails/month you're looking at $225/month, which is 2–3x what SES costs at the same volume. The quality is worth it for transactional email, but it bites you when your newsletter list grows. Resend — product maturity. The API is great, the DX is excellent, but the dashboard is still catching up to Postmark, there's no dedicated IP option, and edge cases (email scheduling, advanced suppression list management) are missing features that Postmark has had for years. If you're building something where email is mission-critical infrastructure, Resend's roadmap is a risk factor you need to consciously accept.

Real Code: Sending the Same Email on All Three

The Setup: Three Services, One Transactional Email

I'm going to send the same password reset email through all three. Same subject, same body, same recipient. The differences in the code will tell you more about each service's philosophy than any marketing page will.

AWS SES via Nodemailer

SES doesn't have an official Node SDK for SMTP — you're expected to use Nodemailer with IAM credentials. The port decision matters: use 465 with secure: true for implicit TLS, or 587 with secure: false and starttls. I default to 465 because some corporate firewalls block 587 outbound, and the TLS handshake on 465 is more predictable in practice.

import nodemailer from 'nodemailer'; // nodemailer ^6.9

const transporter = nodemailer.createTransport({
  host: 'email-smtp.us-east-1.amazonaws.com', // region-specific — don't use the generic endpoint
  port: 465,
  secure: true, // false here + requireTLS:true is the 587 path
  auth: {
    user: process.env.SES_SMTP_USER,   // NOT your AWS access key — generate SMTP credentials separately in SES console
    pass: process.env.SES_SMTP_PASS,
  },
  maxConnections: 5,    // SES default send rate is 14 msgs/sec per connection — pool this
  pool: true,
});

async function sendPasswordReset(toEmail: string, resetLink: string) {
  try {
    const info = await transporter.sendMail({
      from: '"My App" ',
      to: toEmail,
      subject: 'Reset your password',
      html: `Click here to reset.`,
    });
    return info.messageId; // SES message ID, useful for debugging in CloudWatch
  } catch (err: any) {
    if (err.responseCode === 454) {
      // Throttling — SES returns 454 4.7.0 when you exceed your sending rate
      throw new Error('SES_RATE_LIMIT');
    }
    if (err.response?.includes('Address blacklisted')) {
      throw new Error('SES_SUPPRESSION_LIST'); // SES has an account-level suppression list that will silently swallow sends
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

The gotcha that burned me: SES SMTP credentials are not your AWS access key and secret. You generate them separately under "SMTP Settings" in the SES console, and they look completely different. I spent 45 minutes debugging an auth error before I figured that out.

Postmark

Postmark has a first-class Node SDK (postmark on npm) and the API is clean. The thing people miss — including me on the first project — is the MessageStream field. If you skip it, Postmark defaults to your "outbound" stream, which might not be what you want. Transactional and broadcast emails live in separate streams with separate deliverability reputations, and you want that separation.

import * as postmark from 'postmark'; // postmark ^4.0

const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN!);

async function sendPasswordReset(toEmail: string, resetLink: string) {
  try {
    const result = await client.sendEmail({
      From: 'noreply@yourdomain.com',
      To: toEmail,
      Subject: 'Reset your password',
      HtmlBody: `Click here to reset.`,
      MessageStream: 'outbound', // explicit — don't rely on the default. Use your stream's ID from the Postmark dashboard
    });
    return result.MessageID;
  } catch (err: any) {
    if (err instanceof postmark.Errors.PostmarkError) {
      if (err.code === 429) {
        // Postmark's rate limit HTTP 429 maps to their ErrorCode 429 in the SDK
        throw new Error('POSTMARK_RATE_LIMIT');
      }
      if (err.code === 406) {
        // 406 = inactive recipient — address is on their suppression list
        throw new Error('POSTMARK_SUPPRESSED_ADDRESS');
      }
      if (err.code === 300) {
        // 300 = invalid email address format
        throw new Error('POSTMARK_INVALID_ADDRESS');
      }
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Resend

Resend's SDK is the most minimal of the three. One sharp edge: the from field must use a domain you've verified with Resend via DNS records. You cannot use a personal Gmail address, a Hotmail, nothing. Trying to send from me@gmail.com returns a 403 immediately. This catches people who test with their personal email first.

import { Resend } from 'resend'; // resend ^3.0

const resend = new Resend(process.env.RESEND_API_KEY);

async function sendPasswordReset(toEmail: string, resetLink: string) {
  const { data, error } = await resend.emails.send({
    from: 'My App ', // "Name " format works; bare address also works
    to: toEmail,
    subject: 'Reset your password',
    html: `Click here to reset.`,
  });

  // Resend returns {data, error} — no throw on failure, you check the error object
  if (error) {
    if (error.statusCode === 429) {
      throw new Error('RESEND_RATE_LIMIT');
    }
    if (error.statusCode === 422) {
      // Validation error — usually bad address format or unverified from domain
      throw new Error(`RESEND_VALIDATION: ${error.message}`);
    }
    throw new Error(`RESEND_ERROR: ${error.message}`);
  }

  return data?.id;
}
Enter fullscreen mode Exit fullscreen mode

Error Handling: The Real Difference

This is where the philosophy gap shows. Nodemailer/SES throws actual exceptions with SMTP response codes baked into err.responseCode and a raw err.response string — you're parsing protocol-level messages, which is brittle. Postmark throws typed PostmarkError objects with clean integer error codes that map directly to their docs. Resend takes the Go/Rust pattern of returning {data, error} and never throwing — which I actually prefer for async flows since you don't need try/catch everywhere, but it's easy to forget to check error and silently swallow failures.

For rate limits specifically: SES is the most aggressive throttler of the three — if you're in sandbox mode you're capped at 1 message per second and it returns a 454 SMTP code. Postmark sends a proper HTTP 429 with a Retry-After header that the SDK surfaces. Resend's 429 comes back in the error object's statusCode. My recommendation: wrap all three in a retry utility with exponential backoff. None of them retry automatically.

// Minimal retry wrapper that works across all three
async function withRetry(fn: () => Promise, maxAttempts = 3): Promise {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const isRateLimit = err.message?.includes('RATE_LIMIT');
      if (!isRateLimit || attempt === maxAttempts) throw err;
      // Backoff: 1s, 2s, 4s — crude but effective for transactional volume
      await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1)));
    }
  }
  throw new Error('unreachable');
}
Enter fullscreen mode Exit fullscreen mode

When to Pick What — Match the Tool to Your Situation

Match the Tool to Your Situation

The honest answer is that all three work. The question is what you're paying in ops time, money, and pain. I've seen small teams pick SES because "AWS is what we use" and then spend two weeks debugging bounce handling through SNS queues before a single production email lands correctly. Picking the right tool here is less about features and more about your team's current use.

Pick SES if: you're already running your infra on AWS, have someone who's comfortable with IAM policies and can wire up SNS topics to Lambda or SQS for bounce/complaint processing, and your volume is high enough that the cost gap actually closes deals. SES costs $0.10 per 1,000 emails. Postmark starts at $15/month for 10,000 emails. At low volume that's irrelevant. At 500,000 emails/month, you're paying $50 on SES vs significantly more elsewhere — that math starts mattering. But budget roughly a full day of engineering time upfront just to get SES production-ready, not just sending.

Pick Postmark if: you need email working correctly by end of week and can't afford to debug DMARC alignment edge cases or SNS callback failures. Postmark's activity logs are genuinely good — you can see open, bounce, spam complaint, and link click events per message without setting up any additional infrastructure. The thing that caught me off guard was how fast their support responds with actual humans who know email. Their transactional stream is purpose-built for triggered emails (receipts, password resets, notifications) and their bulk stream is separate, which forces a discipline most small teams actually benefit from. The $15/month starting price is the real cost — no hidden webhook setup, no IAM footguns.

Pick Resend if: you're starting a greenfield Next.js or React app and want to write your email templates the same way you write your UI. The react.email component library pairs directly with Resend's API, and the DX is genuinely nicer than handwriting MJML or managing HTML string templates. Their API is clean:

import { Resend } from 'resend';
import { WelcomeEmail } from './emails/WelcomeEmail'; // your React component

const resend = new Resend(process.env.RESEND_API_KEY);

await resend.emails.send({
  from: 'hello@yourdomain.com',
  to: 'user@example.com',
  subject: 'Welcome aboard',
  react: <WelcomeEmail username="dana" />,
});
Enter fullscreen mode Exit fullscreen mode

The trade-off is real though: Resend is younger than SES or Postmark. Their free tier gives you 3,000 emails/month and 100/day, which works for side projects. Where I'd hesitate is high-stakes transactional email (billing receipts, security alerts) on a business that can't absorb the risk of a maturing platform. Their deliverability reputation is building, not built.

The multi-provider pattern worth knowing: some teams run SES as the high-volume workhorse for marketing and notification blasts, and route only critical transactional email — password resets, payment confirmations, account alerts — through Postmark. The logic is sound. SES is cheap at scale but the deliverability for transactional mail can suffer if your sending reputation takes a hit from bulk traffic. Postmark's dedicated transactional IPs are separate from any bulk sending, so your password reset doesn't get caught in the blast radius of a bad campaign. Routing between them is usually a simple conditional in your email service class:

// email-router.ts
export function getTransport(type: 'transactional' | 'bulk') {
  if (type === 'transactional') {
    return postmarkTransport; // isolated reputation, better logs
  }
  return sesTransport; // cheap at volume, acceptable for bulk
}
Enter fullscreen mode Exit fullscreen mode

This isn't over-engineering — it's insurance. If your SES reputation drops because one campaign went sideways, your users can still log in. The two-provider setup adds maybe a half-day of abstraction work and the cost difference at typical small-business transactional volume (under 50K critical emails/month) is negligible against the ops risk you're hedging.

The Things the Docs Don't Warn You About

The thing that burned me hardest with SES was during onboarding. I was testing with a seeded user database — fake accounts, auto-generated emails, the usual dev workflow — and SES tracks bounce rates from the moment you start sending, even in sandbox mode during verification testing. By the time I moved to production, my account's reputation was already carrying those bounces. SES will suspend your sending privileges if your bounce rate climbs above roughly 5%, and they don't care why it happened. I got the suspension email two days after going live. The fix is non-negotiable: scrub your list before you send a single production email. Run every address through a validation pass. Don't assume "it's just test data" is safe — SES has no concept of test-mode forgiveness.

Postmark's gotcha is subtler and produces one of the more confusing error messages I've seen. The 422 that hits you looks like this:

422 Unprocessable Entity
{"ErrorCode": 11, "Message": "The 'From' address you supplied is not a Sender Signature
associated with this account."}
Enter fullscreen mode Exit fullscreen mode

What's maddening is that your domain is verified on the account. The issue is that Postmark distinguishes between a verified domain and a verified Sender Signature. A Sender Signature is tied to a specific from address like hello@yourdomain.com, not just the domain. You can verify yourdomain.com as a domain and still get this 422 if you're sending from noreply@yourdomain.com without that exact address being set up as a Sender Signature. The fix is either to create an explicit Sender Signature for each address you send from, or switch to domain-level verification and enable the "Allow any sender on this domain" option — which is buried in the domain settings, not the Sender Signatures tab.

Resend's DKIM propagation is slower than the UI implies. The spinner stops, the UI shows a green checkmark, and your first instinct is to fire a test email. Don't. DNS propagation for DKIM TXT records can take anywhere from a few minutes to several hours depending on your registrar and TTL settings, and Resend's verification check is essentially polling — it stops when it sees the record once, not when it's fully propagated across resolvers. I've had sends fail with DKIM signing errors 20 minutes after the UI said I was good. The reliable approach: after Resend says it's verified, independently confirm with a tool like dig:

# Replace with your actual DKIM selector and domain
dig TXT resend._domainkey.yourdomain.com +short

# You want to see a real TXT record come back, not empty output
# If it's empty, wait — don't trust the UI alone
Enter fullscreen mode Exit fullscreen mode

All three services share the same suppression list problem when your app generates test email addresses. If your test suite or seed scripts create addresses like user+test1729@yourdomain.com and any of those bounce or trigger spam complaints, that address lands in the suppression list permanently — and future sends to that user (if the pattern collides with real users) silently drop. With SES, suppression list entries via the account-level suppression list can block sends without surfacing an error to your app. The fix I use across all three is to gate bounce handling in the webhook processor:

// Before adding an address to suppression, check if it's test-generated
function shouldSuppress(email) {
  const testPatterns = [
    /\+test\d+@/,
    /seed_user/,
    /@example\.com$/,
    /@mailinator\.com$/,
  ];
  // Never suppress addresses matching test patterns
  return !testPatterns.some(pattern => pattern.test(email));
}
Enter fullscreen mode Exit fullscreen mode

This is especially critical if you run integration tests against real sending infrastructure (which you probably shouldn't, but happens). The suppression list pollution is hard to clean up retroactively — SES requires you to remove entries individually via API or the console, and there's no bulk "remove all test entries" option unless you script it yourself using aws sesv2 delete-suppressed-destination in a loop.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.

Top comments (0)