✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

TutorialsIntermediate24 min read

Cron Jobs in Strapi 5: A Complete Guide

Cron Jobs in Strapi 5: A Complete Guide

Introduction

Cron jobs, also known as scheduled tasks, are a feature of any serious backend application. Whether you need to send a weekly newsletter, sync data from an external API, or clean up stale records, you need a reliable way to run code at specific times without a human pressing a button.

In Strapi 5, this is handled through cron jobs.

This tutorial walks you through everything you need to know:

  • What cron jobs are
  • How Strapi manages cron jobs internally
  • How to configure and implement cron jobs in Strapi
  • And how to avoid the common pitfalls that trip up most developers.

In Brief (TL;DR)

  • Cron jobs in Strapi 5 let you run code on a schedule, such as newsletters, syncs, cleanups, and so on.
  • Enable Strapi cron job cron: { enabled: true } in config/server.ts as it's off by default.
  • Use the object format myJob: { task, options: { rule } }, and avoid the bare-expression key, which creates a job you can't remove.
  • Pick the right file location config/cron-tasks.ts for static jobs.
  • Use strapi.cron.add() when you need to add cron jobs programmatically at runtime (e.g., from a plugin's bootstrap() lifecycle). Also when scheduling jobs dynamically based on runtime data.
  • If you run more than one instance, every instance runs every job, so you need a lock, an external scheduler, or a designated cron node.

Prerequisites

A working Strapi 5 project and basic Node.js knowledge.

Strapi Cron Job Concepts & Fundamentals

Think of a cron job like a recurring calendar reminder, except instead of reminding you to do something, it tells your server to run a piece of code automatically.

The name "cron" comes from the Unix cron daemon, a time-based job scheduler.

In practical terms, cron jobs are used for tasks like:

  • Sending scheduled newsletters
  • Syncing data from external APIs
  • Creating database backups
  • Cleaning up expired sessions or records, etc.

The key characteristic is that these tasks run on a schedule, not in response to a user request.

How Strapi Handles Cron Internally

When your Strapi server boots, it initializes a CronService, which is a custom module powered by the node-schedule package.

Here's what happens under the hood during startup:

  1. Strapi checks whether server.cron.enabled is true in your server config file.
  2. If enabled, it reads any tasks defined in server.cron.tasks and passes them to strapi.cron.add().
  3. It then calls strapi.cron.start(), which schedules all registered jobs.

NOTE: An important detail: cron only uses a single timer at any given time, rather than re-evaluating upcoming jobs every second or minute. This makes it efficient but also means the scheduler is stateless. So if your server restarts, all jobs are re-registered from scratch. There is no built-in persistence of job state across restarts.

Strapi Cron Expression Syntax

Strapi uses the standard cron expression format, extended with an optional "seconds" field:

*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    |
│    │    │    │    │    └ day of week (0 - 7, or SUN - SAT) (0 or 7 is Sun)
│    │    │    │    └───── month (1 - 12, or JAN - DEC)
│    │    │    └────────── day of month (1 - 31)
│    │    └─────────────── hour (0 - 23)
│    └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, OPTIONAL)

So, the six stars stand for second, minute, hour, day of month, month, and day of week.

✋NOTE: The first star is always optional

In cron syntax, there are special operators or symbols that define how schedules are interpreted:

SymbolName / TermMeaning
*WildcardMatches all possible values (e.g., every minute, every hour)
,List SeparatorSpecifies multiple values (e.g., 1,15,30 = 1st, 15th, and 30th)
-RangeDefines a range of values (e.g., 1-5 = Monday to Friday)
/Step (Increment)Specifies intervals/steps (e.g., */5 = every 5 units)

Practical Examples:

ExpressionMeaning
0 15 14 1 * *At 14:15 on day-of-month 1.
0 * * * *Every hour
0 0 * * *Daily at midnight
0 0 1 * * 1Every Monday at 1am (with seconds field)
* * * * * *Every second

The seconds field is optional. If you provide 5 fields, the first field is minutes. If you provide 6 fields, the first field is seconds. This is a common source of confusion. More on that in later sections.

For me, I prefer using the 5 fields.

How to Enable Cron Jobs in Strapi

