Some work should simply not happen while a user is waiting: sending emails, generating reports, cleaning up old data. Work that runs outside the request/response cycle is called background processing. Spring Boot ships with two easy tools for it — @Async and @Scheduled — and this chapter shows how to use them and where they stop being enough.
@Async — respond now, work laterMarking a method with @Async makes Spring run it on a separate thread, so the caller continues immediately. Enable it once on your application class:
@SpringBootApplication
@EnableAsync
public class Application { }
Then mark the slow method:
@Service
@Slf4j
public class NotificationService {
@Async
public void sendWelcomeEmail(String email) {
log.info("Sending welcome email to {}", email);
// slow work: talking to a mail provider...
}
}
When a controller calls notificationService.sendWelcomeEmail(email), the call returns instantly and the email is sent in the background — exactly the registration example from the previous chapter.
<aside> ⚠️
@Async only works when the method is called from another class. Spring wraps your bean in a proxy to redirect the call to a new thread — calling an @Async method from inside the same class skips the proxy and runs it like a normal method. A classic surprise.
</aside>
@Scheduled — run on a timerFor periodic work, enable scheduling with @EnableScheduling and annotate a method with @Scheduled. In Week 10 you learned that session tokens should have an expiry date — and someone has to clean the expired ones up:
@Component
@Slf4j
public class SessionCleanupJob {
@Scheduled(cron = "0 0 3 * * *") // every day at 03:00
public void deleteExpiredSessions() {
log.info("Deleting expired sessions...");
// DELETE FROM sessions WHERE expires_at < now()
}
}
<aside> 💡
Spring cron expressions have six fields, starting with seconds: second minute hour day-of-month month day-of-week.
</aside>
@Scheduled has three modes:
| Mode | Meaning | Typical use |
|---|---|---|
cron |
Calendar schedule ("every day at 03:00") | Nightly cleanup |
fixedRate |
Start every N ms, measured start-to-start | Polling a status every 30 seconds |
fixedDelay |
Start N ms after the previous run finished | Batch work — the safest default, a run can never overlap itself |
Both tools live inside your running application — and that is exactly their weakness:
@Scheduled duplicates across instances. In production, applications usually run as two or more identical copies for reliability. Each copy has its own scheduler, so the 03:00 job fires on every copy. With session cleanup that is harmless — with "email the weekly report to all customers" it very much is not.<aside> ❗
@Async and @Scheduled are excellent for a single-instance application. The moment work must survive restarts, retry on failure, or run exactly once across instances, you need something that lives outside the application. That is where the rest of this week is heading.
</aside>
A common production pattern keeps the work inside your application, behind an HTTP endpoint, and lets an external scheduler trigger it. At work that scheduler is often a Kubernetes CronJob or a cloud scheduler — but you already know a free one: GitHub Actions (Week 12) can run a workflow on a schedule:
name: nightly-cleanup
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC every day
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Call the cleanup endpoint
run: |
curl --fail -X POST "${{ secrets.CLEANUP_URL }}" \
-H "X-Job-Token: ${{ secrets.CLEANUP_TOKEN }}"
The endpoint must be secured: it should check the token before doing anything (Week 10 thinking — this endpoint deletes data). Note that GitHub's cron has five fields (no seconds) and runs in UTC.
<aside> ⚠️
GitHub's scheduled workflows are best-effort: runs can be delayed at busy times, and schedules on inactive public repositories are disabled after about 60 days. Fine for a cleanup job — not for business-critical timing.
</aside>
<aside> 💡
If a multi-instance application must keep @Scheduled inside the app, a distributed lock library like ShedLock (backed by the database you already have) makes sure only one instance runs each job. Awareness only — link below.
</aside>
💬 Your app sends a weekly newsletter with @Scheduled and runs as three instances in production. What happens, and what are two possible fixes?
@Async and @Scheduled move work off the request path with almost no setup, and they are fine for a single instance. But they do not survive restarts, do not retry, and duplicate across instances. Production systems therefore trigger periodic jobs from outside — and for background work that must never be lost, they use the tool coming up in the next chapters: the message queue.
@Scheduled task — Spring logs the error and the schedule continues.The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.