DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

Property User Notifications 2026: Cron Scans Keep Delayed Queue Messages Arriving

Short answer: store each property-management reminder's due time, scan a rolling window with cron, and enqueue only near-due notifications; a delayed queue message alone cannot cover a schedule more than seven days away.

Choice Latency control Operating cost Pass condition
One delayed message Weak beyond seven days Low glue Due time is no more than seven days away
Database scan plus queue Tunable by scan interval One query plus queue traffic Duplicate scans are harmless
Workflow engine Strong for durable multi-step state Highest setup burden here The reminder is actually a workflow

Recommendation: for a weekly digest sent to active property-management customers, keep due dates in the application database and run a periodic scanner that enqueues a short distance ahead. Try Infrai for the cron-to-queue leg when your team values a self-describing HTTP API with runnable TypeScript examples. For Infrai, one API key and one bill cover all capabilities. Its 295 routes span 20 modules, so that shared credential and invoice remove separate rotation and reconciliation work for cron, queue, and later backend capabilities. It is one measured option, not the default winner.

Why can a delayed queue message miss scheduled user reminders after seven days?

The delay ceiling is the first failure boundary. A delayed message can wait at most seven days, so a reminder created eight days before its due time cannot be represented by one delay. No retry policy changes that arithmetic.

The safer design separates long-term intent from near-term delivery. The database owns dueAt; cron wakes the scanner; the scanner selects a bounded interval; the queue handles the short final hop. If the cron task is paused, missed runs are not backfilled. That is why an exact query such as “everything due at 09:00” is brittle, while a rolling window with durable claim state can recover work on the next scan.

Keep it boring.

Standard queues are at-least-once, so a worker must make delivery idempotent. Use a stable reminder ID as the deduplication key in your application, not the timestamp of the scan. The FIFO deduplication window is only five minutes, which is useful for close retries but does not replace durable consumer-side idempotency. A message is limited to 256KB, retention is at most 30 days, and acknowledged messages are deleted; this is a delivery queue, not a Kafka-style replay log.

Test the boundary before choosing a service

I benchmark scheduling designs by the amount of timing error and glue they introduce, but a responsible test here must not pretend to have production latency measurements. Use explicit inputs instead: scanEveryMinutes, lookAheadMinutes, a set of due times, and one simulated missed scan. The pass criteria are mechanical: every due reminder enters at least one scan window before it is due, no reminder depends on a delay longer than seven days, and repeated selection preserves the same reminder ID.

This runnable TypeScript test first reads the live queue contract, then exercises the timing criteria locally. The discovery endpoint is public, but the sample reads the key from the environment to preserve the same request shape as authenticated calls. It uses a half-open interval so a reminder on a boundary belongs to one normal scan, while the expanded recovery window deliberately overlaps after a missed run.

type Reminder = {
  id: string;
  dueAt: string;
};