The single most common mistake beginners make in writing a cron task is forgetting to enable the feature.

Cron jobs are disabled by default in Strapi. You must explicitly turn them on. Set cron.enabled to true, import and declare your cron task as shown below:

// ./config/server.ts

import type { Core } from "@strapi/strapi";

const config = ({
  env,
}: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
  host: env("HOST", "0.0.0.0"),
    
  // ... other configurations
    
  // enable cron job
  cron: {
    enabled: true
  },
});

export default config;

How to Define Cron Jobs (Format/Syntax)

You have two main options for defining cron jobs in Strapi, and choosing the right one matters.

The Object format (recommended) uses a named key with task and options properties. The object format is the recommended approach.

// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';

export default {
  myJob: {
    task: async ({ strapi }: { strapi: Core.Strapi }) => {
      // Add your job logic here
    },
    options: {
      rule: '*/2 * * * *',
    },
  },
};

2. Using the Key format (Discouraged)

You can use the cron expression itself as the key, thereby creating an anonymous job that cannot be removed.

// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';

export default {
  '*/2 * * * *': async ({ strapi }: { strapi: Core.Strapi }) => {
    // Add your job logic here
  },
};

You can also use both of them together in the same code as shown in the code below, but the key format is highly discouraged:

// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';

export default {
  myJob: {
    task: async ({ strapi }: { strapi: Core.Strapi }) => {
      // Add your job logic here
    },
    options: {
      rule: '*/2 * * * *',
    },
  },

  '*/2 * * * *': async ({ strapi }: { strapi: Core.Strapi }) => {
    // Add your job logic here
  },
};

⚠️ NOTE Avoid the key format

The key format, e.g., "0 0 1 * * 1": () => {}, creates an anonymous cron job that cannot be removed later with strapi.cron.remove(), and may cause issues with some plugins.

Learn more about using the key format

Where to Define Cron Jobs (Location):

There are 3 locations where you can define cron jobs in Strapi

1. Create Cron Task File and Import in Server Config File

Create your cron tasks in a dedicated file, config/cron-tasks.js|ts, and import it into the config/server.js|ts file.

// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';

export default {
  myJob: {
    task: ({ strapi }: { strapi: Core.Strapi }) => {
      // Add your own logic here (e.g. send a queue of email, create a database backup, etc.).
    },
    options: {
      rule: '0 0 1 * * 1', // Every Monday at 1am
    },
  },
};

Import and define inside the server config file./config/server.js

// Path: ./config/server.ts
import type { Core } from '@strapi/strapi';
import cronTasks from './cron-tasks';

export default ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),

  // ... Other configurations

  cron: {
    enabled: true,
    tasks: cronTasks, // define cron tasks
  },
});

2. Create Inside cron.tasks Key in Server Config File

You can directly create a task inside the cron.tasks key of config/server.js|ts as shown below:

// Path: ./config/server.ts
import type { Core } from '@strapi/strapi';

export default ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),

  // ... Other configurations

  cron: {
    enabled: true,

    // create job here
    tasks: {
      myJob: {
        task: async ({ strapi }: { strapi: Core.Strapi }) => {
          // Add your own logic here
        },
        options: {
          rule: '0 0 1 * * 1', // Every Monday at 1am
        },
      },
    },
  },
});

3. Programmatically Create Jobs with strapi.cron.add()

You can also create cron jobs anywhere in your custom code using the strapi.cron.add() function.

This could be in your bootstrap function inside the src/index.js|ts file, or in a plugin's strapi-server.js file.

You can also use this method when you need access to the fully initialized Strapi instance.

// ./src/plugins/my-plugin/strapi-server.ts

export default () => ({
  bootstrap({ strapi }) {
    strapi.cron.add({
      myJob: {
        task: async ({ strapi }) => {
          console.log("hello from plugin");
        },
        options: {
          rule: "* * * * * *", // Runs every second
        },
      },
    });
  },
});

Key Differences Between Options 1, 2 and 3

Options 1 and 2 are essentially the same, as both define jobs statically at startup via the server config. The only difference is file organization. Option 1 is preferred when you have many jobs and want to keep config/server.js|ts clean.

