DB-Scheduler: A Persistent, Cluster-Friendly Scheduler for Java

I stopped trusting @Scheduled in clustered Spring Boot apps a long time ago. Duplicate job runs on restart or scale-out are a real production headache, and db-scheduler solved it for me with nothing more than a database table. Here's how a persistent, cluster-friendly scheduler saved me from writing my own distributed...

Spring's @Scheduled annotation is fine for single-instance toy jobs. It's in-memory, it has no idea what happened on other nodes, and if the JVM restarts mid-cycle, whatever state it had is gone. The moment you run more than one instance of a service, which most of us do the second we care about uptime, you either build your own locking mechanism around it or reach for something designed for this from day one. That's where db-scheduler comes in, and it's become one of my default picks whenever a Spring Boot service needs "run this later, run this once, and don't run it twice." What db-scheduler Actually Is db-scheduler is a lightweight, persistent task scheduler for Java (by kagkarlsson on GitHub) that stores all scheduling state in your existing database. No Quartz-style cluster of tables you don't fully understand, no separate broker, no ZooKeeper. Just one table, scheduled_tasks , sitting in the same PostgreSQL instance your app already talks to. That single design decision is what sold me. Quartz has been the default answer to "persistent Java scheduling" for almost two decades, and it works, but it comes with real ceremony: JobDetail, Trigger, JobDataMap, a dozen tables, an XML-flavored mental model even when you configure it in Java. db-scheduler strips that down to "here's a task, here's when to run it, here's what data it needs." It feels like it was built by people who got tired of Quartz's ceremony and wrote the thing they actually wanted to use. I'm running the current release, 16.12.0 , which is worth calling out because this library ships often and quietly. There's no big marketing push around new versions, they just show up with small correctness fixes and occasional API polish, and you upgrade because it's low risk, not because a changelog promised you the world. Why Persistence Actually Matters Here Persistence isn't just about surviving restarts. It's about surviving scale. The moment you run two or three instances of the same service behind a load balancer, in-memory schedulers become a liability, not a convenience. Each instance thinks it owns the schedule. Each one fires at the same time. On ITJobOpportunities, our FeaturedJobScheduler handles job promotion timing across WhatsApp and Telegram notifications. If that ran on three nodes simultaneously, we'd triple-notify candidates. Not catastrophic, but embarrassing, and exactly the kind of bug that's hard to reproduce locally because it only shows up under real cluster conditions. db-scheduler solves this with row-level locking in the database itself. Each task execution is claimed by exactly one scheduler instance, using a SELECT ... FOR UPDATE -style pattern under the hood (the exact SQL depends on your dialect, but the idea holds across Postgres, MySQL, and Oracle). If a node dies mid-execution, another node picks up the task after a configurable timeout. No coordinator, no leader election, no extra infrastructure. Your database was already your source of truth; now it's also your lock manager. Setting It Up The setup is refreshingly small. Add the dependency <dependency> <groupId>com.github.kagkarlsson</groupId> <artifactId>db-scheduler-spring-boot-starter</artifactId> <version>16.12.0</version> </dependency> Run the schema migration It ships one, and if you're already on Liquibase or Flyway like I am on basically every project, you just drop it into your changelog: CREATE TABLE scheduled_tasks ( task_name varchar(100) not null, task_instance varchar(100) not null, task_data bytea, execution_time timestamp with time zone not null, picked boolean not null, picked_by varchar(50), last_success timestamp with time zone, last_failure timestamp with time zone, consecutive_failures integer, last_heartbeat timestamp with time zone, version bigint not null, PRIMARY KEY (task_name, task_instance) ); That's genuinely most of the infrastructure work. Compare that to Quartz's QRTZ_* tables (there are eleven of them, and I still can't name all of them from memory) and it's easy to see why this is my default now. Define a recurring task Recurring tasks use RecurringTask , and the API leans on builders rather than annotations, which I prefer once you get past the initial unfamiliarity. It reads like configuration, not magic. @Bean public RecurringTask<Void> dailyJobFeedTask(JobFeedService jobFeedService) { return Tasks.recurring("daily-job-feed", FixedDelay.ofHours(24)) .execute((taskInstance, executionContext) -> { jobFeedService.sendDailyDigest(); }); } You register it with a Scheduler bean (the starter wires most of this for you on Spring Boot), and it just runs. No @EnableScheduling , no cron string buried in a properties file nobody remembers to update. If you do want cron semantics, Schedules.cron() is right there too: @Bean public RecurringTask<Void> weeklyRecruiterDigest(RecruiterDigestService digestService) { return Tasks .recurring("weekly-recruiter-digest", Schedules.cron("0 8 * * MON")) .execute((taskInstance, executionContext) -> digestService.send()); } Define a one-time task For one-off tasks tied to actual business events instead of a clock, db-scheduler gives you OneTimeTask , which is the part I find more useful day to day: @Bean public OneTimeTask<CandidateNotification> sendFollowUpEmail() { return Tasks.oneTime("send-follow-up-email", CandidateNotification.class) .execute((taskInstance, executionContext) -> { CandidateNotification data = taskInstance.getData(); emailService.sendFollowUp(data.candidateId(), data.jobId()); }); } Then, somewhere in your application logic, you schedule an instance of it: scheduler.schedule( sendFollowUpEmail.instance("candidate-482-job-91", new CandidateNotification(482L, 91L)), Instant.now().plus(Duration.ofDays(3)) ); This is the pattern I ended up using for "check back with a candidate a few days after Easy Apply if the recruiter hasn't responded" on the ATS side. That's a business rule, not a cron job, and modeling it as a scheduled task instance tied to a specific candidate and job felt a lot more honest than jamming it into a nightly batch that scans the whole applications table for stale rows. Where It Beats Quartz (and Where It Doesn't) Quartz isn't a bad library. It's just an old one, carrying baggage from a Java ecosystem that looked very different in 2001. Where db-scheduler wins for me: Setup time. One table versus eleven. Roughly four minutes to get db-scheduler running from a fresh Spring Boot project, versus closer to twenty wrestling with Quartz's quartz.properties and JobStore configuration. Task data as plain Java objects, via Jackson serialization, instead of a JobDataMap that stores everything as loosely typed key-value pairs. Built-in dead execution detection. If a node crashes holding a lock, db-scheduler notices via missed heartbeats and reassigns the task. Quartz can do this too, but it's more configuration, not less. The library itself is small. The core jar is well under a megabyte. Quartz pulls in more. Where Quartz still has an edge: Cron expression support is more mature and battle-tested. db-scheduler's Schedules.cron() works fine in practice, but Quartz has been running weird edge-case cron strings in production for two decades. Misfire policies. Quartz gives you fine-grained control over what happens when a trigger's window is missed. db-scheduler's model is simpler, and that simplicity has occasionally bitten me when I wanted more nuanced catch-up behavior after a long outage. Ecosystem and tooling. More Stack Overflow answers, more admin UI plugins, more prior art, simply because it's older and more widely deployed. If you need calendar-precision scheduling with holiday exclusions and complex misfire handling, Quartz is probably still the safer bet. For "run this task reliably, exactly once, across however many instances I'm running this week," db-scheduler wins, and it's not close....