Recurring jobs

Schedule recurring jobs with a single line of code using any CRON expression or interval.

Creating a recurring job (either a CRON job or a job with a fixed defined interval) is just as simple as creating a background job – you only need to write a single line of code (and it is even less if you use the jobrunr-spring-boot-starter, jobrunr-spring-boot-2-starter, jobrunr-spring-boot-3-starter , jobrunr-micronaut-feature or the jobrunr-quarkus-extension ).

Important: the jobrunr-spring-boot-starter is deprecated since JobRunr v6, please use jobrunr-spring-boot-2-starter or jobrunr-spring-boot-3-starter instead!

On this page you can learn about:

Note that JobRunr OSS supports up to 100 recurring jobs (depending on the performance of your SQL or NoSQL database). Do you need to run more than 100 recurring jobs? This is supported in JobRunr Pro!

Note that recurring jobs may not be executed on the exact moment you specify using your CRON expression: Whenever JobRunr fetches all the jobs that are scheduled and need to be executed, it fetches all jobs that need to happen in the next poll interval and enqueues them immediately. This may result in a difference of a couple of seconds. If you need real-time scheduling, then have a look at JobRunr Pro.

Using a CRON expression

BackgroundJob.scheduleRecurrently(Cron.daily(), () -> System.out.println("Easy!"));

This line creates a new recurring CRON job entry in the StorageProvider. A special component in BackgroundJobServer checks the recurring jobs with a fixed interval (the pollIntervalInSeconds) and then enqueues them as fire-and-forget jobs. This enables you to track them as usual.

The Cron class contains different methods and overloads to run jobs on a minute, hourly, daily, weekly, monthly and yearly basis. You can also use a standard CRON expressions to specify a more complex schedule:

BackgroundJob.scheduleRecurrently("0 12 * */2", () -> System.out.println("Powerful!"));

All these methods are also available on the JobScheduler and JobRequestScheduler bean:

@Inject
private JobScheduler jobScheduler;

jobScheduler.scheduleRecurrently(Cron.daily(), () -> System.out.println("Easy!"));
@Inject
private JobRequestScheduler jobRequestScheduler;

jobRequestScheduler.scheduleRecurrently(Cron.daily(), new SysOutJobRequest("Easy!"));

You can also create a recurring job with a CRON expression using the builder pattern:

@Inject
private JobScheduler jobScheduler;