Option 3 is fundamentally different because:

  • It runs after Strapi is fully initialized, giving you access to the Strapi instance (e.g., to query the database)
  • It supports dynamic scheduling at runtime (e.g. adding or removing jobs based on user actions)
  • It is the only option for plugin authors who need to register jobs as part of their plugin lifecycle

How to Remove a Cron Job in Strapi

In some cases, there may be a need for you to remove some cron jobs. Here are some reasons you might want to remove cron jobs.

1. Application Shutdown (Cleanup with destroy())

When Strapi is shutting down, you should remove cron jobs registered during bootstrap() to avoid resource leaks or orphaned jobs. This is done in the destroy() lifecycle.

// ./src/index.ts

let cronJobKey: string | undefined;

export default {
  async bootstrap({ strapi }) {
    cronJobKey = "log-reminders";

    strapi.cron.add({
      [cronJobKey]: {
        task: async ({ strapi }) => {
          strapi.log.info("Remember to review new content in the admin panel.");
        },
        options: {
          rule: "0 */6 * * *", // Every 6 hours
        },
      },
    });
  },

  async destroy({ strapi }) {
    if (cronJobKey) {
      strapi.cron.remove(cronJobKey);
    }
  },
};

2. Rescheduling a Job

When a job needs to run at a different time, you remove the old job before adding the new one.

// Remove the old scheduled job
strapi.cron.remove('publishRelease_1');

// Define the new schedule date
const newScheduleDate = new Date(Date.now() + 10000); // e.g. 10 seconds from now

// Add the new one with updated schedule
strapi.cron.add({
  publishRelease_1: {
    async task() {
      // publish logic
    },
    options: newScheduleDate,
  },
});

3. Plugin Cleanup

When a plugin shuts down, it should remove any cron jobs it registered.

// ./src/plugins/my-plugin/server/src/destroy.ts

import type { Core } from "@strapi/strapi";

export default ({ strapi }: { strapi: Core.Strapi }) => {
  strapi.cron.remove("myJob");
};

4. Feature/License-Based Removal

When a feature is disabled (e.g., a license expires), its associated cron jobs should be removed.

strapi.cron.remove("deleteExpiredRecords");

In summary, you should remove a cron job when a user action changes the schedule of a dynamically added job at runtime. This will ensure that the old job is cancelled and a new one is created to avoid duplicate executions.

Defining Cron Job Inside a Plugin

If you're building a Strapi plugin and want to include a cron task scoped to that plugin, register it in the plugin's bootstrap function:

// /src/plugins/my-plugin/server/src/index.ts
import type { Core } from '@strapi/strapi';

export default () => ({
  bootstrap({ strapi }: { strapi: Core.Strapi }) {
    strapi.cron.add({
      // runs every second
      myJob: {
        task: async ({ strapi }: { strapi: Core.Strapi }) => {
          console.log('hello from plugin');
        },
        options: {
          rule: '* * * * * *',
        },
      },
    });
  },
});

To remove it cleanly when the plugin is destroyed, use strapi.cron.remove("myJob").

// /src/plugins/my-plugin/server/src/index.ts
import type { Core } from '@strapi/strapi';

export default () => ({
  bootstrap({ strapi }: { strapi: Core.Strapi }) {
    strapi.cron.add({
      // runs every second
      myJob: {
        task: ({ strapi }: { strapi: Core.Strapi }) => {
          console.log('hello from plugin');
        },
        options: {
          rule: '* * * * * *',
        },
      },
    });
  },

  destroy({ strapi }: { strapi: Core.Strapi }) {
    strapi.cron.remove('myJob');
  },
});

Learn more about adding or removing cron jobs

Working with the Document Service and Cron Jobs

Inside a cron task, you have full access to the Strapi instance via the { strapi } argument. In Strapi 5, use strapi.documents() to interact with your content and not strapi.entityService, which is deprecated in Strapi 5.

// Path: ./config/cron-tasks.ts

import type { Core } from '@strapi/strapi';

