For a Node.js SaaS, a Healthchecks alternative for cron job monitoring is useful only if it can attribute a missed customer-notification run to the right account and region.
Short answer: run four failure drills against a pseudonymous test account, measure the signal volume each design creates, and choose the simplest heartbeat receiver that preserves account-level cost attribution and US/EU alert routing without storing notification content.
That flips the usual evaluation. Don't begin with a feature grid. Begin by breaking a synthetic delivery schedule in controlled ways, because a green process check says little about one customer's absent batch. The receiver that survives those drills with an acceptable ownership burden is the easier setup.
Prove reliability with four controlled failures
Create a test account called acct_test_7f2 and give it one EU email batch scheduled for 10:00 UTC with a 12-minute grace period. No real recipient belongs in this fixture. The useful output is an incident key that support can resolve through an authorized internal lookup, plus dimensions that finance can count: account, region, channel, job, and scheduled slot.
Now rehearse four failures. First, withhold the heartbeat entirely; one missed-job alert should open after 10:12, not before. Second, record two attempts for the same slot; they should remain evidence for one scheduled obligation. Third, complete a US SMS batch at the same time; that completion must not close the EU email incident. Fourth, deliver the EU completion late; the system should preserve the fact that the deadline was missed even though the eventual outcome is known.
One obligation. One identity.
This exercise exposes a cost problem that plan pages cannot answer. A single global heartbeat creates very few signals but cannot allocate an account-specific failure. A random identity for every attempt creates more incident groups than the support obligation requires. The middle path is a deterministic identity for the scheduled batch, with attempt count attached as evidence rather than baked into identity.
Write the expected observations before configuring a receiver:
| Drill | Passing observation | Cost-attribution check |
|---|---|---|
| No completion | One incident after the grace period | Incident retains account and region |
| Duplicate attempt | No new obligation identity | Attempts remain countable separately |
| Other region completes | Original incident stays open | US and EU usage remain distinct |
| Late completion | Missed deadline remains reviewable | Work and alert signals can both be counted |
The numbers above are fixture values, not a production recommendation. A real grace period should reflect the delivery promise, scheduler jitter, retry policy, and when support can still act. I'm not sure there is a useful universal default; a measured distribution of normal completion times would resolve that uncertainty for a particular service.
Govern heartbeat identity with a small state machine
The implementation needs three facts: what was scheduled, what completion arrived, and what time the detector is evaluating. Keep message bodies, ticket text, email addresses, and phone numbers outside the monitoring record.
The TypeScript below makes the state transition explicit. It is local on purpose. You can test the contract before choosing any network endpoint.
type Region = "us" | "eu";
type Channel = "email" | "sms" | "push";
type Schedule = {
accountKey: string;
region: Region;
channel: Channel;
job: "support-notification-delivery";
slot: string;
graceMinutes: number;
};
type Completion = {
scheduleId: string;
completedAt: string;
attemptCount: number;
};
type RunState =
| { kind: "waiting"; scheduleId: string }
| { kind: "completed"; scheduleId: string; attemptCount: number }
| { kind: "missed"; scheduleId: string; detectedAt: string };
function scheduleId(item: Schedule): string {
return [item.accountKey, item.region, item.channel, item.job, item.slot].join(":");
}
function inspectRun(
item: Schedule,
completions: Completion[],
now: Date,
): RunState {
const id = scheduleId(item);
const completion = completions.find((entry) => entry.scheduleId === id);
if (completion) {
return { kind: "completed", scheduleId: id, attemptCount: completion.attemptCount };
}
const deadline = new Date(item.slot).getTime() + item.graceMinutes * 60_000;
if (now.getTime() <= deadline) return { kind: "waiting", scheduleId: id };
return { kind: "missed", scheduleId: id, detectedAt: now.toISOString() };
}
const euEmail: Schedule = {
accountKey: "acct_test_7f2",
region: "eu",
channel: "email",
job: "support-notification-delivery",
slot: "2026-08-21T10:00:00.000Z",
graceMinutes: 12,
};
const state = inspectRun(
euEmail,
[],
new Date("2026-08-21T10:13:00.000Z"),
);
if (state.kind !== "missed") {
throw new Error("The synthetic EU delivery should be marked missed");
}
Read it as a diagram: schedule enters waiting -> matching completion moves it to completed -> absent completion after the deadline moves it to missed. The state machine doesn't infer delivery from process health. It asks about the scheduled customer obligation.
There is an intentional limitation here. A completion received after the detector has opened an incident needs an event history if the team wants to retain both “missed deadline” and “eventually completed.” Overwriting one status with the other loses operational context. Store transitions as records or preserve both timestamps; the exact storage engine is a deployment choice.
Evaluate signal volume before comparing price
Cost attribution starts with counts, not currency. For each account and region, count scheduled obligations, completion events, opened incidents, and alert deliveries. Keep attempt volume alongside those counts because notification work and monitoring traffic answer different questions. A finance policy can apply rates later without forcing the heartbeat identity to contain billing logic.
type Usage = {
accountKey: string;
region: Region;
scheduled: number;
completions: number;
incidents: number;
alerts: number;
};
function addUsage(current: Usage, next: Partial<Omit<Usage, "accountKey" | "region">>): Usage {
return {
...current,
scheduled: current.scheduled + (next.scheduled ?? 0),
completions: current.completions + (next.completions ?? 0),
incidents: current.incidents + (next.incidents ?? 0),
alerts: current.alerts + (next.alerts ?? 0),
};
}
const euUsage = addUsage(
{
accountKey: "acct_test_7f2",
region: "eu",
scheduled: 1,
completions: 0,
incidents: 0,
alerts: 0,
},
{ incidents: 1, alerts: 1 },
);
Small counters beat vague estimates.
Run the same four drills through each candidate design and record the resulting counts. This makes hidden multiplication visible. For example, retry evidence should increase attempt-related work without inventing a second scheduled obligation. Regional routing may create more than one alert delivery for one incident, and that should be visible rather than silently treated as another failure.
Cost is not the only boundary. GDPR Article 17 defines a right to erasure, so records in scope need a deletion path. Test that acct_test_7f2 can be located and removed under an approved request without searching notification content. Region is a routing and allocation dimension here; its presence alone does not establish legal compliance.
How can a Node.js SaaS migrate cron heartbeat monitoring?
Compare migration targets after the drills pass. A hosted heartbeat receiver can reduce infrastructure ownership. A scheduler-native monitor can reuse an existing cloud control plane. A general event pipeline can carry the same incident alongside other application signals. A self-hosted monitor gives the team direct responsibility for storage and deployment. None is automatically easiest, and the stable schedule identity should survive a move between them.
Score each category on six things: who patches it, how incidents are grouped, how regional routing is expressed, how records are deleted, how usage is exported by account, and how the detector itself is watched. Sentry's event-grouping documentation is a useful concrete reference for the effect of fingerprints on grouping. The transferable lesson is broader: the scheduled obligation should drive incident identity, while retry attempts remain evidence attached to that incident.
The catch is real. Account-level heartbeats are not suitable for a single global nightly task when nobody needs customer-level routing or allocation; use one deadline check and avoid the extra records. A global check is not suitable when support must identify one affected account or finance must separate regional usage. In that case, retain the pseudonymous schedule identity in an application-owned ledger even if the alert receiver stores only a redacted key.
Don't confuse fewer setup screens with less work. Self-hosting adds deployment and maintenance duties. A hosted tool adds an external data destination and its associated review. Reusing a broad event pipeline can reduce tool count but may require careful grouping and retention configuration. Your mileage may vary — the team's existing skills and policies decide which burden is smaller.
Deploy through a reversible adapter
Put the state-machine fixture in continuous integration. In a test environment, schedule acct_test_7f2, suppress its completion, and require one correctly routed alert after the chosen grace window. Then complete a different-region fixture and confirm that it cannot resolve the first incident. This is the crisp before/after: before rollout, a worker can look healthy while an account batch disappears; after rollout, the detector names the missing obligation without exposing the notification.
Watch the detector separately from the delivery worker. Expected schedules must be created independently enough that a silent worker cannot erase both the completion and the evidence that completion was required. The upper-level check asks whether detection is running. The lower-level state machine asks which customer obligation missed its deadline.
Finally, retain the adapter boundary. Receiver-specific grouping, authentication, and routing belong in one outbound component; schedule identity, the drills, and usage counters do not. A later receiver change should rerun the same acceptance suite, not redefine failure.
Choose only after the evidence lines up: one obligation, correct regional route, deletable pseudonymous record, and explainable usage counts. That is a practical missed-job monitoring system for customer notifications, not merely a green light beside a Node.js process.










