Next.js is primarily a frontend framework, but in real-world projects we often need background scheduled tasks within the same application—syncing third-party data, clearing expired caches, sending notification emails, and so on. We used node-cron to handle this in a production project and ran into several pitfalls worth sharing.
The Basic Approach: node-cron
The most straightforward solution is to register cron jobs when the Next.js server starts:
import cron from 'node-cron';
// Run every day at 3 AM
cron.schedule('0 3 * * *', async () => {
await syncExternalData();
});
Simple enough, but using it naively leads to several problems.
Problem 1: Duplicate Initialization in Dev Mode
Next.js dev mode hot-reloads modules, which means your cron job may get registered multiple times. Every code change creates a new scheduler, and the same task fires several times at once.
The fix is a global flag to prevent re-initialization:
// lib/cron.ts
const globalForCron = globalThis as typeof globalThis & {
cronInitialized?: boolean;
};
export function initCronJobs() {
if (globalForCron.cronInitialized) return;
globalForCron.cronInitialized = true;
cron.schedule('0 3 * * *', async () => {
console.log('[Cron] Starting data sync...');
await syncExternalData();
});
}
Using globalThis ensures the flag persists across hot reloads in development and across module re-evaluations in production.
Problem 2: Startup Timing
Cron jobs shouldn't execute the moment the module loads. If the database connection isn't established or environment variables haven't loaded yet, the job will fail.
We use a delayed startup strategy:
export function initCronJobs() {
if (globalForCron.cronInitialized) return;
globalForCron.cronInitialized = true;
// Wait 30 seconds to ensure the service is fully ready
setTimeout(() => {
registerAllJobs();
console.log('[Cron] All cron jobs registered');
}, 30_000);
}
For the initialization trigger point, Next.js 14+ supports instrumentation.ts:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initCronJobs } = await import('./lib/cron');
initCronJobs();
}
}
The runtime check prevents execution in Edge Runtime environments.
Problem 3: Execution Guards
The worst scenario for cron jobs is overlapping executions—the previous run hasn't finished when the next one starts. When external API calls are involved, slow responses can cause task pile-ups that exhaust resources.
Add an execution lock:
let isRunning = false;
async function safeExecute(taskName: string, fn: () => Promise<void>) {
if (isRunning) {
console.log(`[Cron] ${taskName} still running, skipping this cycle`);
return;
}
isRunning = true;
try {
await fn();
} catch (error) {
console.error(`[Cron] ${taskName} failed:`, error);
} finally {
isRunning = false;
}
}
Problem 4: Manual Triggers via API
Sometimes you need to trigger a job manually—for debugging or data recovery. We expose an internal API endpoint for each task, protected by a secret:
// app/api/cron/sync/route.ts
export async function POST(request: Request) {
const secret = request.headers.get('x-cron-secret');
if (secret !== process.env.CRON_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
await syncExternalData();
return Response.json({ success: true });
}
This endpoint can also integrate with Vercel Cron or external schedulers as an alternative triggering mechanism.
Problem 5: Memory Management
If your cron tasks maintain caches or accumulate state (like a simple rate limiter), you need periodic cleanup. Otherwise, memory usage grows steadily over time:
const cache = new Map<string, { value: any; expiry: number }>();
// Clean up expired entries every 10 minutes
cron.schedule('*/10 * * * *', () => {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiry < now) {
cache.delete(key);
}
}
});
When Not to Use This Approach
This solution works for single-instance deployments. If your application runs multiple instances (e.g., Kubernetes replicas), each instance will start its own cron jobs, causing duplicate execution. In that case, consider:
- A dedicated task queue (e.g., BullMQ + Redis)
- An external scheduler (e.g., Vercel Cron, AWS EventBridge)
- Distributed locking (e.g., Redis SETNX)
Takeaways
Running cron jobs in Next.js isn't complicated, but the details matter. The key points are: prevent duplicate initialization, delay startup, use execution locks, protect manual trigger endpoints with secrets, and clean up memory. For small-to-medium single-instance apps, this approach is reliable enough; migrate to a proper task queue system when you scale up.