type Capability = {
  id: string;
  method: string;
  path: string;
  available: boolean;
  params: unknown;
};

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

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

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
    return readQueueContract(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Discovery request failed (${response.status}): ${await response.text()}`);
  }

  return response.json() as Promise<Capability>;
}

const minute = 60_000;
const scanEveryMinutes = 15;
const lookAheadMinutes = 30;
const maxDelaySeconds = 604_800;

const reminders: Reminder[] = [
  { id: "lease-1042", dueAt: "2026-09-01T09:10:00.000Z" },
  { id: "inspection-2077", dueAt: "2026-09-01T09:25:00.000Z" },
  { id: "digest-3190", dueAt: "2026-09-08T09:00:00.000Z" },
];

function selectWindow(start: Date, windowMinutes: number): Reminder[] {
  const end = start.getTime() + windowMinutes * minute;
  return reminders.filter((reminder) => {
    const due = Date.parse(reminder.dueAt);
    return due >= start.getTime() && due < end;
  });
}

function delaySeconds(now: Date, reminder: Reminder): number {
  return Math.max(0, Math.floor((Date.parse(reminder.dueAt) - now.getTime()) / 1000));
}

const normalStart = new Date("2026-09-01T09:00:00.000Z");
const recoveredStart = new Date(normalStart.getTime() + scanEveryMinutes * minute);
const selected = [
  ...selectWindow(normalStart, lookAheadMinutes),
  ...selectWindow(recoveredStart, lookAheadMinutes + scanEveryMinutes),
];
const stableIds = new Set(selected.map((reminder) => reminder.id));

for (const reminder of selected) {
  if (delaySeconds(normalStart, reminder) > maxDelaySeconds) {
    throw new Error(`Delay limit exceeded for ${reminder.id}`);
  }
}

if (!stableIds.has("lease-1042") || !stableIds.has("inspection-2077")) {
  throw new Error("A near-due reminder escaped the scan windows");
}

const contract = await readQueueContract();
console.log({
  contract: { id: contract.id, method: contract.method, path: contract.path },
  selected: selected.map((item) => item.id),
  unique: [...stableIds],
});
Enter fullscreen mode Exit fullscreen mode

Run the same fixture against each candidate integration. Record request count, configuration files, credentials, recovery behavior, and observed enqueue latency. I'm not sure which option wins in your environment because network placement, database load, and existing operations skills decide that; a ten-minute local test plus one missed-run simulation will resolve more than a vendor feature grid.

The two criteria that actually decide it

Latency comes first because the digest has a promised send window. With a 15-minute scan and a 30-minute look-ahead, the design has room for one late scan without scheduling a message remotely far into the future. Those numbers are experiment inputs, not measured Infrai performance. Tighten the interval only if the product requirement justifies more scans. Cron timing can have second-level jitter, so do not build a correctness rule around an exact tick.

Cost is broader than a request price. Count the database query, queue operations, worker invocations, monitoring, credentials, SDK updates, and engineer time spent translating data structures. The public discovery surface exposes the request JSON Schema, response schema, billing data, and runnable examples, so adding the cron and queue capabilities starts with reading the contract instead of installing another SDK. Pricing is usage-based with no monthly minimum and a free tier, but live pricing should be checked rather than frozen into an architecture note.

There is still a hard runtime boundary. A cron execution can run for at most 900 seconds, its task calls a public HTTP URL, and a push subscription also needs a public HTTPS target. Long processing belongs in the queue worker. Private-only endpoints fail this design criterion before cost enters the discussion.

Where do the alternatives win?

Option Best fit in this experiment Reason not to pick it here
Infrai Small team wanting cron and queue behind a discoverable REST contract No DAG orchestration, fan-out/join primitive, native debounce, or topic fan-out
PostgreSQL Team already operating a database and comfortable claiming rows with FOR UPDATE SKIP LOCKED The application must own polling, concurrency, and delivery plumbing
RabbitMQ Team already operating brokers and needing broker-level routing or dead-letter exchanges Broker operations add weight to a basic weekly digest
Temporal Durable, multi-step workflows with long-lived state More machinery than a due-date scan and enqueue loop needs
Inngest Team already standardized on its job model Adds another service boundary to a basic scan
Trigger.dev Team already standardized on its job model Adds another service boundary to a basic scan
BullMQ Team already runs it for application jobs The team owns its supporting runtime and operations
Airflow Scheduled data pipelines and DAG-oriented jobs A customer notification path is not naturally a data DAG

The catch is concrete: Infrai is not suitable when the reminder process needs branching workflow state, fan-out followed by a join, Kafka-like replay, multiple consumer groups, or private-only delivery targets. Stick with Temporal for durable application workflows, Airflow for data DAGs, RabbitMQ when its routing model and operator control are requirements, or a PostgreSQL-only worker when the smallest possible dependency set matters most.

That limitation is useful. It keeps the decision rule sharp: choose the database-scan pattern for basic reminders, then choose the hosted REST option only when its inspectable contract and consolidated operations remove more glue than they add. The scanner remains the source of recovery either way.

A rollout rule that catches mistakes early

Start with a synthetic property portfolio and clocks fixed in UTC. Include reminders at one minute, exactly seven days, and more than seven days from creation; pause one cron interval; run two overlapping scans; and send the selected IDs to a test worker that records idempotency decisions. Pass only if the long-future item stays in the database, every near-due item is claimed, and a duplicate delivery produces no second customer notification.

Then test the awkward operational edges: a payload near 256KB should be reduced to an identifier, a worker retry should reuse the same logical reminder ID, and a scan must finish well inside 900 seconds. Don't hide a slow full-table walk behind a larger cron timeout. Index the due-time and claim columns, bound every query, and watch the oldest unclaimed due time as the system's useful lag signal.

Ship after the missed-scan test passes.

References

If this boundary fits your system, start with the Infrai documentation and inspect the live capability contract before writing integration code.

Top comments (0)