Fixing an Infinite Email Cron Loop in a Next.js Monorepo and Adding Proper Promise‑Rejection Handling
TL;DR: I stopped a runaway email‑cron that was consuming >1 GB RAM by refactoring the job into a idempotent queue worker and adding explicit try/catch with Error objects. The fix also introduced a global unhandledRejection guard that logs real Error instances, eliminating the “Non‑Error promise rejection” noise in Sentry.
The Problem
Our SaaS runs a nightly rent‑reminder batch inside a Next.js monorepo. The cron is a simple Node script (src/cron/emailCron.ts) that pulls all active leases, builds an email template, and fires off a call to the mail service.
On September 3 2026 Sentry started spamming us with two alerts:
email‑cron: memory usage 1.2 GB – restarting
UnhandledRejection: Non‑Error promise rejection captured with value: Object Not Found Matching Id:1, MethodName:update, ParamCount:4
The memory spike was caused by the same batch looping forever. The second alert indicated that somewhere in the code we were rejecting a promise with a plain object instead of an Error, making debugging impossible.
What I Tried First
My first instinct was to add a setTimeout guard inside emailCron.ts to bail out after a fixed number of iterations:
// src/cron/emailCron.ts (first attempt)
let loops = 0;
while (true) {
await sendEmails();
if (++loops > 1000) break; // <-- quick hack
}
That stopped the OOM, but the job never completed its legitimate work because the guard cut it off mid‑run. I also tried wrapping the whole file in a top‑level try/catch, but the non‑Error rejection still bubbled up to Sentry as an opaque object.
The Implementation
1. Refactor the cron into a queue‑driven worker
Instead of a tight while (true) loop, I introduced a lightweight in‑process queue (src/lib/EmailQueue.ts). The queue pulls a batch of lease IDs, processes them one by one, and marks each as “sent” in the DB. If the job crashes, the next run will resume where it left off.
// src/lib/EmailQueue.ts
export class EmailQueue {
private pending: number[] = [];
constructor(private readonly db: DB) {}
async loadPending(): Promise<void> {
this.pending = await this.db.query<number>(`
SELECT id FROM leases WHERE reminder_sent = false
`);
}
async processNext(): Promise<void> {
const id = this.pending.shift();
if (id === undefined) return;
try {
const lease = await this.db.getLease(id);
await sendEmail(lease);
await this.db.updateLease(id, { reminder_sent: true });
} catch (err) {
// Re‑throw as an Error so the global handler can log it properly
throw new Error(`Failed processing lease ${id}: ${err instanceof Error ? err.message : JSON.stringify(err)}`);
}
}
hasMore(): boolean {
return this.pending.length > 0;
}
}
2. Update the cron entry point
// src/cron/emailCron.ts
import { DB } from '@/lib/DB';
import { EmailQueue } from '@/lib/EmailQueue';
(async () => {
const db = new DB();
const queue = new EmailQueue(db);
await queue.loadPending();
while (queue.hasMore()) {
await queue.processNext();
}
console.log('✅ Email cron completed');
process.exit(0);
})().catch(err => {
// Any uncaught error bubbles here; we still exit with non‑zero code
console.error('❌ Email cron failed', err);
process.exit(1);
});
3. Enforce Error‑only rejections globally
I added a small bootstrap module that installs a listener for unhandledRejection. It converts any non‑Error value into an Error before sending it to Sentry.
// src/lib/globalErrorHandler.ts
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.2,
});
process.on('unhandledRejection', (reason: unknown) => {
const error = reason instanceof Error
? reason
: new Error(`Non‑Error rejection: ${JSON.stringify(reason)}`);
Sentry.captureException(error);
console.error('Unhandled Rejection:', error);
});
I import this module as the first line in every entry point (including the cron):
// src/cron/emailCron.ts (top)
import '@/lib/globalErrorHandler';
4. Tighten the DB layer to surface real errors
The original DB.update method was returning the raw driver response, which could be null when the record wasn’t found. That caused the “Object Not Found Matching Id” object to be thrown directly.
// src/lib/DB.ts (excerpt)
async updateLease(id: number, data: Partial<Lease>): Promise<void> {
const result = await this.prisma.lease.update({
where: { id },
data,
});
if (!result) {
// Throw an Error instead of returning a raw object
throw new Error(`Lease ${id} not found`);
}
}
5. Deploy with the new env var
During the fix I added VERCEL_PREVIEW_FEEDBACK to control preview‑only logging. The changelog entry reflects this:
## [2026-09-03] VS
### Changed
- Deploy: re‑deployed after adding the environment variable `VERCEL_PREVIEW_FEEDBACK`.
The variable gates the verbose Sentry output to preview deployments only, keeping production noise low.
Key Takeaway
Never let a background job run in an uncontrolled infinite loop. Refactor long‑running batches into idempotent, resumable workers and always reject promises with real Error objects. A global unhandledRejection guard that normalizes non‑Error values saves you from opaque Sentry alerts and makes debugging deterministic.
What’s Next
- Persist the queue state in Redis so that a crash mid‑run can be recovered across process restarts.
- Add rate‑limiting to the mail service client to avoid hitting provider throttling.
-
Write integration tests that simulate a failed DB update and assert that the global handler logs a proper
Error.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
Tags: #vibecoding #buildinpublic #nextjs #typescript #nodejs #cron #sentry #errorhandling
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-09-04
#playadev #buildinpublic
Top comments (0)