Production · Lesson 82 of 95
Scheduling with @Scheduled
Learn scheduling with @Scheduled in Spring Boot: fixedRate, fixedDelay and cron jobs, one-thread traps and a runnable pharmacy example with output.
Every morning a tea stall owner does the same small jobs without anyone asking: light the stove at six, check the milk stock at seven, and clear the old cups at nine. Nobody presses a button. The jobs just happen at their time. Real applications need the same thing: send a daily report, clear old records every night, or check a payment gateway every minute. Scheduling with @Scheduled lets Spring Boot run these jobs by itself, on a timetable you write.
In this guide you will build a pharmacy app with three timed jobs, learn the difference between fixed rate, fixed delay and cron, and see how to avoid the traps that make scheduled jobs silently stop.
What is Scheduling with @Scheduled?
A scheduled method must follow two rules. It must return void (any returned value is ignored), and it must take no arguments. Spring calls it for you, so there is nobody to give it arguments.
You choose the timing in one of three ways:
fixedRatestarts the method every N milliseconds, measured from the start of the last run.fixedDelaywaits N milliseconds after the last run finished, then starts again.cronuses a calendar expression, like "every day at 2 AM".
Why is it used?
- Routine work. Nightly clean-up, daily emails, weekly reports and monthly invoices need no human.
- Polling. Check a remote service or a folder every few seconds.
- Keeping data fresh. Reload a cache or refresh a rate list on a timer.
- No extra tool. For simple jobs you do not need a separate scheduler program. It is one annotation.
How it works
Spring keeps a small scheduler running inside your app. At startup it finds every @Scheduled method and registers it with its timing.
textApp starts | v +------------------------+ | @EnableScheduling | | find @Scheduled methods| +------------------------+ | v +------------------------+ | Task scheduler | | (a small thread pool) | +------------------------+ | v time reached +------------------------+ | Run your method | +------------------------+ | v wait, then repeat
Once the app is up, the scheduler watches the clock. When a job's time arrives, it hands the method to a thread and runs it. By default Spring uses just one thread for all scheduled jobs. That means a slow job can make the other jobs wait, which we will see in the output below.
Fixed rate, fixed delay and cron
| Setting | Meaning | Good for |
|---|---|---|
fixedRate = 1000 | Start every second | Heartbeats, quick polling |
fixedDelay = 5000 | Wait 5 seconds after each run ends | Jobs of unknown length |
initialDelay = 2000 | Wait before the first run | Letting the app warm up |
cron = "0 0 2 * * *" | Seconds, minutes, hours, day, month, weekday | Daily or weekly jobs |
A cron expression in Spring has six fields, and the first one is seconds. So 0 0 2 * * * means "at second 0, minute 0, hour 2, every day". The expression */2 * * * * * means "every 2 seconds".
Real-Life Example
A pharmacy must remove expired medicines from its shelves. The manager does not remember it each day. She sets three routines: a stock count every hour (fixed rate), a restock call that starts ten minutes after the last delivery is checked in (fixed delay), and an expiry sweep every morning at 8 (cron). The pharmacy app copies this plan exactly.
Code Example
Let's build MediQuick Pharmacy, a plain app with three scheduled jobs. The main method lets it run for a few seconds and then closes it, so the output stays short.
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.mediquick</groupId> <artifactId>jobs</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.application.name=mediquick-jobs spring.main.banner-mode=off logging.level.root=warn
File: JobsApplication.java in package com.mediquick.jobs
javapackage com.mediquick.jobs; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class JobsApplication { public static void main(String[] args) throws InterruptedException { ConfigurableApplicationContext context = SpringApplication.run(JobsApplication.class, args); Thread.sleep(5500); context.close(); } }
File: PharmacyJobs.java in package com.mediquick.jobs
javapackage com.mediquick.jobs; import java.util.concurrent.atomic.AtomicInteger; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class PharmacyJobs { private final AtomicInteger counts = new AtomicInteger(); @Scheduled(fixedRate = 2000) public void stockCount() { System.out.println("Stock count #" + counts.incrementAndGet() + " on " + Thread.currentThread().getName()); } @Scheduled(initialDelay = 1000, fixedDelay = 3000) public void restockCall() throws InterruptedException { System.out.println("Restock call started"); Thread.sleep(500); System.out.println("Restock call finished"); } @Scheduled(cron = "*/2 * * * * *") public void expirySweep() { System.out.println("Expiry sweep done"); } }
Run it with ./mvnw spring-boot:run. Your lines may come in a slightly different order, because the jobs depend on the clock.
Output:
textStock count #1 on scheduling-1 Restock call started Restock call finished Expiry sweep done Stock count #2 on scheduling-1 Expiry sweep done Stock count #3 on scheduling-1 Restock call started Restock call finished
Code Explained
@EnableSchedulingstarts the scheduler. Without it,@Scheduledis ignored and no error appears.stockCountusesfixedRate = 2000, so it starts every two seconds. The thread name shows it runs on a scheduler thread, not onmain.restockCallwaits one second (initialDelay), runs for half a second, and then waits three seconds before the next run. That wait counts from the end of the last run, which isfixedDelay.expirySweepuses a cron expression that fires on every even second of the clock, whatever time the app started.- All three jobs share one thread by default. While
restockCallsleeps, the other jobs wait for their turn, so a late job usually means a busy scheduler, not a broken clock. - The
mainmethod closes the context after 5.5 seconds so the demo ends. A real web app keeps running, and the jobs go on for as long as it lives.
Making Timings Configurable
Hard-coded numbers are hard to change. Move them into application.properties, and read them with fixedRateString or cron.
java@Scheduled(fixedRateString = "${pharmacy.stock-ms:60000}") public void stockCount() { } @Scheduled(cron = "${pharmacy.sweep-cron:0 0 8 * * *}") public void expirySweep() { }
The part after the colon is a default value. Now operations can change the timing in each environment without touching the code. You can also add zone = "Asia/Kolkata" to a cron job so it runs on Indian time, whatever the server's own time zone is.
Common Mistakes
- Forgetting `@EnableScheduling`. Nothing runs, and nothing tells you why.
- Running many servers. If you start three copies of the app, every copy runs the job. A nightly email goes out three times. Use a lock such as ShedLock, or run the job on one instance only.
- Using `fixedRate` for slow jobs. If a run takes longer than the rate, runs pile up behind each other.
fixedDelayis safer when the length is not known. - Wrong cron order. Spring cron starts with seconds. Copying a five-field Linux cron line gives an error or a wrong time.
- Long jobs on the shared thread. Give the scheduler a bigger pool with the scheduling pool size property (the practice problem shows it).
Interview Questions
What do you need to run a `@Scheduled` method?
Ans:@EnableScheduling on a configuration class, and a method on a Spring bean that returns void and takes no arguments.
What is the difference between `fixedRate` and `fixedDelay`?
Ans:fixedRate measures from the start of one run to the start of the next. fixedDelay measures from the end of one run to the start of the next.
How many fields does a Spring cron expression have?
Ans:Six: second, minute, hour, day of month, month and day of week.
What happens if two instances of the app run the same scheduled job?
Ans:Both run it. You need a distributed lock or a single scheduler instance to avoid duplicates.
How can you run scheduled jobs in parallel?
Ans:Increase the scheduler pool size in the properties, or combine @Scheduled with @Async.
Key Points to Remember
@Scheduledruns a method on a timetable, and@EnableSchedulingturns it on.- Use
fixedRate,fixedDelayorcronto set the timing. - The method must be
voidand take no arguments. - The scheduler uses one thread by default, so a slow job can delay the others.
- With several app instances, each instance runs every job.
Frequently Asked Questions
Does scheduling with @Scheduled work in a web app?
Yes. The scheduler runs beside the web server. Once the app starts, the jobs run on their own, whether or not anyone calls an endpoint.
Can I change a schedule while the app is running?
Not with the plain annotation. Move the values into properties and restart, or use a SchedulingConfigurer for dynamic schedules.
Is @Scheduled good enough for important jobs?
It is fine for simple, repeatable jobs. For jobs that must survive restarts, keep history, or run once across many servers, use a tool like Quartz.
Why did my scheduled job not run?
Check @EnableScheduling, check that the class is a Spring bean, and check that the method is void with no arguments.
Related Topics
- Async Processing with @Async: run slow work on other threads so jobs do not block each other.
- Caching in Spring Boot: refresh or clear caches on a timer.
- Spring Events: let a job announce its result to other parts of the app.
- Sending Email: send the daily report a job builds.
Practice Problems
Try each problem on your own first. Both are command line programs that run for a few seconds and then close, like the MediQuick example.
Easy: Oven Check Every Second
Sweet Oven Bakery wants a job that prints Oven temperature OK every second, starting one second after the app starts. Let the app run for 3.5 seconds and then close it.
Show answerHide answer
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.sweetoven</groupId> <artifactId>checks</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn
File: ChecksApplication.java in package com.sweetoven.checks
javapackage com.sweetoven.checks; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class ChecksApplication { public static void main(String[] args) throws InterruptedException { ConfigurableApplicationContext context = SpringApplication.run(ChecksApplication.class, args); Thread.sleep(3500); context.close(); } }
File: OvenCheck.java in package com.sweetoven.checks
javapackage com.sweetoven.checks; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class OvenCheck { @Scheduled(initialDelay = 1000, fixedRate = 1000) public void check() { System.out.println("Oven temperature OK"); } }
Output:
textOven temperature OK Oven temperature OK Oven temperature OK
Medium: Bus Tracker with a Bigger Pool
BusLine tracks two things: Bus A and Bus B. Each tracking job takes 0.8 seconds and runs every second, and the rate must come from application.properties (busline.tracker-ms). With the default single scheduler thread the two jobs would queue behind each other. Make them run side by side by setting the scheduler pool size to 2, and print how many different threads were used.
Show answerHide answer
File: pom.xml
xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.1</version> <relativePath/> </parent> <groupId>com.busline</groupId> <artifactId>tracker</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
File: application.properties in src/main/resources
propertiesspring.main.banner-mode=off logging.level.root=warn busline.tracker-ms=1000 spring.task.scheduling.pool.size=2
File: TrackerApplication.java in package com.busline.tracker
javapackage com.busline.tracker; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling public class TrackerApplication { public static void main(String[] args) throws InterruptedException { ConfigurableApplicationContext context = SpringApplication.run(TrackerApplication.class, args); Thread.sleep(3500); System.out.println("Threads used: " + context.getBean(BusTracker.class).threadsUsed()); context.close(); } }
File: BusTracker.java in package com.busline.tracker
javapackage com.busline.tracker; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component public class BusTracker { private final Set<String> threads = ConcurrentHashMap.newKeySet(); @Scheduled(initialDelay = 500, fixedRateString = "${busline.tracker-ms}") public void trackBusA() throws InterruptedException { threads.add(Thread.currentThread().getName()); Thread.sleep(800); } @Scheduled(initialDelay = 500, fixedRateString = "${busline.tracker-ms}") public void trackBusB() throws InterruptedException { threads.add(Thread.currentThread().getName()); Thread.sleep(800); } public int threadsUsed() { return threads.size(); } }
Output:
textThreads used: 2