Short answer: for rate-limited email sending in a Node.js SaaS backend, use one durable background-job queue for email and webhook attempts, but give each tenant and channel its own admission budget; when PostgreSQL already anchors the application, a database-backed dispatcher is the least complex place to enforce that rule.
| Control location | Tenant budget | Duplicate evidence | Configuration load | Use it when |
|---|---|---|---|---|
| PostgreSQL dispatcher | Application-owned | Kept beside business events | One worker and schema | The database already anchors the SaaS |
| Managed job service | Application policy carried in messages | Split between ledger and queue | Another service contract | Workers need separate scaling and ownership |
| Email transport scheduler | Transport-specific | Application ledger still required | Smallest local scheduler | The workflow is email-only and intentionally tied to one transport |
Recommendation: start with the PostgreSQL dispatcher for a B2B SaaS that must retry outbound webhooks without duplicate deliveries. The database is not automatically the fastest option, and it is not free operationally. It wins this decision because tenant admission, effect identity, and audit state remain inspectable in one place. A managed job service is the runner-up when worker isolation is the harder requirement.
Price is a weak opening filter. A cheap queue that lets one tenant starve another, or that cannot explain whether a webhook was already accepted, creates an expensive investigation later.
How can governance align a Node.js SaaS email queue with background jobs?
Compare control boundaries, not home-page feature grids. Resend, Postmark, and Amazon SES sit behind the transport boundary in this design. Their current, account-specific limits are inputs to admission control; product names are not a scheduling algorithm. I'm not sure a static comparison table can stay correct for any particular account. The useful resolution is to read the current contract and documentation attached to that account, then run the same controlled workload through each adapter.
The first criterion is who owns admission. A worker should know whether tenant A may start one more email without letting tenant A consume every available claim. A single global sleep(1000) fails that test. It delays quiet tenants behind noisy ones, wastes worker slots, and spreads timing constants through code. Provider throttling still matters, but waiting for a 429 before applying policy turns a known capacity boundary into reactive control.
The second criterion is where duplicate evidence lives. Email and webhook delivery are separate external effects even when one invoice event creates both. Give them stable keys such as invoice:inv_42:email:overdue-v1 and invoice:inv_42:webhook:account_7:v1. Retrying reuses the key. A new template version or a genuinely new business event gets a new key. This does not promise exactly-once networking. It gives operators a durable answer to a narrower and more useful question: which business effect did this attempt intend to perform?
Those two criteria make the vendor comparison testable. For each transport adapter, measure claim delay by tenant, oldest ready effect, attempts per effect key, and accepted receipts. Do not publish a synthetic requests-per-second winner unless the workload also includes a noisy tenant, a quiet tenant, worker termination, and delayed retry eligibility. I care more about the quiet tenant's one urgent job than a flattering aggregate average.
Keep it mean.
The decision matrix also exposes configuration bloat. A managed queue may own timers and worker coordination, yet the application still needs tenant keys, stable effect keys, and a retry classification. A transport scheduler can reduce local timing machinery for an email-only flow, but it cannot become the authoritative ledger for an unrelated customer webhook. Moving the timer is useful. Moving responsibility is a different claim.
Governance separates effect identity from attempt count
A retry answers whether another attempt is eligible. Idempotency identifies the effect that all of those attempts represent. Combining both ideas in a status column and an attempts < 5 condition looks tidy until a worker stops after the remote side accepts a call but before the local success update commits. The next worker sees incomplete local state. Blindly sending again can duplicate the customer-visible effect.
That gap cannot be deleted by picking a different queue. Model it.
Store one row per external effect, enforce uniqueness on its stable key, record each attempt, and retain the transport receipt when one is returned. If a destination accepts an idempotency key, pass the stable effect key through the adapter. If it does not, the ledger still prevents two local workers from intentionally owning the same effect, while ambiguous outcomes need reconciliation rather than an automatic resend. The catch is more state and an operational path for ambiguity. It is still an honest contract.
Email pacing and webhook pacing should remain separate policies. They can share a ledger and worker framework without sharing one bucket. A tenant may have an email backlog while its webhook destination remains healthy; coupling the channels would make unrelated capacity constraints block each other. Likewise, a global email bucket is insufficient for B2B SaaS fairness because one large tenant can consume every ready slot.
Cron has a smaller role here. It is a time-based job scheduler, so it can wake a sweeper or create periodic work. It is not evidence that an outbound effect was accepted, and a cron expression per tenant is config bloat disguised as architecture. One wake-up signal plus durable eligibility timestamps is easier to inspect.
Implementation keeps the TypeScript control loop boring
PostgreSQL documents that SKIP LOCKED skips rows that cannot immediately be locked and notes that it can be used to avoid lock contention with multiple consumers accessing a queue-like table. That makes it suitable for a short claim transaction. It does not supply fairness, leases, backoff, or idempotency; those remain visible application policy.
The example chooses at most one ready effect per tenant, locks a small batch, and gives each claim a lease. The transaction ends before any network call. Holding a database lock open while an email or webhook request runs would tie queue concurrency to network latency, which is exactly the kind of hidden coupling I don't want in an SDK-facing backend.
import type { Pool, PoolClient } from "pg";
type Channel = "email" | "webhook";
type Effect = {
id: string;
tenantId: string;
effectKey: string;
channel: Channel;
payload: unknown;
attempt: number;
};
const claimSql = `
WITH tenant_heads AS (
SELECT DISTINCT ON (tenant_id) id
FROM outbound_effects
WHERE state = 'ready'
AND next_attempt_at <= now()
ORDER BY tenant_id, next_attempt_at, id
), claimable AS (
SELECT effect.id
FROM outbound_effects AS effect
JOIN tenant_heads AS head ON head.id = effect.id
ORDER BY effect.next_attempt_at, effect.id
FOR UPDATE OF effect SKIP LOCKED
LIMIT $1
)
UPDATE outbound_effects AS effect
SET state = 'running',
attempt = attempt + 1,
lease_expires_at = now() + interval '30 seconds'
FROM claimable
WHERE effect.id = claimable.id
RETURNING
effect.id,
effect.tenant_id AS "tenantId",
effect.effect_key AS "effectKey",
effect.channel,
effect.payload,
effect.attempt
`;
async function claimBatch(
client: PoolClient,
size: number,
): Promise<Effect[]> {
const result = await client.query<Effect>(claimSql, [size]);
return result.rows;
}
DISTINCT ON keeps the policy legible, but it is not a complete fairness system. Production selection also needs indexed eligibility columns and durable tenant budgets. Put plan-specific rates and temporary pauses in data with an owner and an update path. Don't grow twelve unexplained environment variables around the worker. Nobody will remember which timeout controlled Tuesday's backlog.
The transport interface is deliberately smaller than the queue. It translates a provider response into an accepted result, a retry time, or a terminal rejection. It does not decide which tenant runs next.
type DispatchResult =
| { kind: "accepted"; receipt: string }
| { kind: "retry"; retryAt: Date; reason: string }
| { kind: "rejected"; reason: string };
interface Transport {
dispatch(effect: Effect, signal: AbortSignal): Promise<DispatchResult>;
}
async function finishAttempt(
pool: Pool,
effect: Effect,
result: DispatchResult,
): Promise<void> {
if (result.kind === "accepted") {
await pool.query(
`UPDATE outbound_effects
SET state = 'accepted', receipt = $2, lease_expires_at = NULL
WHERE id = $1 AND state = 'running'`,
[effect.id, result.receipt],
);
return;
}
if (result.kind === "retry") {
await pool.query(
`UPDATE outbound_effects
SET state = 'ready', next_attempt_at = $2, last_reason = $3,
lease_expires_at = NULL
WHERE id = $1 AND state = 'running'`,
[effect.id, result.retryAt, result.reason],
);
return;
}
await pool.query(
`UPDATE outbound_effects
SET state = 'rejected', last_reason = $2, lease_expires_at = NULL
WHERE id = $1 AND state = 'running'`,
[effect.id, result.reason],
);
}
No provider SDK types leak into the job table. Good. Swapping a transport changes response translation, while the effect key, tenant budget, and retry ownership stay fixed.
Evaluation starts with duplicate delivery drills
Benchmarks should attack ownership. Insert the same invoice event twice and verify that it creates one email effect key and one webhook effect key, not four effects. Give tenant A a deep email backlog and tenant B one ready email, then verify that tenant B gets a claim opportunity. Terminate a worker immediately before dispatch, then immediately after an accepted result but before the local update. Advance the test clock past the lease. Return a retry time later than the ordinary pacing interval. Each case should assert ledger rows and fake-destination calls, not just promise return values.
Use the same fixture for a PostgreSQL dispatcher, a managed job service, and a transport scheduler where the workflow permits it. Feed every candidate identical effect keys and timing. Record oldest-ready age by tenant, claim delay, accepted effects, ambiguous effects, and attempts per effect key. Your mileage may vary with workload and account limits, which is why the fixture and account contract matter more than a borrowed benchmark chart.
A 429 belongs in the adapter test because it exercises retry classification. It should not be the queue's only pacing signal. Also test a terminal rejection and an accepted receipt. Avoid asserting that every transport reports these outcomes in the same wire format; the adapter exists because they don't share application types.
Then kill it twice.
Run the fixture against a shadow worker before changing production ownership. The shadow can operate on copied rows and a fake destination, but it should consume the real tenant-budget shape and emit the same operational measurements. Promotion depends on state-transition agreement and fairness behavior, not merely a lower average latency.
Rollout moves ownership only after the evidence
Choose a managed job service when dispatch workers must deploy or scale independently, when queue traffic is unwelcome on the application database, or when another team already owns that service operationally. Keep the idempotency ledger near the business event and carry the stable effect key in every message. The limitation is a split control plane: queue state alone cannot prove the business effect's identity.
Stick with transport-native scheduling when every external effect is email, one transport is an intentional constraint, and cross-channel ordering does not matter. It is not suitable for the scenario here, where the same SaaS event creates tenant-paced email and a duplicate-sensitive customer webhook. The application still needs shared evidence across those promises.
Stay with PostgreSQL only while its operational boundary remains acceptable. A database-backed queue is a poor fit when dispatch load threatens transactional work, independent scaling is mandatory, or the team does not want to own leases and recovery. There is no universal cheapest SaaS backend once operational ownership enters the calculation. Choose the control plane your team can test, observe, and explain during an ambiguous delivery.
The decision is intentionally dull: keep admission and identity together until a concrete ownership constraint justifies separating them.
Top comments (0)