export default {
  unpublishExpiredPromotions: {
    task: async ({ strapi }: { strapi: Core.Strapi }) => {
      const now = new Date().toISOString();

      const expiredPromotions = await strapi.documents('api::promotion.promotion').findMany({
        status: 'published',
        filters: {
          expiresAt: { $lte: now },
        },
      });

      strapi.log.info(`[unpublishExpiredPromotions] Found ${expiredPromotions.length} expired promotions.`);

      for (const promotion of expiredPromotions) {
        await strapi.documents('api::promotion.promotion').unpublish({
          documentId: promotion.documentId,
        });
      }
    },
    options: {
      rule: '0 0 0 * * *', // Every day at midnight
    },
  },
};

How to Query Single Type vs Collection Type in a Cron Task

The way you query data inside a cron task depends on your content type:

Collection Types

Collection types have multiple entries. You'll typically query for entries matching certain criteria (e.g., all draft articles with a scheduledAt date that has passed).

// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';

export default {
  publishScheduledArticles: {
    task: async ({ strapi }: { strapi: Core.Strapi }) => {
      const now = new Date().toISOString();

      // Collection type: find many draft entries whose scheduledAt date has passed
      const articles = await strapi.documents('api::article.article').findMany({
        status: 'draft',
        filters: {
          scheduledAt: {
            $lte: now,
          },
        },
      });

      for (const article of articles) {
        await strapi.documents('api::article.article').publish({
          documentId: article.documentId,
        });
      }
    },
    options: {
      rule: '0 * * * *', // Every hour
    },
  },
};

Single Types

Single types have exactly one entry. You query them without filtering by ID.

// ./config/cron-tasks.ts
export default {
  syncHomepage: {
    task: async ({ strapi }) => {
      // Single type: no ID or filter needed, just findFirst()
      const homepage = await strapi.documents("api::homepage.homepage").findFirst({
        status: "published",
      });

      // Do something with homepage data...
    },
    options: {
      rule: "0 0 * * *", // Every day at midnight
    },
  },
};

Both are accessed through the Document Service API. The cron task structure is the same in both cases.

The only thing that differs is the query.

Working with Time Zones in Strapi Cron Jobs

By default, a job runs in the server's local timezone.

To pin it to a specific zone, add a tz value to options with a valid tz-database name — for example, tz: "Asia/Dhaka" runs the rule on Dhaka time regardless of where the server lives.

// ./config/cron-tasks.ts

export default {
  /**
   * Cron job with timezone example.
   * Every Monday at 1am for Asia/Dhaka timezone.
   * List of valid timezones: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
   */

  myJob: {
    task: ({ strapi }) => {
      /* Add your own logic here */
    },
    options: {
      rule: "0 0 1 * * 1",
      tz: "Asia/Dhaka",
    },
  },
};

Example Project: Build a Cron Job Manager with Strapi 5 & Next.js

For a full deep dive on a project-based example of Strapi cron jobs, explore the following tutorials:

Strapi Cron Jobs Advanced Considerations

Multi-Instance Deployments

When you run a single Strapi instance, cron jobs are straightforward: one server, one scheduler, one execution per interval.

But in production, you often run multiple Strapi instances behind a load balancer for redundancy and scale. This is where cron jobs become dangerous.

The problem Each Strapi instance initializes its own CronService independently. If you have 3 instances running, a cron job that fires every hour will execute 3 times once per instance at the same time.

For a newsletter send, that means 3x the emails going out to your subscribers.

The Solution Strapi's Content Releases feature handles Multi-Instance Deployments

Strapi's own content releases scheduling feature (which uses cron internally) addresses this with a database-level locking strategy.

When a scheduled release fires, it sets up a transaction that locks the release row using SQL forUpdate. Any other process attempting to access the same row is put on hold until the first one finishes. Read more here.

  1. Step 1: Create a cron_locks content type or database table. You need a cron_locks table in your database. Create a content type (or migration) with these fields: job_name (String, unique), locked_at (DateTime),released_at (DateTime, nullable).

  2. Step 2: Create the CronLock utility

// Path: ./src/utils/cron-lock.ts
import type { Core } from '@strapi/strapi';

const LOCK_MODEL = 'api::cron-lock.cron-lock';