jobScheduler.createRecurrently(aRecurringJob()
    .withCron(Cron.daily())
    .withDetails(() -> System.out.println("I'm created by the builder!"));
    

If you are using the jobrunr-spring-boot-starter, the jobrunr-micronaut-feature or the jobrunr-quarkus-extension this becomes even easier: just add the @Recurring annotation to any bean method and JobRunr will schedule it as a recurring job:

    @Recurring(id = "my-recurring-job", cron = "*/5 * * * *")
    @Job(name = "My recurring job")
    public void executeSampleJob() {
        // your business logic here
    }

Using an Interval

Instead of giving a Cron expression, you can also give a duration. This will make sure that the recurring job will now be executed using a fixed interval starting the moment the recurring job was scheduled.

BackgroundJob.scheduleRecurrently(Duration.parse("P5D"), () -> System.out.println("Easy!"));

Also in this case, you can create a recurring job with a fixed interval using the builder pattern:

@Inject
private JobScheduler jobScheduler;

jobScheduler.createRecurrently(aRecurringJob()
    .withInterval(Duration.ofDays(3))
    .withDetails(() -> System.out.println("I'm created by the builder!"));
    

If you are using the jobrunr-spring-boot-starter, the jobrunr-micronaut-feature or the jobrunr-quarkus-extension this stays as easy as with a CRON expression: add the @Recurring annotation to any bean method with the interval attribute where you can pass an ISO8601 duration. JobRunr will schedule it again as a recurring job using the provided interval:

    @Recurring(id = "my-recurring-job", interval = "P2D8H")
    @Job(name = "My recurring job")
    public void executeSampleJob() {
        // your business logic here
    }

Managing recurring jobs

Each recurring job has its own unique identifier. In the previous examples it was generated implicitly, using the type and method names of the given lambda expression (resulting in “System.out.println()” as the identifier). The BackgroundJob and JobScheduler class contains overloads that take an explicitly defined job identifier. This way, you can refer to the job later on.

BackgroundJob.scheduleRecurrently("some-id", "0 12 * */2",
  () -> System.out.println("Powerful!"));

If you are using the JobBuilder pattern, this becomes:

BackgroundJob.createRecurrently(aRecurringJob()
    .withId("some-id")
    .withCron("0 12 * */2")
    .withDetails(() -> System.out.println("Powerful!")));

The methods above will create a new recurring job if no recurring job with that id exists or else update the existing job with the given identifier.

Identifiers should be unique - use unique identifiers for each recurring job, otherwise you’ll end with a single job.

Deleting recurring jobs

You can remove an existing recurring job either via the dashboard or by calling the BackgroundJob.delete method with the id of the recurring job. It does not throw an exception when there is no such recurring job.

Pause and Resume recurring jobs

JobRunr Pro

Using JobRunr Pro, you can pause and resume recurring jobs from the dashboard and using the API.

Recurring jobs with limited lifetime

JobRunr Pro

By default, a RecurringJob is active for the entire lifetime of an application (unless paused).

With JobRunr Pro you can provide an end time: each RecurringJob with a deleteAt in the passed will stop scheduling new jobs. JobRunr will also automatically remove the RecurringJob from the DB.

You can enable the feature using @Job:

    @Recurring(id = "my-recurring-job", interval = "P2D8H", deleteAt = "2025-06-01T14:00:00Z")
    @Job(name = "My recurring job")
    public void executeSampleJob() {
        // your business logic here
    }

If you are using the JobBuilder, this is also possible:

BackgroundJob.createRecurrently(aRecurringJob()
    .withId("some-id")
    .withCron(Cron.daily())
    .withDeleteAt(Instant.parse("2025-06-01T14:00:00Z"))
    .withDetails(() -> System.out.println("Schedule me up to the 2025-06-01T14:00:00Z")));

Advanced CRON Expressions

JobRunr Pro

Do you need to run recurring jobs on some special moments like the first business day of the month or the last business day of the month? JobRunr Pro has a CRON expression parser on steroids and supports your really complex schedule requirements.

L character

L stands for “last”. When used in the day-of-week field, it allows specifying constructs such as “the last Friday” (5L) of a given month. In the day-of-month field, it specifies the last day of the month.

Some examples:

  • 0 0 * * 5L: midnight the last Friday of each month
  • 0 0 * 2 1L: midnight the last Monday of each February
  • 0 0 L * *: midnight the last day of each month
  • 0 0 L 2 *: midnight the last day of each February

# character

# is allowed for the day-of-week field, and must be followed by a number between one and five. It allows specifying constructs such as “the second Friday” of a given month.

Some examples:

  • 0 0 * * 1#1: midnight the first Monday of each month
  • 0 0 * 1 1#1: midnight the first Monday of each January
  • 0 0 * * 5#3: midnight the third Friday of each month

W character

The W character is allowed for the day-of-month field. This character is used to specify the weekday (Monday-Friday) nearest the given day. As an example, if “15W” is specified as the value for the day-of-month field, the meaning is: “the nearest weekday to the 15th of the month.” So, if the 15th is a Saturday, the trigger fires on Friday the 14th. If the 15th is a Sunday, the trigger fires on Monday the 16th. If the 15th is a Tuesday, then it fires on Tuesday the 15th. However, if “1W” is specified as the value for day-of-month, and the 1st is a Saturday, the trigger fires on Monday the 3rd, as it does not ‘jump’ over the boundary of a month’s days. The ‘W’ character can be specified only when the day-of-month is a single day, not a range or list of days.

Some examples:

  • 0 0 1W * *: midnight the first Monday of each month
  • 0 0 1W+2 * *: midnight 2 days after the first Monday of each month
  • 0 0 20W * *: midnight on the 20th or the closest workday to the 20th

Custom Recurring Job Schedules

JobRunr Pro

Do you have really complex recurring job schedule? Just extend the class org.jobrunr.scheduling.custom.CustomSchedule and implement one method where you provide the next java.time.Instant your job should run. For example:

@Recurring(id = "my-recurring-job", customSchedule = "com.project.services.MySchedule(2025-01-01T01:00:00.000Z,2026-01-01T01:00:00.000Z,2027-01-01T01:00:00.000Z)")
public void myRecurringMethod(JobContext jobContext) {
    System.out.print("My recurring job method");
}
JobRunr will instantiate the class com.project.services.MySchedule and pass the content between the parentheses as input to the constructor. You can use any String input you want to determine when the recurring job should run.

Your CustomSchedule implementation must not throw an exception as this will result in an unexpected behavior, and in the worst case will kill the JobRunr background job processing server.

Recurring jobs missed during downtime

JobRunr Pro

If for some reason all of your servers are down (e.g. deploying a new version / scheduled down time / …), JobRunr OSS skips these recurring jobs: as there is no background job server running, it will not be able to schedule these recurring jobs.

JobRunr Pro improves this and adds the capability to catch up all of the skipped recurring jobs: for each run that was skipped during the downtime for a certain recurring job, it will schedule a job.

This feature is disabled by default and can be enabled using the following setting:

    @Recurring(id = "my-recurring-job", interval = "P2D8H", scheduleJobsSkippedDuringDowntime=true)
    @Job(name = "My recurring job")
    public void executeSampleJob() {
        // your business logic here
    }

If you are using the JobBuilder, this is also possible:

BackgroundJob.createRecurrently(aRecurringJob()
    .withId("some-id")
    .withCron("0 12 * */2")
    .withScheduleJobsSkippedDuringDowntime()
    .withDetails(() -> System.out.println("Powerful!")));

Concurrent recurring jobs

JobRunr Pro

JobRunr by default does not allow concurrent recurring jobs - the reason being is that if your recurring jobs for some reason take longer than the given CRON expression or interval, you may create more jobs than you can process. So, if a job instance created by a recurring job is still in state SCHEDULED, ENQUEUED or PROCESSING and it’s time to again queue a new instance of the recurring job then this last instance will not be created.

Sometimes the business requirements define that recurring jobs may not be skipped in any case and your recurring job runs very fast in 95% of the cases. But you have some outliers (e.g. due to a lot of work to process) that may take longer than the given CRON expression or interval. JobRunr Pro comes to the rescue with the option maxConcurrentJobs to create a certain amount of job instances of that recurring job that will run concurrently.

By default, maxConcurrentJobs is set to 1 but this setting can be changed per recurring job:

    @Recurring(id = "my-recurring-job", interval = "PT5M", maxConcurrentJobs=4)
    @Job(name = "My recurring job")
    public void executeSampleJob() {
        // if for some reason this method takes more than 5 minutes (=PT5M), 
        // JobRunr will create up to 4 concurrent job instances of this recurring job
    }

If you are using the JobBuilder, this is also possible:

BackgroundJob.createRecurrently(aRecurringJob()
    .withId("some-id")
    .withInterval(Duration.ofMinutes(5))
    .withMaxConcurrentJobs(4)
    .withDetails(() -> System.out.println("Powerful!")));

Important remarks!

Remark 1: for recurring jobs to work, at least one BackgroundJobServer should be running all the time.

Remark 2: also, for recurring job to work, they should be registered at application startup (which can either be a webapp, a console app, …). This is different for each application/environment. The easiest way to do so is via the @Recurring annotation that ships with the jobrunr-spring-boot-starter, the quarkus-jobrunr extension or the jobrunr-micronaut-feature as shown above. If you are not using an integration with a certain framework, you will need to register these scheduled jobs yourselves using Container Startup Event listeners.

Remark 3: please note that the cron interval or duration for your recurring jobs must be more than your pollIntervalInSeconds. If your pollIntervalInSeconds is greater than your cron interval or duration of your recurring jobs, JobRunr will launch multiple instances of the same recurring job to keep up. This means that the same recurring job will be launched multiple times at the same moment.

Remark 4: also note that the smallest possible cron interval for your recurring jobs is every 5 seconds. JobRunr prevents creating recurring jobs with cron values less than every 5 seconds (e.g. every second) as it would generate too much load on your StorageProvider (SQL or noSQL database).