Skip to content
CalliCoder

Quartz Scheduler with Spring Boot: Email Scheduling

Spring Boot 13 min read

Scheduling a job at a time a user picks, which @Scheduled cannot do — persistent jobs that survive a restart, clustering so several instances do not all fire, and the JobDataMap rule that breaks a JDBC store.

@Scheduled handles a fixed schedule decided at compile time. It cannot schedule something for half past three next Tuesday because a user asked, it forgets everything on restart, and it runs on every instance you deploy.

Quartz solves all three, at the cost of a database schema and more configuration. This builds an email scheduler (a request specifies a recipient and a time, and the job fires then) because it needs every one of those capabilities.

Written against Spring Boot 3.2, Quartz 2.3 and Java 17.

Setup

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
spring.quartz.job-store-type=jdbc
spring.quartz.jdbc.initialize-schema=never
spring.quartz.properties.org.quartz.scheduler.instanceName=email-scheduler
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.jobStore.clusterCheckinInterval=5000
spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate
spring.quartz.properties.org.quartz.threadPool.threadCount=5

The store type is the decision that matters.

memory (the default) keeps jobs in a RAMJobStore. Fast, and everything scheduled is lost on restart, which defeats the purpose here entirely.

jdbc persists to eleven QRTZ_ tables. Jobs survive restarts and, with clustering on, several instances coordinate through the database.

initialize-schema=never with the schema applied through your migrations. always drops and recreates the tables on every start, which is convenient in development and deletes every scheduled job. Quartz ships the DDL per database in its distribution (tables_mysql_innodb.sql and friends); add it as a Flyway migration.

instanceId=AUTO generates a distinct id per node: required for clustering, and a fixed value shared by two nodes causes them to fight over the same lock rows.

driverDelegateClass needs to match your database. StdJDBCDelegate works for most; PostgreSQL needs PostgreSQLDelegate if you store binary job data.

The job

@Component
public class EmailJob extends QuartzJobBean {

    private static final Logger log = LoggerFactory.getLogger(EmailJob.class);

    private final JavaMailSender mailSender;

    public EmailJob(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        JobDataMap data = context.getMergedJobDataMap();

        String to = data.getString("email");
        String subject = data.getString("subject");
        String body = data.getString("body");

        try {
            MimeMessage message = mailSender.createMimeMessage();
            MimeMessageHelper helper = new MimeMessageHelper(message, StandardCharsets.UTF_8.name());
            helper.setTo(to);
            helper.setSubject(subject);
            helper.setText(body, true);
            mailSender.send(message);

            log.info("sent scheduled email to {}", to);

        } catch (MessagingException | MailException e) {
            log.error("failed to send scheduled email to {}", to, e);
            // refire this trigger; without the flag the failure is final
            throw new JobExecutionException(e, true);
        }
    }
}

QuartzJobBean rather than raw Job, because Spring Boot configures a SpringBeanJobFactory that performs dependency injection on job instances. Constructor injection works; with a plain Job and no factory, Quartz instantiates the class reflectively and every dependency is null.

JobExecutionException(e, true) sets refire-immediately. Without it a failed execution is simply over: Quartz logs it and moves on, and a transient SMTP timeout silently loses the email. With it the trigger fires again straight away, so wrap it in a retry count or the job loops on a permanent failure:

int attempts = data.getInt("attempts");
if (attempts >= 3) {
    log.error("giving up on email to {} after {} attempts", to, attempts);
    return;                          // do not refire
}
data.put("attempts", attempts + 1);
throw new JobExecutionException(e, true);

That data.put only persists across executions with @PersistJobDataAfterExecution on the class — see below.

Scheduling one

@Service
public class EmailSchedulerService {

    private final Scheduler scheduler;

