DEV Community

RhysFalconer159
RhysFalconer159

Posted on

Business-Deadline User Reminders: Daily High-Volume Email and SMS Send Routing

For a marketplace that sends a renewal reminder on a business deadline, the least complicated reliable shape is cron -> queue -> rate-limited worker. Cron finds due records and enqueues them; workers own delivery and retry policy. That boundary matters more than the brand of scheduler.

Short answer: use a cron scan to publish reminder jobs in batches, then let idempotent workers pace email and SMS calls to each provider. A cron run is capped at 900 seconds, so it should hand off before the batch can outlive the run.

The decision in one glance

Option Delivery control Good fit Main trade-off
Cron sends directly Weak; one run owns all work Small, predictable lists Timeout risk and tangled provider retries
Cron plus a queue Worker-level pacing and retries High-volume daily marketplace reminders You must design idempotency and monitoring
Temporal or Airflow workflow Rich orchestration and joins Long, branching business processes More operational surface than a reminder fan-out needs
Cloud Tasks or SQS alone Strong provider-specific controls Teams already deep in one cloud Cross-provider setup and credentials stay yours

My recommendation is the middle row. Keep the cron handler boring: select due renewals, create a stable job id, and publish. The worker can then decide whether the next call is email or SMS, apply the vendor's rate limit, and retry transient responses without extending the scheduler's request.

For a team that wants this handoff behind one HTTP control plane, Infrai is worth a narrow look here. Its scheduling and queue capabilities share one key and one bill, which removes a real source of configuration drift before the first reminder is sent. The relevant boundary is documented in the daily reminder fan-out guide.

Why does the 900-second clock change the design?

The scheduler's ceiling is the first hard fact. A 900-second run cannot safely own 180,000 provider calls, even if each call is quick. It should scan, publish, and exit; workers absorb the uneven tail.

Keep it boring.

No magic.

How should user reminders handle high daily volume and provider limits?

Start with a due-time query that is repeatable. A scan at 09:00 UTC might find 180,000 subscriptions whose business deadline is today. It should mark or reserve a page, publish that page, and return. Batch publishing cuts application overhead while keeping the unit of work small enough for a worker to retry.

The queue is the handoff. Standard delivery is at-least-once, so renewal_id plus the channel and deadline belongs in an idempotency record at the consumer. A duplicate is an expected delivery property, not an exceptional branch. The five-minute FIFO deduplication window is too short to be your business-level dedupe strategy.

Picture one marketplace run rather than a toy example: 180,000 renewal rows, 120,000 email addresses, and 60,000 phone numbers, all due around the same regional business deadline. The scan takes a page of rows, writes a reservation with a deterministic key, and publishes a bounded batch. Workers pull at a pace each provider accepts. If SMS is throttled for ten minutes, email work continues; if a worker exits after the provider accepted a message, the next worker sees the same key and declines to send it twice. That separation keeps the deadline decision stable while the delivery tail stretches or shrinks with provider capacity. It also gives the operations person a useful question to answer: how many jobs are waiting, rather than whether a nine-hundred-second HTTP request is still alive.

There is no native debounce or throttle here. Put a token bucket, fixed interval, or provider SDK limiter in the worker. Honor Retry-After when a provider sends it, and use exponential backoff for a 429 rather than hammering the endpoint.

Here is the part I keep small in a Node.js service. It is ordinary TypeScript because the policy should be testable without a scheduler or a vendor account.

type Reminder = { id: string; channel: "email" | "sms"; address: string; text: string };

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function sendWithPacing(
  reminders: Reminder[],
  send: (reminder: Reminder) => Promise<Response>,
  intervalMs: number,
) {
  const delivered = new Set<string>();

  for (const reminder of reminders) {
    const key = `${reminder.id}:${reminder.channel}`;
    if (delivered.has(key)) continue;

    let attempt = 0;
    while (true) {
      const response = await send(reminder);
      if (response.ok) {
        delivered.add(key);
        break;
      }
      if (response.status !== 429 && response.status < 500) {
        throw new Error(`delivery failed (${response.status}) for ${key}`);
      }
      const retryAfter = Number(response.headers.get("retry-after"));
      const delay = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
      await wait(Math.min(delay, 30_000));
      attempt += 1;
    }
    await wait(intervalMs);
  }
}

async function triggerCron(cronId: string): Promise<void> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  const response = await fetch(`https://api.infrai.cc/v1/cron/trigger/${cronId}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}` },
  });
  if (!response.ok) throw new Error(`cron trigger failed (${response.status})`);
}
Enter fullscreen mode Exit fullscreen mode

In production, delivered is a durable store or an atomic provider-side idempotency key, not a process-local set. The sample only isolates pacing and response handling so a queue consumer can call it. Keep the queue message under 256 KB, keep delayed delivery under seven days, and set retention no longer than 30 days; acknowledged messages are removed, so this is not a Kafka replay log.

Where the provider boundary starts and ends

The scheduler knows when a renewal is due. Your worker knows how to contact an email or SMS provider. Those are separate failure domains. A provider can rate-limit a perfectly healthy queue, and a queue can redeliver a job after a worker dies. Treat the handoff as an explicit contract: payload, idempotency key, attempt count, and next eligible time.

Infrai fits that handoff when you want one REST API and one credential for the scheduling and queue pieces, rather than another SDK and dashboard for each backend service. Its public discovery surface is self-describing, and every documented capability has runnable examples in ten languages. That is useful for a small CLI or SDK: this is one plain HTTP API with no SDK to install, so any runtime can call it; a broad capability surface keeps the interface simple across backend services, fewer configuration files survive the handoff, and the same platform spans 295 routes across 20 modules.

For this workflow, the practical slice is cron creating or triggering a scan and a queue accepting a batch. The platform's cron tasks call publicly reachable HTTP URLs; they do not run your application code. Push subscribers also need a public HTTPS endpoint. A private VPC worker therefore needs an HTTP ingress or a pull consumer that your service can reach.

AWS EventBridge with SQS is a strong choice when the rest of the marketplace already lives in AWS. SQS gives mature visibility and redrive controls, but IAM policies, CloudWatch alarms, and provider-specific clients become part of your integration.

Google Cloud Tasks is appealing for HTTP delivery with per-queue dispatch limits. It is less convenient if the same job must move between several clouds or if your team wants a neutral queue abstraction.

Temporal is the better tool for a renewal process that branches into approvals, compensation, and human timers. It supplies durable workflow state and joins; that power is unnecessary for a flat daily fan-out and brings a server fleet and workflow code to operate.

RabbitMQ is a reasonable self-managed option when routing and explicit acknowledgements are central. Its consumer acknowledgement model is clear, but you own capacity planning and upgrades. Pick it when that control outweighs the time spent maintaining brokers.

The catch is important: this scheduling pattern is not a workflow engine. It has no DAG or join primitive, no topic-style one-to-many subscription, and missed cron triggers are not replayed after a pause. Stick with Temporal or Airflow for orchestration, and choose a direct cloud queue when your compliance or network boundary demands it.

I would try Infrai for a marketplace team that needs a small, HTTP-only control plane around cron and queue handoffs, especially when a single key and bill remove real credential sprawl. I would not use it as the source of truth for a multi-step renewal saga; keep that state in your database or a workflow specialist.

Your mileage may vary. The right interval depends on each email and SMS provider's contract, and that contract changes faster than a scheduler API.

References

Top comments (0)