Short answer: make the Next.js health route read a durable event ledger written by the background worker, then let the uptime monitor alert when a scheduled import has not produced a successful result by its deadline.
A process-only check answers, "Can this HTTP process respond?" That is useful, but it cannot prove that yesterday's customer import was scheduled, claimed, executed, and completed. For incident reconstruction, the check needs evidence from the work itself.
The shift is small: monitor progress, not presence.
Replace the green process check with an import event ledger
The before model is one bit: GET /api/health returns 200, so the service looks healthy. Meanwhile, a scheduler can stop enqueueing work, a worker can stop polling, or an import can run without producing a result. The web process may remain perfectly responsive through all three failure modes.
The after model is a short timeline stored outside the API and worker processes. Picture it left to right: scheduled -> started -> heartbeat -> succeeded or failed. Each event carries one stable run ID, the import ID, its planned time, the observation time, and a small amount of diagnostic context. The health route reads that timeline and asks a narrower question: did every import due before the grace deadline reach a terminal state?
That distinction matters during an incident. If the last event is scheduled, investigate dispatch. If it is started followed by an old heartbeat, investigate worker execution. If it is failed, the error category and attempt number explain what the worker observed. A single red status now points toward a stage instead of merely announcing that something, somewhere, is late.
Keep the ledger durable. An in-memory timestamp disappears during a deploy and splits across Node.js processes, which makes a restarted service look newly healthy while erasing the evidence an operator needs. The writer and reader should share a transactional store that survives both processes. Postgres is a reasonable shape for this example, but the interface can sit over another durable database.
This is the crisp before and after: a green HTTP handler used to describe itself; now it summarizes completed business work.
How should a Next.js API route, background worker heartbeat, and uptime monitor detect failed cron jobs?
Start with an explicit service-level rule, not an arbitrary heartbeat interval. For a scheduled import, define dueAt, an allowed start delay, a heartbeat expiry while it is running, and a completion deadline. The monitor should classify the latest durable evidence against those boundaries.
Use four states in the response:
| State | Evidence | Operator interpretation |
|---|---|---|
ok |
The latest due run succeeded | Results are arriving within the agreed window |
late |
A due run has not started | Scheduler, queue handoff, or worker pickup needs attention |
stalled |
A running job's heartbeat is older than its limit | Execution stopped making observable progress |
failed |
The worker wrote a terminal failure | The run ended without a usable result |
Don't let the worker calculate the overall health state. It should report facts about its own run. The route owns classification because it has the current clock and can compare every due import using one policy. That also keeps a crashed worker from declaring itself healthy.
Return 200 only for ok; return 503 for the other three states so an ordinary uptime monitor can alert without understanding a custom protocol. The JSON body still carries the stage, run ID, and timestamps needed for triage. A short cache policy matters too — a cached green response can hide the exact transition the probe exists to catch.
There is one tricky edge. A heartbeat says that code reached a checkpoint; it doesn't prove that an import produced a correct result. Record a terminal success only after the result is durably committed, and include a result count or result reference that lets an operator distinguish "completed with zero records" from "never completed." Whether zero records is healthy depends on the import contract. I'm not sure a universal default exists; expected cardinality or an upstream manifest is what resolves that ambiguity.
Build one copyable Node.js checkpoint path
First, give the worker and route a deliberately small storage contract. The store implementation should enforce uniqueness for runId plus kind, and it should reject event-time regression for a run. Those constraints turn retries into repeatable writes instead of a second fictional timeline.
type EventKind = "scheduled" | "started" | "heartbeat" | "succeeded" | "failed";
type ImportEvent = {
runId: string;
importId: string;
kind: EventKind;
dueAt: string;
observedAt: string;
attempt: number;
resultCount?: number;
errorCode?: string;
};
interface CheckpointStore {
append(event: ImportEvent): Promise<void>;
latestDueRun(now: Date): Promise<ImportEvent[]>;
}
The scheduler writes scheduled when it creates the run. The worker then appends progress around the actual import. Notice where success is recorded: after commitResults finishes, not when processing begins.
type ImportJob = {
runId: string;
importId: string;
dueAt: string;
attempt: number;
};
declare const checkpoints: CheckpointStore;
declare function fetchSource(importId: string): Promise<unknown[]>;
declare function commitResults(importId: string, rows: unknown[]): Promise<number>;
const writeEvent = (job: ImportJob, kind: EventKind, extra: Partial<ImportEvent> = {}) =>
checkpoints.append({
runId: job.runId,
importId: job.importId,
kind,
dueAt: job.dueAt,
observedAt: new Date().toISOString(),
attempt: job.attempt,
...extra,
});
export async function runImport(job: ImportJob): Promise<void> {
await writeEvent(job, "started");
try {
const rows = await fetchSource(job.importId);
await writeEvent(job, "heartbeat");
const resultCount = await commitResults(job.importId, rows);
await writeEvent(job, "succeeded", { resultCount });
} catch (error) {
const errorCode = error instanceof Error ? error.name : "UnknownError";
await writeEvent(job, "failed", { errorCode });
throw error;
}
}
For long imports, write additional heartbeats at meaningful checkpoints such as pages committed, not from an unrelated timer that can keep ticking while useful work is wedged. Also make cancellation explicit. A deployment that intentionally hands a job to another worker should create a new attempt under the same run ID, preserving the chain rather than overwriting the old timestamps.
Next comes the Next.js App Router handler. This version reads the latest due run, reduces its events to the latest observation, and applies two separate thresholds: five minutes to begin and ten minutes without progress while running. They are example policy values, not universal recommendations; choose them from the import schedule and the delay users can tolerate.
import { NextResponse } from "next/server";
declare const checkpoints: CheckpointStore;
type HealthState = "ok" | "late" | "stalled" | "failed";
const START_GRACE_MS = 5 * 60_000;
const HEARTBEAT_LIMIT_MS = 10 * 60_000;
export async function GET(): Promise<NextResponse> {
const now = new Date();
const events = await checkpoints.latestDueRun(now);
const latest = events.at(-1);
let state: HealthState = "late";
if (latest?.kind === "succeeded") state = "ok";
if (latest?.kind === "failed") state = "failed";
if (latest?.kind === "scheduled") {
const startDeadline = new Date(latest.dueAt).getTime() + START_GRACE_MS;
state = now.getTime() > startDeadline ? "late" : "ok";
}
if (latest?.kind === "started" || latest?.kind === "heartbeat") {
const ageMs = now.getTime() - new Date(latest.observedAt).getTime();
state = ageMs > HEARTBEAT_LIMIT_MS ? "stalled" : "ok";
}
return NextResponse.json(
{
state,
runId: latest?.runId ?? null,
importId: latest?.importId ?? null,
dueAt: latest?.dueAt ?? null,
lastObservedAt: latest?.observedAt ?? null,
lastEvent: latest?.kind ?? null,
attempt: latest?.attempt ?? null,
},
{
status: state === "ok" ? 200 : 503,
headers: { "Cache-Control": "no-store" },
},
);
}
Probe this route from outside the deployment boundary. A check running inside the same process can share its DNS, network, or runtime failure and vanish at the same moment as the service it watches. The external probe only needs the status code for paging; retain the response body with the alert event so the first person on call gets the last known stage immediately.
One warning: don't put credentials, source payloads, customer identifiers, access tokens, or raw exception messages in the public health response. The OWASP logging guidance calls out data that should usually be excluded or masked. Apply the same discipline to this endpoint and to the ledger. Use opaque import and run identifiers, constrain access, and keep richer diagnostics in protected telemetry.
Two objections: traffic checks and queue dashboards
"We already alert when the API has no traffic." That signal catches a different failure. A developer-tools SaaS may have quiet periods while scheduled imports still owe results, and normal API traffic can continue while a single cron path is broken. Traffic is supporting context; the due-run ledger is direct evidence for this job.
"Our queue dashboard already shows failed jobs." Keep it. Queue-native metrics are excellent for execution detail, but the business boundary may extend beyond the queue: scheduling happens before enqueue, and durable result commit happens after a handler starts. The event ledger connects those stages with one run ID. During reconstruction, compare its timestamps with queue depth, worker logs, database commit records, deployment events, and the external probe. No single green chart gets veto power.
The same separation improves alert quality. Page on a missed result deadline or a truly expired heartbeat. Send retry counts, slowly rising duration, and isolated terminal failures to a lower-urgency channel when policy permits automatic recovery. The exact split depends on import frequency and user impact — your mileage may vary — but every alert should name the violated contract and the evidence that triggered it.
Test the transitions with a controlled clock. Cover a run that is scheduled but still inside its grace period, one that never starts, one whose heartbeat expires, one that succeeds with zero records, one that fails, and a retry that later succeeds. Then deploy the route and worker separately in a staging environment and verify that the external monitor retains the response for each forced state.
Crisp tests beat hopeful dashboards.
Choose this design for reconstruction, not universal health
An event ledger is a good fit when scheduled work has a deadline, multiple components touch the run, and an operator must reconstruct which stage stopped producing results. It is deliberately more specific than a generic process probe. Keep a lightweight liveness endpoint as well when an orchestrator needs to decide whether one container should restart; don't make a database dependency turn that local restart signal red.
The catch is storage and policy ownership. Every heartbeat adds writes, retention needs a limit, clocks need consistent handling, and a badly chosen deadline will create noisy alerts. This design is not suitable when a job finishes in a single short transaction and the database already exposes an authoritative completion record; monitor that record directly. Stick with a queue's native retry and failure view when the queue truly contains the whole workflow and cross-stage reconstruction adds no operational value.
For the scheduled-import case, the decision rule is simple. Use HTTP liveness to answer whether the Next.js route can respond. Use checkpoint freshness to answer whether the Node.js worker is progressing. Use a committed result deadline to answer whether the SaaS delivered the work.
Alert on the last one, and attach the first two as evidence.
Top comments (0)