export async function withCronLock(
  strapi: Core.Strapi,
  jobName: string,
  task: () => Promise<void>
): Promise<void> {
  await strapi.db.transaction(async ({ trx }) => {
    // Attempt to acquire a lock on the row for this job
    const lock = await strapi.db
      .queryBuilder(LOCK_MODEL)
      .where({ job_name: jobName, released_at: null })
      .select(['id', 'job_name', 'locked_at', 'released_at'])
      .first()
      .transacting(trx)
      .forUpdate()
      .execute();

    if (!lock) {
      // No lock row exists yet, insert one
      await strapi.db
        .queryBuilder(LOCK_MODEL)
        .insert({ job_name: jobName, locked_at: new Date() })
        .transacting(trx)
        .execute();
    } else if (lock.released_at) {
      // Already ran, skip silently
      strapi.log.info(`[CronLock] Job "${jobName}" already ran. Skipping.`);
      return;
    }

    try {
      await task();

      // Release the lock after successful execution
      await strapi.db
        .queryBuilder(LOCK_MODEL)
        .where({ job_name: jobName })
        .update({ released_at: new Date() })
        .transacting(trx)
        .execute();

      strapi.log.info(`[CronLock] Job "${jobName}" completed successfully.`);
    } catch (error) {
      strapi.log.error(`[CronLock] Job "${jobName}" failed.`, error);
      throw error;
    }
  });
}
  1. Step 3: Use it in your cron tasks
// Path: ./config/cron-tasks.ts
import type { Core } from '@strapi/strapi';
import { withCronLock } from '../src/utils/cron-lock';

export default {
  publishScheduledArticles: {
    task: async ({ strapi }: { strapi: Core.Strapi }) => {
      await withCronLock(strapi, 'publishScheduledArticles', async () => {
        const now = new Date().toISOString();

        const articles = await strapi.documents('api::article.article').findMany({
          status: 'draft',
          filters: {
            scheduledAt: { $lte: now },
          },
        });

        for (const article of articles) {
          await strapi.documents('api::article.article').publish({
            documentId: article.documentId,
          });
        }
      });
    },
    options: {
      rule: '0 * * * *', // Every hour
    },
  },
};

How the Logic Works The logic works like this:

  • If the first instance successfully publishes the release, it updates releasedAt with the current timestamp and releases the lock.
  • When the second instance's process finally gets access to the row, it sees releasedAt is already set and fails silently — the work is already done.
  • If the first instance fails, the release status is set to failed, and subsequent attempts also fail silently until a user intervenes. [Content Releases Scheduling]

Strategies you can apply to your own Cron Tasks

  1. Database locking: Before executing your task, attempt to acquire a lock on a database record (e.g., a CronLock table). If the lock is already held by another instance, skip execution. Release the lock when done.

  2. External scheduler: Move scheduling responsibility outside of Strapi entirely. Use a single external scheduler (a cloud cron service, BullMQ, or Agenda) that makes an HTTP request to one designated Strapi instance, or to a dedicated worker process, rather than letting every instance run its own timer.

  3. Designate a single cron instance: In your infrastructure, configure only one instance to have cron.enabled: true. All other instances run with cron disabled. This is the simplest approach but introduces a single point of failure for scheduled tasks.

Environment-Based Scheduling

It's common to want cron jobs to run more frequently in development (so you can test them quickly) and less frequently, or not at all, in production.

Strapi Environment Configuration Strapi's environment configuration already handles this.

You can use NODE_ENV inside your cron task file to switch the rule:

// ./config/cron-tasks.ts
const isDev = process.env.NODE_ENV === 'development';

export default {
  sendScheduledNewsletters: {
    task: async ({ strapi }) => {
      // your task logic
    },
    options: {
      // Every 30 seconds in dev, every 5 minutes in production
      rule: isDev ? "*/30 * * * * *" : "*/5 * * * *",
    },
  },
};

Strapi's environment-specific config files Alternatively, you can use Strapi's environment-specific config files. Place a different cron-tasks.ts in ./config/env/production/ that imports and overrides specific task rules for production. Learn more in environment configurations.

Disable Cron in an Environment

You can also disable cron entirely in certain environments by setting cron.enabled conditionally in your server config:

// ./config/server.ts
import cronTasks from "./cron-tasks";