    public EmailSchedulerService(Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    public String schedule(EmailRequest request) throws SchedulerException {
        ZonedDateTime when = request.dateTime().atZone(ZoneId.of(request.timeZone()));

        if (when.isBefore(ZonedDateTime.now())) {
            throw new IllegalArgumentException("dateTime must be in the future");
        }

        JobDetail job = buildJobDetail(request);
        Trigger trigger = buildTrigger(job, when);

        scheduler.scheduleJob(job, trigger);
        return job.getKey().getName();
    }

    private JobDetail buildJobDetail(EmailRequest request) {
        JobDataMap data = new JobDataMap();
        data.put("email", request.email());
        data.put("subject", request.subject());
        data.put("body", request.body());
        data.put("attempts", 0);

        return JobBuilder.newJob(EmailJob.class)
                .withIdentity(UUID.randomUUID().toString(), "email-jobs")
                .withDescription("Send scheduled email")
                .usingJobData(data)
                .storeDurably(false)
                .build();
    }

    private Trigger buildTrigger(JobDetail job, ZonedDateTime when) {
        return TriggerBuilder.newTrigger()
                .forJob(job)
                .withIdentity(job.getKey().getName(), "email-triggers")
                .withDescription("Send email trigger")
                .startAt(Date.from(when.toInstant()))
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withMisfireHandlingInstructionFireNow())
                .build();
    }

    public boolean cancel(String jobId) throws SchedulerException {
        return scheduler.deleteJob(JobKey.jobKey(jobId, "email-jobs"));
    }
}

public record EmailRequest(@NotBlank @Email String email,
                           @NotBlank String subject,
                           @NotBlank String body,
                           @NotNull @Future LocalDateTime dateTime,
                           @NotBlank String timeZone) { }

This is what @Scheduled cannot do. The time comes from the request, the job is created at runtime, and it persists to the database. Nothing about it is known when the application is compiled.

The timezone is part of the request, and it has to be. “Send at 09:00” is meaningless without knowing whose nine o’clock: store the instant, and let the caller say which zone their local time was in.

The JobDataMap rule that breaks a JDBC store

With a JDBC job store, the JobDataMap is serialised into a BLOB column. Two consequences that cause real failures:

Only put serializable values in it. A String, a number, a boolean, fine. An entity, a service reference, a LocalDateTime in an older Quartz, anything holding a database connection: either fails on write or, worse, deserialises into something broken weeks later.

Store ids, not objects. Look the entity up inside the job:

// wrong: the whole object goes into a BLOB and rots as the class changes
data.put("user", userEntity);

// right
data.put("userId", userEntity.getId());

The second point is the one that bites months later: a serialised object in the database is tied to the class shape at the time it was written. Add a field, and a job scheduled last week fails to deserialise with an InvalidClassException that mentions nothing about Quartz.

Quartz can be configured with useProperties=true, which restricts the map to String values only and avoids Java serialisation entirely. That constraint is a feature. It makes the mistake impossible:

spring.quartz.properties.org.quartz.jobStore.useProperties=true

Misfires

A misfire is a trigger whose time passed without it firing, the application was down, every worker thread was busy, or the job store was locked. Quartz’s default misfireThreshold is 60 seconds.

The instruction decides what happens on recovery:

.withMisfireHandlingInstructionFireNow()          // fire immediately (used above)
.withMisfireHandlingInstructionIgnoreMisfires()   // fire, catching up every missed occurrence
.withMisfireHandlingInstructionNextWithRemainingCount()  // skip to the next scheduled time

For a one-off email, firing now is right: an hour late is better than never. For a recurring job, ignoreMisfires after a long outage fires every missed occurrence in a burst, which for a five-minute job down for a day is 288 executions at once. That is rarely what anyone wants, and it is the default behaviour people are surprised by.

Clustering

With isClustered=true and a JDBC store, every instance polls the same tables and takes a row lock before firing a trigger. Exactly one node runs each execution.

Three requirements, and missing any of them produces subtle misbehaviour rather than an error:

  • The same instanceName on every node. That is what defines the cluster.
  • instanceId=AUTO so each node has a distinct id.
  • Clocks in sync. Quartz coordinates on timestamps, and nodes more than a second or two apart fire jobs early, late, or twice. Run NTP.

Note that clustering gives you failover, not load balancing of a single job: one node executes, the others skip.

If a node dies mid-execution, its in-flight jobs are recovered by another node only when the job is marked requestRecovery:

JobBuilder.newJob(EmailJob.class).requestRecovery(true)

Consider whether re-running is safe. For an email, recovery may mean sending twice, so the job needs to be idempotent, or you accept the duplicate.

Two annotations worth knowing

@DisallowConcurrentExecution      // never two executions of the same JobDetail at once
@PersistJobDataAfterExecution     // save JobDataMap changes back to the store
public class EmailJob extends QuartzJobBean { ... }

