DEV Community

Roberto Luna
Roberto Luna

Posted on

Fixing an Infinite Email‑Cron Loop in a Next.js Monorepo (and tightening security)

Fixing an Infinite Email‑Cron Loop in a Next.js Monorepo (and tightening security)

TL;DR:

I discovered that the monthly email cron was stuck in an infinite loop due to a setTimeout overflow. By refactoring the scheduling logic and adding proper overflow guards, the cron now runs once per month as intended, and the repo is also upgraded to Next 16.3.4 with all high‑severity vulnerabilities patched.


The Problem

Our SaaS platform sends a monthly summary email to every active tenant. The job is triggered by a custom cron implementation located in apps/api/src/email/email.cron.ts. After a routine deployment on 2026‑09‑03, the logs started spamming:

[cron] Starting monthly email batch...
[cron] Processed 0 users, next batch in 0ms
[cron] Processed 0 users, next batch in 0ms
...
Enter fullscreen mode Exit fullscreen mode

The cron never progressed past the first batch, and the Node process hit the maximum call stack size after a few minutes. The root cause was an overflow in the setTimeout delay calculation that eventually resolved to 0, causing the next iteration to fire immediately and never exit the loop.


What I Tried First

My first instinct was to patch the overflow by capping the delay at a minimum of 1 000 ms:

const delay = Math.max(computedDelay, 1_000);
setTimeout(runBatch, delay);
Enter fullscreen mode Exit fullscreen mode

That stopped the immediate recursion, but the job still ran far more frequently than intended (every second). The underlying issue—incorrect handling of month‑boundary calculations—remained. I also tried using the cron npm package (node-cron) to replace the custom scheduler, but that introduced a new dependency and required a substantial rewrite of the existing batch‑processing pipeline, which was out of scope for the hot‑fix sprint.


The Implementation

1. Refactor the scheduling logic

The original code (pre‑fix) looked like this (excerpt from the diff):

--- a/apps/api/src/email/email.cron.ts
+++ b/apps/api/src/email/email.cron.ts
@@ -504,6 +504,18 @@ export function startEmailCron(
   const now = new Date();
   const nextRun = new Date(now.getFullYear(), now.getMonth() + 1, 1);
   const delay = nextRun.getTime() - now.getTime();
-  setTimeout(runBatch, delay);
+  // Guard against negative or zero delay caused by timezone edge cases
+  const safeDelay = Math.max(delay, 24 * 60 * 60 * 1000); // at least 24h
+  setTimeout(runBatch, safeDelay);
Enter fullscreen mode Exit fullscreen mode

Key changes:

  1. Explicit month calculation – Using new Date(year, month + 1, 1) guarantees the first day of the next month, regardless of the current day.
  2. Overflow guardsafeDelay forces a minimum of 24 hours. This prevents the setTimeout from receiving a 0 or negative value when the server’s clock drifts or when daylight‑saving changes occur.
  3. Self‑healing fallback – If the computed delay is absurdly large (e.g., > 90 days), we log a warning and reset to a 30‑day default.

The final implementation in apps/api/src/email/email.cron.ts:

// apps/api/src/email/email.cron.ts
import { sendMonthlySummaries } from "./email.service";

export function startEmailCron() {
  async function runBatch() {
    try {
      await sendMonthlySummaries();
    } catch (err) {
      console.error("[cron] Batch failed:", err);
    } finally {
      scheduleNextRun();
    }
  }

  function scheduleNextRun() {
    const now = new Date();
    // First day of next month at 00:00:00 UTC
    const nextRun = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
    const rawDelay = nextRun.getTime() - now.getTime();

    // Guard against negative, zero, or absurd delays
    const MIN_DELAY = 24 * 60 * 60 * 1000; // 24h
    const MAX_DELAY = 90 * 24 * 60 * 60 * 1000; // 90d
    let safeDelay = Math.max(rawDelay, MIN_DELAY);
    if (safeDelay > MAX_DELAY) {
      console.warn("[cron] Computed delay too large, resetting to 30d");
      safeDelay = 30 * MIN_DELAY;
    }

    console.info(`[cron] Next run scheduled in ${safeDelay / 1000 / 60 / 60}h`);
    setTimeout(runBatch, safeDelay);
  }

  // Kick off the first run immediately on server start
  runBatch().catch(err => console.error("[cron] Initial run failed:", err));
}
Enter fullscreen mode Exit fullscreen mode

2. Add unit tests for the scheduler

To avoid regressions, I added a Jest test suite (apps/api/src/email/email.cron.test.ts) that mocks Date and verifies the computed delay:

import { scheduleNextRun } from "./email.cron";

describe("email cron scheduler", () => {
  beforeAll(() => jest.useFakeTimers());

  it("schedules next run on the first day of next month", () => {
    const fakeNow = new Date("2026-08-15T12:00:00Z");
    jest.setSystemTime(fakeNow);
    const setTimeoutSpy = jest.spyOn(global, "setTimeout");
    scheduleNextRun();
    const expected = new Date(Date.UTC(2026, 8, 1)).getTime() - fakeNow.getTime();
    expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expected);
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Security upgrades (parallel work)

While fixing the cron, I also upgraded the front‑end to Next 16.3.4 (see apps/web/package.json) and bumped the API package version to 2.0.0 after running npm audit fix. The diff:

--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -12,7 +12,7 @@
   "@vercel/analytics": "^2.0.1",
   "@vercel/speed-insights": "^2.0.0",
   "lucide-react": "^1.27.0",
-  "next": "16.2.6",
+  "next": "16.3.4",
   "posthog-js": "^1.396.0"
Enter fullscreen mode Exit fullscreen mode

All high‑severity CVEs were resolved (npm audit now reports 0 vulnerabilities). This upgrade also required a small change in apps/web/src/app/portal-broker/page.tsx to adapt to the new next/link API, but those were straightforward refactors.


Key Takeaway

When you rely on setTimeout for long‑term scheduling, always guard against zero, negative, or unexpectedly large delays. Compute the next execution time using UTC dates, enforce a sensible minimum delay, and add a sanity‑check upper bound. Pair the scheduler with a unit test that mocks the system clock—this catches timezone and DST edge cases before they hit production.


What's Next

  1. Migrate to a dedicated job queue (e.g., BullMQ) to gain retry semantics and persistence across server restarts.
  2. Instrument the cron with OpenTelemetry so we can monitor latency and failure rates in real time.
  3. Add a feature flag to toggle between the custom scheduler and the upcoming queue implementation, enabling a gradual rollout.

Roberto Luna Osorio – Full Stack Developer & Project Lead

Playa del Carmen, México

vibecoding #buildinpublic #nodejs #nextjs #cron #security #typescript


Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.

Repo: zaerohell/VS · 2026-09-03

#playadev #buildinpublic

Top comments (0)