Enterprise Java teams commonly reach a point where scheduled jobs become a liability rather than an asset. Jobs fail silently — catching exceptions, logging nothing meaningful, and returning success to the scheduler. Others run simultaneously because the previous execution didn't finish before the next trigger fired and nobody configured @ScheduledLock. Data corruption from concurrent runs can take weeks to surface.
None of this shows up in dashboards until a data audit flags inconsistencies in a critical report.
This guide explains why @Scheduled breaks down at scale, how Temporal's durable execution model eliminates these problems, and how to migrate a production Spring Batch pipeline with complete code examples.
What Temporal Actually Is (In One Paragraph)
Temporal is a durable execution platform. You write plain Java code — functions, loops, try/catch — and Temporal makes it fault-tolerant by recording every state transition to an event log. If the process crashes mid-execution, it replays the log and resumes from exactly where it left off. There's no external state machine to define, no checkpoint tables to maintain, no retry logic to write. The code IS the workflow.
graph TD
subgraph BEFORE ["Before — Spring Scheduler + Batch"]
C1[Cron Trigger] --> J1[@Scheduled Method]
J1 --> J2[Spring Batch Job]
J2 --> J3[ItemReader]
J3 --> J4[ItemProcessor]
J4 --> J5[ItemWriter]
J5 --> J6[DB commit]
E1[Failure?] -.->|swallowed| X1[😶 Silent]
E2[Overlap?] -.->|no guard| X2[💥 Corruption]
end
subgraph AFTER ["After — Temporal"]
T1[Temporal Scheduler] --> W1[Workflow Method]
W1 --> A1[Activity: Read chunk]
W1 --> A2[Activity: Process chunk]
W1 --> A3[Activity: Write chunk]
F1[Failure?] -.->|auto-retry| W1
F2[Crash?] -.->|replay from log| W1
F3[Overlap?] -.->|workflow ID lock| X3[✅ Prevented]
end
The Problem With @Scheduled at Scale
@Scheduled works fine for one or two simple jobs. By the time a team has dozens, accumulated debt becomes significant:
No visibility. Which jobs ran? Which failed? How long did they take? Spring's scheduler offers nothing here by default. Teams bolt on Actuator, add custom logging, hook up Micrometer. By the time there's real observability it's a custom framework to maintain.
No fault tolerance. An exception kills the job instance. Whether it retries, and how, is the team's problem. Teams solve this inconsistently — some catch-and-retry inline, some use Spring Retry, some let it silently fail and rely on the next scheduled trigger.
No distributed locking. @ScheduledLock with ShedLock or Quartz clustering works, but it's an additional library, additional config, and an additional failure mode (what happens when the lock row gets corrupted?).
Testing is painful. Unit tests can cover job logic, but testing scheduling behavior — does it actually retry? does it respect the lock? — requires either waiting for real time to pass or mocking the scheduler in ways that diverge from production behavior.
Spring Batch adds its own complexity. Job metadata tables (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION, etc.) need to be managed, pruned, and kept consistent. The restart/skip/retry model is powerful but verbose to configure. And Spring Batch has no concept of a workflow spanning multiple jobs — that becomes custom orchestration code.
Before: A Typical Spring Batch Job
Here's a representative pattern — daily invoice reconciliation, reading from a pending_invoices table, processing, and writing to processed_invoices:
// Cron trigger — buried in application.yml
// cron: "0 0 2 * * *"
@Component
@RequiredArgsConstructor
public class InvoiceReconciliationScheduler {
private final JobLauncher jobLauncher;
private final Job invoiceReconciliationJob;
@Scheduled(cron = "${jobs.invoice-reconciliation.cron}")
@SchedulerLock(name = "invoiceReconciliation", lockAtMostFor = "PT2H")
public void run() {
try {
JobParameters params = new JobParametersBuilder()
.addLong("run.id", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(invoiceReconciliationJob, params);
} catch (Exception e) {
log.error("Invoice reconciliation failed", e);
// No alert. No retry. No escalation.
}
}
}
@Configuration
@RequiredArgsConstructor
public class InvoiceReconciliationJobConfig {
@Bean
public Job invoiceReconciliationJob(Step reconcileStep) {
return jobBuilderFactory.get("invoiceReconciliationJob")
.start(reconcileStep)
.build();
}
@Bean
public Step reconcileStep() {
return stepBuilderFactory.get("reconcileStep")
.<PendingInvoice, ProcessedInvoice>chunk(500)
.reader(invoiceReader())
.processor(invoiceProcessor())
.writer(invoiceWriter())
.faultTolerant()
.retryLimit(3)
.retry(TransientDataAccessException.class)
.build();
}
// ... reader/processor/writer beans
}
This is roughly 150 lines across three files for one job. It has per-item retry (good), but no workflow-level retry, no timeout enforcement, no alerting on failure, and no way to see execution history without querying the batch tables directly.
After: The Same Job in Temporal
// Workflow interface — the contract
@WorkflowInterface
public interface InvoiceReconciliationWorkflow {
@WorkflowMethod
void reconcile(LocalDate date);
}
// Activity interface — the actual I/O work
@ActivityInterface
public interface InvoiceReconciliationActivities {
List<PendingInvoice> readChunk(LocalDate date, int offset, int limit);
List<ProcessedInvoice> processChunk(List<PendingInvoice> invoices);
void writeChunk(List<ProcessedInvoice> processed);
int countPending(LocalDate date);
}
// Workflow implementation — plain Java, no framework magic
public class InvoiceReconciliationWorkflowImpl implements InvoiceReconciliationWorkflow {
private final InvoiceReconciliationActivities activities =
Workflow.newActivityStub(InvoiceReconciliationActivities.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofMinutes(10))
.setRetryOptions(RetryOptions.newBuilder()
.setMaximumAttempts(5)
.setDoNotRetry(IllegalArgumentException.class.getName())
.build())
.build());
@Override
public void reconcile(LocalDate date) {
int total = activities.countPending(date);
int offset = 0;
int chunkSize = 500;
while (offset < total) {
List<PendingInvoice> chunk = activities.readChunk(date, offset, chunkSize);
List<ProcessedInvoice> processed = activities.processChunk(chunk);
activities.writeChunk(processed);
offset += chunkSize;
}
}
}
// Schedule registration — once, at startup
client.scheduleClient().createSchedule(
"invoice-reconciliation-daily",
ScheduleSpec.newBuilder()
.setCronExpressions(List.of("0 2 * * *"))
.build(),
ScheduleAction.startWorkflow(
InvoiceReconciliationWorkflow.class,
"reconcile",
WorkflowOptions.newBuilder()
.setWorkflowId("invoice-recon-" + LocalDate.now())
.setTaskQueue("invoice-queue")
.setWorkflowExecutionTimeout(Duration.ofHours(4))
.build())
);
The workflow body is 12 lines. It reads like the business requirement. No chunk configuration DSL, no step beans, no job parameter boilerplate.
Because the workflow ID includes the date (invoice-recon-2026-08-28), attempting to start a second execution for the same day throws WorkflowExecutionAlreadyStarted — the overlap corruption problem is gone by construction, with no lock to configure or expire.
What You Get for Free
Retries with backoff. Every activity gets the retry policy defined at registration. Failed activities retry automatically, with exponential backoff, without any code in the activity itself.
Crash recovery. If the worker process dies mid-workflow, the next worker that picks up the task queue replays the event history and continues from the last completed activity. No data is lost.
Visibility out of the box. Temporal's web UI shows every workflow execution: start time, current state, activity history, retry attempts, failures, input and output. What would take custom Micrometer instrumentation now comes for free.
Workflow ID deduplication. Using a business-meaningful workflow ID (like invoice-recon-2026-08-28) means the same logical job can never run twice. This is structurally better than distributed locking.
Testable without real time. Temporal's TestWorkflowEnvironment lets you test the entire workflow including retries, timeouts, and activity failures — in unit tests, without waiting for real timers.
@Test
void shouldRetryFailedActivity() {
TestWorkflowEnvironment env = TestWorkflowEnvironment.newInstance();
Worker worker = env.newWorker("invoice-queue");
worker.registerActivitiesImplementations(new InvoiceActivitiesFailing(failOnAttempt: 1));
worker.registerWorkflowImplementationTypes(InvoiceReconciliationWorkflowImpl.class);
env.start();
InvoiceReconciliationWorkflow wf = env.getWorkflowClient()
.newWorkflowStub(InvoiceReconciliationWorkflow.class,
WorkflowOptions.newBuilder().setTaskQueue("invoice-queue").build());
// Runs in milliseconds — Temporal's test env skips real timers
wf.reconcile(LocalDate.of(2026, 8, 28));
verify(failingActivity, times(2)).readChunk(any(), anyInt(), anyInt()); // retried once
}
Common Migration Pitfalls
Activity timeouts. StartToCloseTimeout must be set conservatively — Temporal cancels and retries the activity if it exceeds the timeout. Set it to the P99 execution time, not the average. Activities that occasionally run for 45 minutes on large-backlog days need that reflected in their timeout.
Non-idempotent writers. Temporal retries activities automatically. If a writer inserts rows without an upsert, retries produce duplicates. Every write activity must be idempotent — ON CONFLICT DO UPDATE on the write side before migration.
History size limits. Temporal's event history has a default limit of 50,000 events. A workflow processing 500k records in 500-row chunks generates 3,000 activity calls — fine. Processing row-by-row would hit the limit. Design activities to operate on chunks, not individual records.
Local dev environment. Running Temporal locally requires Docker (temporalio/temporal). Teams without Docker in their standard dev setup will have friction on day one. Add it to the team devcontainer or docker-compose before rollout.
[!NOTE]
Temporal's Java SDK (version 2.x) works with Spring Boot 3.2+ virtual threads automatically. Pair it with spring.threads.virtual.enabled=true and worker threads scale to thousands of concurrent activity executions on minimal OS threads.
When to Keep Spring Batch
Not every job should move to Temporal. Spring Batch remains the right tool in two scenarios:
Regulated data processing with mandatory audit trails. Spring Batch's job metadata schema (BATCH_JOB_INSTANCE, BATCH_JOB_EXECUTION) is a ready-made audit log that compliance and operations teams can query directly in the database. Temporal's event history lives in Temporal's own store — visible in the UI, but not in your RDBMS. For financial reporting jobs with regulatory requirements, Spring Batch's schema is an asset.
Pure ETL with no orchestration complexity. A job that reads a file, transforms rows, writes to a table, and exits is exactly what Spring Batch was designed for. No retry logic, no dependencies between steps, no timeout sensitivity. Rewriting it in Temporal adds infrastructure without adding value.
Decision rule: if you would describe the job as "a workflow" — with branches, dependencies, or conditions — use Temporal. If you would describe it as "a batch" — fixed input, transform, fixed output — Spring Batch is the right fit.
Getting Started
<dependency>
<groupId>io.temporal</groupId>
<artifactId>temporal-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>
# application.yml
spring:
temporal:
connection:
target: localhost:7233
workers:
- task-queue: invoice-queue
workflow-classes:
- com.example.workflows.InvoiceReconciliationWorkflowImpl
activity-beans:
- invoiceReconciliationActivities
Temporal's Spring Boot starter auto-wires workers, injects WorkflowClient and ScheduleClient as beans, and handles graceful shutdown.
# Local dev — start Temporal server
docker run --rm -p 7233:7233 -p 8233:8233 temporalio/temporal:latest
# UI available at http://localhost:8233
Migration Checklist
- [ ] Inventory all
@Scheduledand Spring Batch jobs — map dependencies between them - [ ] Classify: "workflow" (Temporal) vs "pure batch" (keep Spring Batch)
- [ ] Make all activity implementations idempotent (upsert, not insert)
- [ ] Set
StartToCloseTimeoutat P99 of execution time, not average - [ ] Use business-meaningful workflow IDs for deduplication
- [ ] Add Temporal Docker to dev environment / docker-compose
- [ ] Run parallel execution for 2 weeks (old scheduler + Temporal) and compare outputs
- [ ] Cut over the scheduler trigger, remove old
@Scheduledbeans - [ ] Configure Temporal Worker autoscaling (CPU-based HPA works well on Kubernetes)
Summary
@Scheduled is fine for one or two simple jobs. Beyond that, teams face silent failures, concurrent execution bugs, and zero visibility — problems that only surface when they've already caused damage.
Temporal solves all three at the infrastructure level: durable execution for crash recovery, workflow ID deduplication for concurrency control, and a built-in UI for visibility. The workflow code is shorter than Spring Batch equivalent, easier to read, and testable in isolation without mocking time.
For teams with more than 10 scheduled jobs, or any jobs with dependencies and retries, Temporal is worth evaluating before the next production incident makes the case for you.
Working with Temporal in Java or evaluating the migration? Happy to discuss patterns and pitfalls. Find me on LinkedIn.
Top comments (0)