DEV Community

UrbanDonovan1576
UrbanDonovan1576

Posted on

Next.js API Route Health Check vs Background Worker Heartbeat: SaaS Rollback Evidence

A public health endpoint can prove that an API process answers. It cannot prove that a background worker applied the new pricing rule, so it is weak rollback evidence. For a flagged B2B SaaS pricing change, choose a deadline-based worker heartbeat as the release gate; keep endpoint polling as a separate reachability check. Roll back or disable the flag when the heartbeat misses its deadline, the worker reports a failed run, or the processed pricing revision does not match the active revision. This costs a little more state and one authenticated write per run, but it closes the dangerous gap between "the web app is up" and "accounts are receiving the intended price."

TL;DR: expose a cheap Next.js health route for EU and US probes, record each Node worker run in shared durable storage, and evaluate freshness from an independent monitor. Do not make the worker call the public route and declare itself healthy. That circular design can stay green while scheduled work is stalled.

How should a Next.js API route health check monitor a background worker?

The two signals answer different questions. An external probe asks whether DNS, routing, TLS, and the API path work from its vantage point. A heartbeat asks whether a particular scheduled job completed recently enough, in the expected region, against the expected pricing revision. Combining them into one Boolean erases the distinction operators need during rollback.

Consider a pricing rule moving eligible annual accounts from revision price-v17 to price-v18 behind pricing_v18. The API may continue returning 200 while the worker that materializes account prices has stopped. New requests still arrive; old prices remain. An endpoint-only monitor sees success precisely when the business operation is drifting.

The API is alive. The pricing job is not.

The reverse matters too. A regional probe can fail because the public path is unavailable while the worker is still processing queued accounts. That is an availability incident, but it is not evidence that the pricing revision itself is wrong. Separate signals let the response match the failure.

That distinction pays for the extra state.

Build the two-signal path

The data flow is small. Regional monitors request a read-only API route. Separately, the scheduled worker writes a run record only after it finishes processing its batch. An evaluator reads that record, compares its deadline and revision with release expectations, and emits the failed-job alert. The deploy system or flag operator uses that evaluation as rollback evidence.

The handler should stay shallow: it does not test every dependency, mutate data, or expose worker details.

// app/api/health/route.ts
export const runtime = "nodejs";

export async function GET(): Promise<Response> {
  return Response.json(
    { status: "ok" },
    { status: 200, headers: { "cache-control": "no-store" } },
  );
}
Enter fullscreen mode Exit fullscreen mode

Now give the worker a record that carries enough context to make a rollback decision. completedAt is written after the batch succeeds, not when it starts. A start-only ping rewards a hung process with a healthy signal.

type Region = "eu" | "us";

type WorkerRun = {
  worker: "pricing-materializer";
  region: Region;
  revision: string;
  runId: string;
  completedAt: string;
  outcome: "succeeded" | "failed";
};

interface RunStore {
  save(run: WorkerRun): Promise<void>;
  latest(region: Region): Promise<WorkerRun | null>;
}

async function runPricingBatch(
  store: RunStore,
  region: Region,
  revision: string,
  runId: string,
): Promise<void> {
  try {
    await materializeEligibleAccountPrices({ region, revision });
    await store.save({
      worker: "pricing-materializer", region, revision, runId,
      completedAt: new Date().toISOString(), outcome: "succeeded",
    });
  } catch (error: unknown) {
    await store.save({
      worker: "pricing-materializer", region, revision, runId,
      completedAt: new Date().toISOString(), outcome: "failed",
    });
    throw error;
  }
}

declare function materializeEligibleAccountPrices(input: {
  region: Region; revision: string;
}): Promise<void>;
Enter fullscreen mode Exit fullscreen mode

The evaluator needs a deadline derived from the actual schedule and worst acceptable business delay. Do not copy a universal timeout from a blog post. If the job is expected every five minutes, a six-minute deadline may be too tight once queue delay and execution time are included; a one-hour deadline may hide many skipped runs. The correct number comes from the schedule, observed duration distribution, retry policy, and the last moment at which serving the old price remains acceptable.

type Evaluation =
  | { ok: true; ageMs: number }
  | { ok: false; reason: "missing" | "failed" | "stale" | "wrong-revision" };