@DisallowConcurrentExecution prevents overlap when a job runs longer than its interval, the same pile-up that fixedRate produces with @Scheduled. It applies per JobDetail, not per class, so two different jobs of the same class still run concurrently.

@PersistJobDataAfterExecution is what makes the retry counter above survive between executions. Use the two together. Mutating job data without @DisallowConcurrentExecution is a race on the stored map.

The endpoint

@RestController
@RequestMapping("/api/emails")
public class EmailSchedulerController {

    private final EmailSchedulerService scheduler;

    public EmailSchedulerController(EmailSchedulerService scheduler) {
        this.scheduler = scheduler;
    }

    @PostMapping("/schedule")
    public ResponseEntity<ScheduleResponse> schedule(@Valid @RequestBody EmailRequest request)
            throws SchedulerException {
        String jobId = scheduler.schedule(request);
        return ResponseEntity.accepted()
                .body(new ScheduleResponse(jobId, "Email scheduled"));
    }

    @DeleteMapping("/{jobId}")
    public ResponseEntity<Void> cancel(@PathVariable String jobId) throws SchedulerException {
        return scheduler.cancel(jobId)
                ? ResponseEntity.noContent().build()
                : ResponseEntity.notFound().build();
    }
}

202 Accepted rather than 200, because the work has been scheduled rather than performed. The job id is returned so the caller can cancel.

$ curl -s -X POST localhost:8080/api/emails/schedule \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","subject":"Reminder","body":"Standup at 10",
         "dateTime":"2026-03-15T09:55:00","timeZone":"Europe/Berlin"}'
{"jobId":"5f2c...","message":"Email scheduled"}
mysql> SELECT job_name, next_fire_time, trigger_state FROM QRTZ_TRIGGERS;
+----------+----------------+---------------+
| 5f2c...  | 1773562500000  | WAITING       |
+----------+----------------+---------------+

Restart the application and that row is still there. That is the whole point.

Quartz or @Scheduled?

@Scheduled for a fixed schedule known at build time, on a single instance or with a distributed lock bolted on. Almost no configuration.

Quartz when you need runtime scheduling, persistence across restarts, or built-in clustering. The cost is eleven tables, a migration, and the serialisation constraint above.

If the only reason you are considering Quartz is “so it does not run on every instance”, a distributed lock around @Scheduled is much less machinery. Quartz earns its place when the schedule itself is data.

Frequently asked questions

Why do my scheduled jobs disappear on restart?

The default job store is in memory. Set spring.quartz.job-store-type=jdbc and create the QRTZ_ tables.

Why are my job’s dependencies null?

The job is being instantiated by Quartz rather than Spring. Extend QuartzJobBean so Spring Boot’s SpringBeanJobFactory performs injection.

Why do all my scheduled jobs vanish on deploy?

spring.quartz.jdbc.initialize-schema=always drops and recreates the tables at startup. Use never and apply the DDL through migrations.

What can I put in a JobDataMap?

With a JDBC store it is serialised, so only serializable values — and prefer ids over objects. A serialised entity breaks when the class changes. useProperties=true restricts it to strings and removes the risk.

What is a misfire?

A trigger whose fire time passed without it running. The handling instruction decides recovery: fire now, catch up every missed occurrence, or skip to the next scheduled time.

Why did my job fire hundreds of times after an outage?

ignoreMisfires fires every missed occurrence. For frequent recurring jobs, use nextWithRemainingCount to skip forward instead.

How do I stop every instance running the job?

isClustered=true with a JDBC store. Nodes take a row lock, so exactly one executes. They need the same instanceName, distinct instanceId, and synced clocks.

Does clustering distribute one job across nodes?

No. It is failover. One node runs each execution and the others skip.

What is the difference between @DisallowConcurrentExecution and @PersistJobDataAfterExecution?

The first prevents two executions of the same JobDetail overlapping; the second saves JobDataMap changes back to the store. Use both when a job mutates its own data.

Should I use Quartz or @Scheduled?

@Scheduled for fixed schedules known at build time. Quartz when the schedule is data, chosen at runtime, persisted, and coordinated across instances.

Where should I go next?

Task scheduling with @Scheduled covers the simpler mechanism and when it is enough, and Flyway covers applying the Quartz schema as a migration.