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
...
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);
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);
Key changes:
-
Explicit month calculation – Using
new Date(year, month + 1, 1)guarantees the first day of the next month, regardless of the current day. -
Overflow guard –
safeDelayforces a minimum of 24 hours. This prevents thesetTimeoutfrom receiving a0or negative value when the server’s clock drifts or when daylight‑saving changes occur. -
Self‑healing fallback – If the computed
delayis 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));
}
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);
});
});
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"
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
- Migrate to a dedicated job queue (e.g., BullMQ) to gain retry semantics and persistence across server restarts.
- Instrument the cron with OpenTelemetry so we can monitor latency and failure rates in real time.
- 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)