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 (org.quartz.simpl.RAMJobStore, the default)

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 @Scheduled.

JDBC-backed (org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX)

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.