export default ({ env }) => ({
  host: env("HOST", "0.0.0.0"),
  port: env.int("PORT", 1337),
  cron: {
    enabled: env.bool("CRON_ENABLED", true),
    tasks: cronTasks,
  },
});

This lets you control cron via an environment variable (CRON_ENABLED=false) without changing code.

This is useful for disabling cron on all but one instance in a multi-instance setup.

When NOT to Use Strapi's Built-in Cron

Strapi's built-in cron is excellent for simple, low-frequency scheduled tasks. But there are clear signs you've outgrown it:

Signs you need something more robust:

  • You need job queues. Cron fires a task at a time, but it doesn't queue work. If your task takes longer than the interval, the next run starts before the previous one finishes, and you have no visibility into the backlog.
  • You need retries. Strapi can log a failed cron execution, but it provides no automatic retry, backoff, persistent failure state, dead-letter queue, or alerting.
  • You're running multiple instances and the locking strategies above feel too complex or fragile for your use case.
  • You need job history and monitoring. Strapi's cron gives you no dashboard, no job history, and no alerting out of the box.
  • Your tasks are long-running. Cron tasks run in the same Node.js process as your API. A heavy task can block the event loop and degrade API performance.
  • You need dynamic scheduling. Strapi's cron rules are defined at startup. If you need to schedule a one-off job at a user-specified time (e.g., "publish this article at 3 pm tomorrow"), you need either the scheduling service pattern used by Content Releases (which dynamically calls strapi.cron.add()) or an external queue. [Content Releases Scheduling]

When to reach for an external tool:

  • BullMQ or Agenda — Node.js job queue libraries backed by Redis. Excellent for retries, concurrency control, and job history. Your Strapi bootstrap function can enqueue jobs; a separate worker process consumes them.
  • Cloud-based cron — AWS EventBridge Scheduler, Google Cloud Scheduler, or similar. These call an HTTP endpoint on your Strapi instance at the scheduled time. Simple, reliable, and instance-count-agnostic.

Testing & Debugging

1. Testing cron jobs locally

The fastest way to verify a cron task is working is to set its interval to something very short — every second or every 5 seconds — during development:

export default {
  myJob: {
    task: ({ strapi }) => {
      strapi.log.info('Task fired!');
    },
    options: {
      rule: "* * * * * *", // every second — for testing only
    },
  },
};

Watch your terminal after restarting Strapi. You should see the log message every second. Once confirmed, change the rule to your intended production schedule.

2. Triggering a task manually during development

Strapi's cron service does not expose a built-in "run now" method. The simplest workaround is to extract your task logic into a standalone service function, then call that function directly from a temporary admin route or from the Strapi bootstrap function during development. This way you can test the logic independently of the scheduler.

3. One-off tasks for testing

You can also schedule a task to run exactly once, a few seconds after startup, using a Date object as the option:

export default {
  myOneOffJob: {
    task: ({ strapi }) => {
      strapi.log.info('One-off task fired!');
    },
    // only run once after 10 seconds
    options: new Date(Date.now() + 10000),
  },
};

This is useful for quickly testing task logic without waiting for a recurring interval.

4. Using an online cron editor

Feel free to practice your Cron Expression Syntax using crontab guru or any other online editor for cron expressions.

crontab guru

5. Using a Plugin from the Strapi Community Hub

You can download several plugins from the Strapi Community Hub to extend the features of your Strapi project.

The Hub is the new home for everything built by and for the community, all in one place:

  • The plugin Marketplace,
  • Framework Integrations,
  • Project Showcases,
  • Partners, and Community Member Profiles.

In the Strapi community hub, you will find the Cron Manager plugin, which allows you to visually add and manage cron jobs.

cron job plugin

Logging and Error Handling Best Practices

Every production cron task should follow this pattern:

export default {
  sendScheduledNewsletters: {
    task: async ({ strapi }) => {
      strapi.log.info('[Newsletter Cron] Starting run...');

      try {
        // your task logic here
        strapi.log.info('[Newsletter Cron] Completed successfully.');
      } catch (error) {
        strapi.log.error('[Newsletter Cron] Task failed:', error);
        // Catch errors so you can add useful context and perform cleanup. 
        // Whether you rethrow depends on how failures are monitored; 
        // rethrowing allows the scheduler's error listener to record the task as failed.
      }
    },
    options: {
      rule: "*/5 * * * *",
    },
  },
};

