Scheduling and Mail
|
This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against. Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change. This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page covers two extensions that are unrelated in what they do but are often reached for together when a
service needs to run work on a timer and notify someone about the outcome: the Scheduler (in-process
@Scheduled methods, plus Quartz for clustered, persistent schedules) and the Mailer (sending email, blocking
or reactive, with or without a template). They ship as separate extensions and can be used independently.
@Scheduled
The quarkus-scheduler extension provides io.quarkus.scheduler.Scheduled, an annotation that turns a
no-argument method on a CDI bean into a scheduled job — no @EnableScheduling-style opt-in is needed, adding
the extension is enough:
./mvnw quarkus:add-extension -Dextensions=scheduler
Cron and interval syntax
cron accepts a standard six-field cron expression (second minute hour day-of-month month day-of-week);
every accepts a simple interval string:
package org.acme.scheduling;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
@ApplicationScoped
public class ReportingJobs {
private static final Logger LOG = Logger.getLogger(ReportingJobs.class);
// fires at 10:15:00 every day
@Scheduled(cron = "0 15 10 * * ?")
void nightlyReport() {
LOG.info("Running nightly report");
}
// fires every 10 seconds, starting 10 seconds after the application is ready
@Scheduled(every = "10s")
void pollQueue() {
LOG.info("Polling queue");
}
}
Config-driven schedules
A schedule value wrapped in { } is resolved from configuration at startup rather than hard-coded, which lets
the same code run on different cadences per environment:
@Scheduled(cron = "{my.job.cron}")
void configuredJob() {
LOG.info("Running on a configurable cron");
}
my.job.cron=0 15 10 * * ?
For a set of related scheduling settings, prefer a typed @ConfigMapping interface over scattering individual
{…} placeholders:
package org.acme.scheduling;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "reporting")
public interface ReportingConfig {
String cron();
boolean enabled();
}
reporting.cron=0 15 10 * * ?
reporting.enabled=true
@ApplicationScoped
public class ConfiguredReportingJobs {
@Inject
ReportingConfig config;
@Scheduled(cron = "{reporting.cron}")
void nightlyReport() {
if (!config.enabled()) {
return;
}
LOG.info("Running configurable nightly report");
}
}
Use @ConfigMapping, never the deprecated @ConfigProperties, for any new config-binding interface.
|
Concurrent execution and blocking behavior
By default, a @Scheduled method runs on a worker thread and Quarkus lets a slow execution overlap with the
next scheduled fire. Scheduled.ConcurrentExecution controls that:
import io.quarkus.scheduler.Scheduled.ConcurrentExecution;
@Scheduled(every = "5s", concurrentExecution = ConcurrentExecution.SKIP)
void skipIfStillRunning() {
// if the previous execution has not finished yet, this fire is skipped entirely
}
SKIP discards a fire that would overlap a still-running previous execution; PROCEED (the default) lets them
run concurrently. @NonBlocking marks a method safe to run directly on the Vert.x event loop instead of being
dispatched to a worker thread — only appropriate for a method that does no blocking I/O and returns quickly,
the same rule that governs any other reactive/event-loop code in Quarkus:
import io.quarkus.scheduler.Scheduled;
import io.smallrye.common.annotation.NonBlocking;
@Scheduled(every = "1s")
@NonBlocking
void cheapEventLoopCheck() {
// no blocking calls here -- this executes on the event loop, not a worker thread
}
Skip predicates
skipExecutionIf names a io.quarkus.scheduler.SkipPredicate bean that decides, per fire, whether the job
should actually run — useful for maintenance windows, feature flags, or leader-only execution in a
single-instance sense:
package org.acme.scheduling;
import io.quarkus.scheduler.ScheduledExecution;
import io.quarkus.scheduler.SkipPredicate;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class MaintenanceWindowSkipPredicate implements SkipPredicate {
@Override
public boolean test(ScheduledExecution execution) {
return MaintenanceWindow.isActive();
}
}
@Scheduled(every = "1m", skipExecutionIf = MaintenanceWindowSkipPredicate.class)
void periodicSync() {
// skipped for every fire while the maintenance window is active
}
Programmatic scheduling
io.quarkus.scheduler.Scheduler can be injected and used to register a job at runtime, when the schedule itself
is not known until the application is running:
package org.acme.scheduling;
import io.quarkus.scheduler.Scheduler;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class DynamicJobRegistrar {
@Inject
Scheduler scheduler;
public void registerJobFor(String tenantId, String cronExpression) {
scheduler.newJob("tenant-sync-" + tenantId)
.setCron(cronExpression)
.setTask(execution -> syncTenant(tenantId))
.schedule();
}
private void syncTenant(String tenantId) {
// ...
}
}
Pausing and resuming
Scheduler.pause() and Scheduler.resume() stop and restart every job at once; pause(String identity) and
resume(String identity) target a single job, provided it was given an explicit identity:
@Scheduled(every = "30s", identity = "cache-refresh")
void refreshCache() {
// ...
}
scheduler.pause("cache-refresh");
// ... later
scheduler.resume("cache-refresh");
Metrics
When quarkus-micrometer is present on the classpath, the Scheduler extension exposes execution metrics
(success/failure counts, execution duration) for each scheduled method through Micrometer without any extra
configuration on the job itself. The Micrometer registry setup and exporting those metrics via OpenTelemetry or
a /q/metrics endpoint is covered in Observability: Health, Logging, Metrics, Tracing — this page only notes that the
integration exists.
Quartz for clustered, persistent schedules
@Scheduled is entirely in-process: its state lives in the JVM’s memory, so it forgets everything across a
restart and every replica of a scaled-out service runs the same job independently. The quarkus-quartz
extension replaces the underlying scheduler implementation with Quartz while
keeping the same @Scheduled/Scheduler programming model, and adds what Quartz brings: persistent job state
and, with the right job store, coordination across a cluster.
./mvnw quarkus:add-extension -Dextensions=quartz
Job stores
Quartz’s job store decides where job and trigger state lives:
| Job store | Behavior |
|---|---|
In-memory ( |
Fast, requires no extra infrastructure, but all schedule state is lost on restart and is not shared across
replicas — each instance still runs every job independently, exactly like plain |
JDBC-backed ( |
Persists jobs, triggers, and fire state in relational tables (Quartz ships the DDL for the supported databases), so schedules survive a restart and, when several instances point at the same database, only one of them executes a given job for a given fire time. |
quarkus.quartz.clustered=true
quarkus.quartz.store-type=jdbc-cmt
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/quartz
quarkus.datasource.username=quartz
quarkus.datasource.password=quartz
Misfire policies
A misfire is a trigger whose scheduled fire time passed without Quartz actually running it — typically because the process was down, or every worker thread was busy past the configured misfire threshold. Each trigger type has its own misfire instruction that decides what happens next: fire the missed execution immediately, skip straight to the next regularly scheduled time, or (for interval triggers) fire once and then resume the normal cadence. The right choice depends on the job: a "top up the cache every 5 minutes" job is usually safe to skip a missed fire, while a "send the daily settlement file" job may need to fire once as soon as the instance comes back up rather than silently skip a whole day.
Running a single instance across replicas
Quartz’s clustered mode — the JDBC job store plus quarkus.quartz.clustered=true — makes this coordination
work through the job-store tables: every clustered node polls the same tables, and the row update that claims
a trigger, plus row locks around firing, is what decides which single node actually fires it for a given tick,
with the others backing off. This requires every clustered instance to point at the same database and to have
reasonably synchronized clocks; it is not a lightweight in-memory coordination mechanism, it is a real,
database-mediated distributed lock built into Quartz’s own storage layer. The Spring Boot view of the same mechanism is
on Quartz Clustering.
Scheduling and ShedLock documents the Spring answer to the same problem: Spring’s
@Scheduled has no built-in cross-instance coordination at all, and ShedLock adds it externally as a
distributed lock (over a JDBC, MongoDB, or Redis store) that scheduled methods acquire before running. The two
solutions differ in shape — Quartz bundles clustering into its own job-store persistence layer, while ShedLock
is a separate locking library layered on top of Spring’s existing in-process scheduler — but both exist to
solve the identical problem: making sure a job scheduled on every replica of a horizontally-scaled service
actually executes only once per tick.
The Mailer extension
The quarkus-mailer extension sends email over SMTP, with both a blocking and a reactive API:
./mvnw quarkus:add-extension -Dextensions=mailer
Blocking API
io.quarkus.mailer.Mailer is a CDI bean with a synchronous send method that takes an io.quarkus.mailer.Mail:
package org.acme.mail;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@Path("/notifications")
@ApplicationScoped
public class NotificationResource {
@Inject
Mailer mailer;
@POST
public void notifyCustomer() {
mailer.send(Mail.withText(
"customer@example.com",
"Your order has shipped",
"Order #12345 is on its way."));
}
}
Reactive API
io.quarkus.mailer.reactive.ReactiveMailer mirrors the same Mail-based API but returns a SmallRye Mutiny
io.smallrye.mutiny.Uni<Void> instead of blocking the calling thread — appropriate on a reactive/event-loop
code path. Uni/Multi themselves, and how to subscribe to or chain them, are covered in
Reactive Programming and Mutiny:
package org.acme.mail;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.reactive.ReactiveMailer;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class ReactiveNotificationService {
@Inject
ReactiveMailer reactiveMailer;
public Uni<Void> notifyCustomer(String email, String orderId) {
return reactiveMailer.send(Mail.withText(
email,
"Your order has shipped",
"Order #" + orderId + " is on its way."));
}
}
Attachments
Mail.withText(…)/Mail.withHtml(…) return a builder that addAttachment extends fluently:
mailer.send(Mail.withText(
"customer@example.com",
"Your invoice",
"Please find your invoice attached.")
.addAttachment("invoice-12345.pdf", invoiceBytes, "application/pdf"));
Qute mail templates
The quarkus-qute integration lets a MailTemplate render its body from a Qute template resolved by
@Location, instead of building the Mail body by hand. Qute’s own templating syntax and data-binding rules
are covered in Qute Templating:
package org.acme.mail;
import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
import io.quarkus.qute.CheckedTemplate;
public class OrderShippedMail {
@CheckedTemplate
public static class Templates {
public static native MailTemplateInstance orderShipped(String customerName, String orderId);
}
}
Templates.orderShipped(customerName, orderId)
.to(customerEmail)
.subject("Your order has shipped")
.send()
.await().indefinitely();
The corresponding template lives under src/main/resources/templates/OrderShippedMail/orderShipped.txt (or
.html) using the same {name} expression syntax as any other Qute template.
Dev Services and mock mode
Setting quarkus.mailer.mock=true — the default in the dev and test profiles when no SMTP host is
configured — stops the Mailer from actually connecting to an SMTP server: messages are logged instead of sent,
so local development and tests never risk delivering real email. Quarkus’s Dev UI also exposes the mock
mailer’s captured messages for inspection while quarkus:dev is running, which is convenient for eyeballing a
rendered Qute template without standing up a real mail server.
Configuring SMTP and providers
Real delivery is configured through quarkus.mailer.* properties:
quarkus.mailer.host=smtp.example.com
quarkus.mailer.port=587
quarkus.mailer.username=${SMTP_USERNAME}
quarkus.mailer.password=${SMTP_PASSWORD}
quarkus.mailer.start-tls=REQUIRED
quarkus.mailer.auth-methods=PLAIN
start-tls controls whether STARTTLS is disabled, optional, or required for the connection; auth-methods
restricts which SASL mechanisms the client is willing to negotiate with the server. Common transactional-email
providers (SendGrid, Mailgun, Amazon SES, and similar) can all be used this way, by pointing quarkus.mailer.*
at that provider’s own SMTP relay endpoint and credentials — there is no need for a provider-specific extension
just to send mail over SMTP. To point the Quarkus Mailer at Azure Communication Services' SMTP relay in
production, see Email with Azure Communication Services. Check
Quarkiverse before assuming one is required,
in case a provider you use has a dedicated extension for its non-SMTP API (webhooks, delivery tracking, and
similar) beyond plain sending.
To send through Amazon SES in production, see Sending Email with Amazon SES. For sending mail from Google Cloud workloads, see Sending Email from Google Cloud.