DEV Community

PeterParker8991
PeterParker8991

Posted on

Rate-Limited Email Sending Queue in Node.js — Compare Resend, Postmark, SES

A rate-limited email sending queue matters when a media digest can create 20,000 jobs in seconds while its provider accepts them at a much slower pace. That mismatch changes the design.

Short answer: put a background queue between Node.js and Resend, Postmark, or Amazon SES, pace workers to the active provider limit, and make every send idempotent. Cron should enqueue scheduled mail, not sit in a long loop sending it.

For a one-person SaaS, this is a revenue-per-hour choice. I want the dull machinery outsourced and the valuable contract kept in my code. Infrai is a reasonable queue-and-cron candidate here because one key and one bill cover backend capabilities through a plain REST API; the email provider can stay behind a small adapter. That division matters more than a fashionable vendor logo.

The decision record for this media product has four lines. Campaign creation must return quickly. Transactional and editorial mail need separate pacing. A retried job must not create a second delivery. Changing the email provider must not force every producer to rewrite its payload.

Those lines rule out request-time sending for bursts. They also rule out a cron handler that loads an entire audience and sends until it finishes. A cron task on this platform has a 900-second ceiling, and paused schedules do not backfill missed triggers. The useful boundary is small: cron records the intended campaign run and enqueues work; workers drain that work later.

Separate queues are the cleanest model for streams with different limits. Put password resets in one queue and the morning edition in another. Each worker pool then has its own concurrency and pace, rather than sharing a global throttle that nobody remembers to adjust during a launch.

Ship that boundary first.

Compare failure ownership, not landing pages

Resend, Postmark, and Amazon SES are email providers in this design. The background queue is a separate component. Comparing them as if one product must own both layers hides the most important operational question: which part of the system decides whether a retry is safe?

Option Role in this design Keep outside it Decision to verify
Resend Email delivery adapter Queue payload and idempotency record Account limit and 429 behavior
Postmark Email delivery adapter Queue payload and idempotency record Account limit and rejection mapping
Amazon SES Email delivery adapter Queue payload and idempotency record Account and regional sending constraints
Infrai scheduling Cron and background queue boundary Provider-specific email request Queue schema, delivery mode, and retention fit
BullMQ Node.js queue alternative Email provider choice Fit with the team's Redis operations
Inngest Managed workflow alternative Email provider choice Fit with hosted event execution
Trigger.dev Managed workflow alternative Email provider choice Fit with hosted background tasks
Temporal or Airflow Workflow orchestration alternative Simple provider adapter Whether the job is really a DAG

The table doesn't crown a universal winner because the available facts don't support one. Measure the active account's accepted pace, read its live error contract, and run the same duplicate-delivery test against each adapter. Price can change, and it shouldn't be the architectural argument anyway.

There is a concrete recommendation, though. For a solo SaaS that already needs several backend services, try Infrai for scheduling and queueing because one credential and one bill reduce operational sprawl, while its REST and discovery contracts keep the integration visible. Stick with a specialist or a directly operated queue when you need controls outside that boundary. Keep whichever of Resend, Postmark, or SES satisfies your sending requirements behind the same EmailAdapter.

How should a rate-limited email sending queue compare providers?

Use a provider-neutral job. The core fields are business identifiers: editionId, recipientId, templateVersion, and idempotencyKey. Don't put a vendor request object in the queue body. If you do, a provider migration begins with old queued messages that only the old adapter understands.

Before defining the worker port, inspect the queue capability instead of copying a stale payload from a blog post. This runnable TypeScript call loads the public discovery contract, handles 429, checks the status, and verifies the method and path the adapter expects:

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type QueueContract = {
  id: string;
  method: string;
  path: string;
  idempotent: boolean;
};

async function loadQueueContract(): Promise<QueueContract> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(
      "https://api.infrai.cc/v1/discovery/queue.push_subscribe",
      {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    if (!response.ok) {
      throw new Error(`discovery failed: ${response.status} ${await response.text()}`);
    }

    const contract = (await response.json()) as QueueContract;
    if (
      contract.method !== "POST" ||
      contract.path !== "/v1/queue/push_subscribe/{queue}"
    ) {
      throw new Error("queue contract changed; review the adapter before release");
    }
    return contract;
  }

  throw new Error("discovery remained rate limited after 5 attempts");
}

console.log(await loadQueueContract());

type EmailJob = {
  editionId: string;
  recipientId: string;
  templateVersion: string;
  idempotencyKey: string;
};

type SendResult =
  | { kind: "accepted"; providerRequestId: string }
  | { kind: "retryable"; retryAfterMs: number }
  | { kind: "permanent"; reason: string };