Key practices:

  • Always wrap in try/catch. An unhandled error inside a cron task can propagate unexpectedly. Catch it, log it, and let the task exit cleanly.
  • Use namespaced log prefixes like [Newsletter Cron]. When you have multiple cron tasks, prefixes make it easy to filter logs.
  • Log at the start and end of each run. This gives you a clear record of when tasks fired and whether they completed.
  • Log meaningful context on errors — not just the error message, but which record was being processed, what state it was in, etc. This is invaluable when debugging production failures.
  • Use strapi.log.info and strapi.log.error rather than console.log. Strapi's logger integrates with your logging infrastructure and respects log level configuration.

Strapi's cron service also attaches an error listener to each job: [Cron service source]

job.on('error', (error) => {
  strapi.log.error(`Cron job "${taskName ?? taskExpression}" failed`, error);
});

This means unhandled errors from node-schedule itself are caught and logged automatically — but errors thrown inside your task function are your responsibility to handle.

Common Mistakes when working with Strapi Cron Jobs

Here's a checklist of the most frequent mistakes when working with Strapi cron jobs:

1. Forgetting to enable cron in config/server.ts

The single most common issue. Cron is disabled by default. Without cron: { enabled: true }, no tasks will ever run. [Enabling cron jobs]

2. Using the key format instead of the object format

The key format (where the cron expression is the object key) creates an anonymous job that cannot be removed with strapi.cron.remove(). Always use the object format with a named key. [Using the key format]

3. Wrong cron expression field count

Strapi supports both 5-field (minute-level) and 6-field (second-level) cron expressions.

If you write a 6-field expression expecting the first field to be minutes, your task will fire at the wrong time or never.

Remember: with 6 fields, the first field is seconds.

4. No duplicate-send protection

If your task sends emails or triggers external actions, you must update a status field after each successful send. Without this guard, every cron run will re-process the same records.

5. Using entityService in Strapi 5

strapi.entityService is deprecated in Strapi 5. Use strapi.documents() instead. Code copied from Strapi v4 tutorials will silently fail or behave unexpectedly.

6. Defining tasks in register() instead of bootstrap()

The register() lifecycle runs before Strapi is fully initialized.

Cron tasks that depend on database access or other services must be added in bootstrap(), where the full Strapi instance is available.

7. Not cleaning up cron jobs in destroy()

If you add a cron job dynamically via strapi.cron.add() in bootstrap(), you should remove it in destroy() to avoid memory leaks or ghost jobs during hot reloads in development. Learn more about combined usage.

Conclusion

You've now covered the full lifecycle of cron jobs in Strapi 5 — from defining static tasks in your server config to dynamically scheduling jobs at runtime with strapi.cron.add().

The most important decisions come down to where you define your tasks and how you format them: always use the object format with a named key, and reach for strapi.cron.add() when you need access to the fully initialized Strapi instance or need to reschedule jobs based on user actions.

Further reading:

Theodore Kelechukwu OnyejiakuDevRel and Community | Software Developer | Technical Writer

Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.

Frequently Asked Questions

Streaming Strapi Dynamic Zones in Next.js 16: Part 2
Tutorials·14 min read

Streaming Strapi Dynamic Zones in Next.js 16: Part 2

Build the streaming UI for Strapi Dynamic Zones in Next.js 16: per-block Suspense boundaries, a block registry, webhook revalidation, and draft preview.

·July 16, 2026
Streaming Strapi Dynamic Zones in Next.js 16: Part 1
Tutorials·18 min read

Streaming Strapi Dynamic Zones in Next.js 16: Part 1

Learn how to model a Strapi 5 Dynamic Zone and build a typed two-phase fetch layer for Next.js 16 that streams content without client-side waterfalls.

·July 13, 2026
Building Docs for the AI Era, Part 1
Tutorials·10 min read

Building Docs for the AI Era, Part 1: Self-Healing Docs

Learn how Strapi uses AI, GitHub Actions, and progressive cost gating to automatically detect documentation gaps and keep docs aligned with code changes.

·June 11, 2026