Java
    August 28, 202622 min read

    Temporal vs Spring Batch: Fixing Enterprise Java's Silent Job Failure Problem

    Many enterprise Java teams have scheduled jobs failing silently, running concurrently, and corrupting data — without knowing. This guide explains how Temporal's durable execution model solves these problems, with complete code examples migrating from @Scheduled and Spring Batch.

    Share

    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 @Scheduled and 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 StartToCloseTimeout at 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 @Scheduled beans
    • 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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Was this helpful?
    AY
    Avaneesh Yadav

    I build enterprise AI systems — Spring AI, RAG, and agents — and write about shipping LLMs to production. I also run advisory and workshops for engineering teams.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Join engineers building production AI systems

    Comments