function evaluateRun(
  run: WorkerRun | null,
  expectedRevision: string,
  nowMs: number,
  deadlineMs: number,
): Evaluation {
  if (!run) return { ok: false, reason: "missing" };
  if (run.outcome === "failed") return { ok: false, reason: "failed" };
  if (run.revision !== expectedRevision) {
    return { ok: false, reason: "wrong-revision" };
  }

  const completedMs = Date.parse(run.completedAt);
  const ageMs = nowMs - completedMs;
  if (!Number.isFinite(completedMs) || ageMs < 0 || ageMs > deadlineMs) {
    return { ok: false, reason: "stale" };
  }
  return { ok: true, ageMs };
}
Enter fullscreen mode Exit fullscreen mode

Evaluate EU and US independently. A single global timestamp lets a healthy US run conceal a stalled EU materializer. It also makes rollback scope ambiguous. Region-specific state permits a regional flag disable when the release mechanism supports it, while a revision mismatch points directly to the new rule rather than generic infrastructure.

Make failure evidence boring and trustworthy

Heartbeat writes should be idempotent by runId, and the store should reject an older completion replacing a newer one. That ordering rule matters when retries finish out of sequence. Use database time for ordering if application hosts can disagree about wall-clock time, and keep the evaluator outside the worker process so a dead worker cannot grade its own homework.

Failed runs deserve explicit records, but absence still has meaning. A process can be terminated before its catch block executes. The deadline converts silence into a detectable state. Short version: errors catch failures; deadlines catch disappearance.

Do not put account identifiers, raw pricing inputs, access tokens, or stack traces into the public route or heartbeat labels. OWASP's logging guidance calls out data that should usually be excluded or protected, including access tokens, passwords, sensitive personal data, and commercially sensitive information. Low-cardinality fields such as worker, region, revision, outcome, and an opaque runId are enough for this decision. Detailed diagnostics belong in access-controlled logs linked by run ID.

The public route should remain cheap. Deep dependency checks can amplify a database problem by adding probe traffic, and returning internal dependency names gives an unauthenticated caller unnecessary detail. If a load balancer needs a readiness decision, keep that contract distinct from the release signal. One status cannot express every audience safely.

What should trigger rollback?

The flag rollout needs a written rule before traffic moves. Disable the new pricing revision when either region has no successful completion within its agreed deadline, reports a failed run, or completes a different revision than the one enabled. An API probe failure pages the availability path; it does not, by itself, prove that the pricing calculation requires rollback.

There is one real trade-off. Automatic flag rollback limits exposure, but a heartbeat can fail because its storage path is impaired while price materialization succeeded. For a pricing change, false rollback is usually safer than silently extending an unverified revision, yet that is a business decision rather than a monitoring default. Document who may override the rollback, what independent evidence is required, and when the rollout may resume.

I would not gate on token-heavy synthetic conversations here. They add model latency, variable usage, and another failure domain without proving that the deterministic materializer finished. A narrow account fixture can validate pricing output before deployment; the production heartbeat then proves timely execution. Different tests, different jobs.

Operate the release without creating a second system

Before enabling the flag, run the evaluator against an empty store and confirm it reports missing. Seed neither a fake success nor an immortal timestamp. Execute one real canary batch for each region, verify both records carry price-v18, then enable the flag for the intended cohort. Watch reachability and heartbeat status as separate alert fields so an operator never has to infer which Boolean failed.

During rollout, preserve the last successful record and every failed run long enough to reconstruct the decision, subject to the service's retention policy. Alert on state transitions rather than sending the same notification every evaluation cycle. Test a stopped scheduler, a thrown batch, an out-of-order retry, a wrong revision, and a regional stall. The rollback path also needs a rehearsal: disabling the flag must return reads to the prior pricing behavior without waiting for the stalled worker.

After the release stabilizes, keep the same signals. The pricing flag may disappear, but the worker deadline still describes a user-visible freshness promise. Review the threshold when the schedule, batch size, retry policy, or regional topology changes. A monitor whose deadline no longer matches the job is just a delayed surprise.

The choice is firm: use deadline-based, region-specific completion heartbeats for failed-job detection and pricing rollback; use external endpoint polling for API reachability. They complement each other, but they are not substitutes.

Sources

Top comments (0)