back

Infrastructure

July 16, 2026

6 mins read

Scheduled jobs in Java + Spring + Kubernetes - Part 1

by Ivan Vorgul

Ivan has spent 10+ years building high-load backend systems across fintech, e-commerce, and analytics, and is now a Principal Engineer at Moniepoint, working across the Deposit & Savings team. This article is the full evolution he's lived through firsthand; from a single scheduled method to sharded, fault-tolerant processing across millions of records — and it doesn't skip the ugly parts. The misfire policy that catches up in a burst you didn't ask for. The SKIP LOCKED transaction that works fine until you need to call an external service mid-lock. The shard that sits idle because its handler crashed, not its trigger. If you've ever shipped a "temporary" scheduled job that's still running two years later, read this before it becomes your 2 AM problem too.

There's a phrase in the running example I can't pass over in silence — "don't credit twice." It's an important problem, but it's a problem of processing idempotency, not of scaling jobs, and I won't be solving it here. The one point worth fixing so it doesn't hang in the air: no scheduler by itself guarantees that a business effect is applied exactly once. ShedLock, Quartz, SKIP LOCKED, Kafka; they all reduce the probability of a double trigger, but "exactly once by effect" is a property of the data model (idempotency keys, transactional boundaries, outbox), not of the schedule. Everything below is about making the work reach execution and scale, not about its financial correctness.

Simple solutions

Before going off into complex architecture, let's honestly look at what most often already works.

@Scheduled + ShedLock

I'd call this approach not a "scheduler" but a "latch on top of a scheduler." Spring already knows how to run annotated methods on a schedule via @Scheduled. The problem arises when the service is deployed across several instances: each one will dutifully run the method at the tick, and the same job gets processed N times. ShedLock solves exactly this. It takes a lock in a shared store (a database, Redis, Hazelcast, etc.) and guarantees that only one instance performs the run.

From practice, it's worth remembering two settings that confuse everyone:

  • lockAtMostFor (the maximum lock-hold time). Insurance against a deadlock: if a node dies while holding the lock, the others will be able to proceed only after a time has elapsed.
  • lockAtLeastFor (the minimum lock-hold time). Protects against the situation where a fast job finished in a millisecond, the lock was released, and a second instance immediately re-ran it.

When it fits: rare, lightweight jobs in a service that already exists, not requiring run history or complex recovery.

What doesn't work:

  • It's always single-instance execution — it doesn't scale horizontally
  • No misfire policy: if all instances were down at the tick, the run is simply skipped — no one will catch up
  • The schedule lives in code or config; any change means a deploy
  • No built-in run history or complex retry

Spring + Quartz in clustered mode

The main idea about Quartz that's often missed: it distributes different jobs across nodes; it doesn't parallelise a single one. If you have 10 different jobs and 3 nodes, Quartz will spread the load nicely. If you have one big job, Quartz won't make it any faster.

What Quartz gives you:

  • A persistent job store with run history
  • Misfire policies — handling of missed ticks
  • Dynamic triggers that can be created and changed from code
  • Calendars, complex cron expressions, custom triggers

Misfire policies, by the way, are treacherous, and the behaviour depends on the trigger type. For a SimpleTrigger, an instruction like MISFIRE_INSTRUCTION_FIRE_NOW / ...RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT after a multi-hour outage can make the job "catch up" with the schedule in a burst — and that's most likely not the load spike you expected. For a CronTrigger, the logic is gentler: most often, a single immediate fire happens, and then it's back to cron. The practical takeaway: before choosing a misfire instruction, look at which trigger you have and what exactly that instruction does in its terms — the defaults for Simple and Cron are different.

And a separate, real-life practical trap: if the beta and prod environments point at the same database (which happens more often than one would like), you need to watch very carefully that Quartz is turned off on beta. Otherwise, both environments will see the same jobs in the Quartz tables and compete for them — beta instances will start regularly snatching prod tasks. Debugging pain here is guaranteed, and it's all the worse because the symptom is flaky: now prod "didn't run," now beta suddenly did something to prod data.

When it fits: many heterogeneous jobs, you need history, you need misfire policies, you need to change the schedule from code without a redeploy.

What doesn't work:

  • One job = one node at a time. There's no parallelism inside a job
  • A full set of Quartz tables in the database, constant polling

Kubernetes CronJob

A K8s CronJob isn't a way to "run code on a schedule." It's a way to run code in isolation. Each firing spins up a separate pod with its own resource limits, its own lifecycle, its own logs. A job crashing won't drag down the main service, and a heavy nightly aggregation won't eat the resources allocated to serving traffic.

Subtleties that are often forgotten:

  • concurrencyPolicy — what to do if the previous run hasn't finished yet (Allow / Forbid / Replace)
  • startingDeadlineSeconds — if the controller missed a tick, within what window to still attempt the run. A separate footgun: if you leave it empty and the controller, for some reason, misses more than 100 schedules in a row (for example, kube-controller-manager was down for a while), the CronJob stops launching altogether — and Cannot determine if job needs to be started: too many missed start times shows up in the events. The cure is setting a reasonable startingDeadlineSeconds so the "missed" window isn't counted from the beginning of time
  • Cold start of a Spring Boot pod is realistically 20–40 seconds. For a short job that's half its working time

When it fits: heavy jobs that need resource isolation; tasks with explicit memory/CPU requirements; teams comfortable living in k8s.

What doesn't work:

  • Each job is a separate entity in CI/CD. Ten jobs = ten manifests
  • Observability is fragmented: logs are scattered across a heap of short-lived pods
  • Either a separate image, or the same one with a different entrypoint/profile — you need to decide at the outset

A decision matrix

The selection logic boils down to two questions:

The first question cuts off the case where the job needs separate resource limits or isolation from serving traffic — that's K8S CronJob territory. The second filters the situations where Quartz genuinely pays off: many heterogeneous jobs, you need run history, misfire policies matter, or a dynamic schedule. If neither is about your task, @Scheduled + ShedLock cover it without extra fuss.

This is only half the story.

@Scheduled + ShedLock, Quartz, and K8s CronJob cover a huge share of real-world scheduling problems. But the deposits in Ivan's running example keep growing, and eventually, none of these three are enough on their own.

In Part 2, Ivan shares what he then does when the simple solutions are no longer enough.

Read similar stories

Scheduled jobs in Java + Spring + Kubernetes - Part 2
Infrastructure

July 20, 2026

Scheduled jobs in Java + Spring + Kubernetes - Part 2

by Ivan Vorgul

What Running Kafka on VMs Taught Us About Systems Thinking
Infrastructure

July 10, 2026

What Running Kafka on VMs Taught Us About Systems Thinking

by Celestina Amadi

High Availability in Production: What Running at Scale Actually Requires
Infrastructure

May 14, 2026

High Availability in Production: What Running at Scale Actually Requires

by Adegoke Obasa

Get more stories like this

Sign up for exciting updates on Moniepoint and how we’re powering business dreams.