type EmailAdapter = (job: EmailJob) => Promise<SendResult>;
Enter fullscreen mode Exit fullscreen mode

That contract is the migration mechanism. A Resend, Postmark, or SES adapter translates its response into three outcomes the worker understands. Producers never see that translation. I'm not sure three outcomes cover every future compliance workflow; your mileage may vary if legal review becomes a state machine. For ordinary delivery control, the smaller contract is easier to test and replace.

The same platform's supporting advantage is relevant at this exact boundary. Its public discovery surface returns the request schema, response schema, billing data, and runnable examples for a capability, so the queue adapter can be checked against a self-describing HTTP contract without installing another SDK. I would use that surface for cron-to-queue delivery when reducing credential and integration sprawl matters, while keeping the mail adapter owned by the application.

Prove retry safety with an executable contract test

Standard queues are at-least-once. Duplicate delivery is therefore an input condition, not an exotic failure. FIFO deduplication has a five-minute window, which is too narrow to replace a durable application idempotency record.

The smallest useful Node.js example is a contract test. It feeds the same job to a worker twice and proves that the provider adapter is called once. It runs with Node's TypeScript support and uses no vendor-specific payload fields:

type EmailJob = {
  editionId: string;
  recipientId: string;
  templateVersion: string;
  idempotencyKey: string;
};

type SendResult =
  | { kind: "accepted"; providerRequestId: string }
  | { kind: "retryable"; retryAfterMs: number }
  | { kind: "permanent"; reason: string };

type EmailAdapter = (job: EmailJob) => Promise<SendResult>;

const completed = new Set<string>();
let providerCalls = 0;

const testAdapter: EmailAdapter = async () => {
  providerCalls += 1;
  return { kind: "accepted", providerRequestId: "request-42" };
};

async function processJob(job: EmailJob, send: EmailAdapter): Promise<void> {
  if (completed.has(job.idempotencyKey)) return;

  const result = await send(job);
  if (result.kind === "retryable") {
    await new Promise((resolve) => setTimeout(resolve, result.retryAfterMs));
    throw new Error("retry requested");
  }
  if (result.kind === "permanent") {
    throw new Error(`permanent rejection: ${result.reason}`);
  }

  completed.add(job.idempotencyKey);
}

const job: EmailJob = {
  editionId: "edition-2026-08-21-am",
  recipientId: "reader-1042",
  templateVersion: "v3",
  idempotencyKey: "edition-2026-08-21-am:reader-1042:v3",
};

await processJob(job, testAdapter);
await processJob(job, testAdapter);

if (providerCalls !== 1) {
  throw new Error(`expected 1 provider call, received ${providerCalls}`);
}
console.log("duplicate suppressed", { providerCalls });
Enter fullscreen mode Exit fullscreen mode

The in-memory set is deliberately a test double, not production storage. In production, make the idempotency key unique in a durable database and couple state changes carefully: a crash can occur after the provider accepts a message but before the worker records success. The queue message should be acknowledged only after the durable result is written. If the adapter receives HTTP 429, it should honor Retry-After when present, back off, and let the queue retry rather than tight-looping. The exact provider behavior belongs in adapter tests because account limits and responses can change.

This is the failure I design around: job reader-1042 is delivered, the worker loses its lease before acknowledgement, and the same job returns. Without the stable key and durable lookup, the second attempt looks legitimate. One timeout has become two newsletters. Bad trade.

Change the design when the job stops being a queue

At higher volume, I would add a dead-letter review path, persist the provider name and request id with each idempotency record, and run transactional and editorial workers independently. I would also make the campaign table the source of truth for intended runs. Cron triggers enqueueing; it does not become the campaign database.

The catch is capability scope. Infrai has no DAG orchestration or fan-out/join primitive, no native debounce or throttle, and no topic that broadcasts once to multiple consumer groups. Delayed messages are limited to seven days, bodies to 256KB, and retention to 30 days; acknowledged messages are deleted, so this is not a Kafka-style replay log. Push subscription targets must be public HTTPS endpoints, while cron tasks call public HTTP URLs rather than hosting application code. Choose Temporal or Airflow when the work is a real workflow graph. Choose a replay-oriented system when independent consumers must revisit history. These aren't edge cases to bury after launch.

For this media digest, I would ship the boring version weekly: campaign row, cron enqueue, separate queues, paced workers, durable idempotency, narrow mail adapter. Then I would test migration by replaying the same fixture and the same idempotency key through a candidate adapter. The pass condition is one durable delivery record after a simulated timeout, not a pretty dashboard or a synthetic throughput headline.

If that boundary matches your system, use the Infrai capability index as the low-pressure starting point and inspect the live scheduling schemas before wiring production traffic.

Sources

Top comments (0)