Chaining Strategies, State Management, Error Handling & Monitoring in Salesforce Apex

Share

Asynchronous Apex can massively scale your Salesforce org—but only if you chain jobs safely, manage state effectively, handle errors gracefully, and maintain visibility into what’s happening behind the scenes.

This guide walks through real-world strategies for chaining async jobs, passing state across transactions, handling errors reliably, and monitoring everything in production. It’s built from practical experience, not theory.


1) Chaining Strategies

Efficient chaining ensures that your async jobs run in the right order without breaking Salesforce’s limits. Here’s how to choose the right pattern for your use case.

Queueable → Queueable (serial paging)
Inside execute, enqueue the next job to process the following “page” of records.

? Note: You can only chain one job from each execute call.

Batch → finish → Queueable/Batch
Kick off the next process in the finish() method once all batch chunks have completed.

Schedulable as an orchestrator
Use a scheduled job to launch Batch or Queueable jobs on a fixed interval—or as a backoff/delay mechanism for retries.

Fan-out with Platform Events
Publish a Platform Event and let multiple subscribers (Apex, Flow, or even external systems) respond independently.
This pattern creates clean, decoupled orchestration.

Avoid @future for orchestration
@future methods are fire-and-forget. They don’t support chaining, so they’re not suitable for orchestrating complex async flows.


2) State Passing

Keeping track of progress and data across async boundaries is key to reliability.

Within a Batch run:
Use Database.Stateful to retain counters, aggregates, or cursors between execute calls.

Across Queueables:
Pass small state directly through constructor parameters.
For larger state, save it in a custom object like JobRun__c or Platform Cache, then pass only the record Id between jobs.

Use idempotency tokens:
Store a unique key (for example, JobRun__c.Id + page) so retries won’t reprocess the same data multiple times.

⚠️ Avoid static variables for state sharing—they reset on every transaction.


3) Error Handling: Retryable vs. Terminal

Not all errors are equal. Separate them into retryable and terminal categories and handle them differently.

Retryable errors
(e.g., timeouts, UNABLE_TO_LOCK_ROW, 5xx callout responses)
→ Retry the operation, possibly using smaller chunks or a scheduled backoff job.

Terminal errors
(e.g., validation issues, bad input data)
→ Log the problem, skip the record, and continue processing.

Partial DML success
Use Database.update(list, false) to process records individually.
Aggregate SaveResult errors to track failed rows instead of aborting the entire transaction.

Prevent infinite retries
Keep a retry counter (e.g., Attempt__c on JobRun__c or in a Platform Event payload) and enforce a max retry limit.

Dead-letter queue
Send unrecoverable failures to a JobError__c custom object for manual review and troubleshooting.


4) Monitoring & Observability

A reliable async system needs visibility at every level.

  • AsyncApexJob – Query by job Id to check system status, job type, error counts, and overall progress.

  • JobRun__c dashboard – Maintain your own progress tracking object with totals, success/failure counts, timestamps, and the latest error message.

  • Platform Events for status updates – Publish Started, Progress, Completed, and Failed events. Admins or external systems can subscribe in Flow or middleware.

  • Trace limits – Log governor metrics like Limits.getQueries() and Limits.getDmlRows() when nearing thresholds. This helps you proactively tune performance.


Real-World Example (With Code)

Scenario:
We’re enriching Account records using an external API in paginated Queueables.
The job tracks progress and state in a custom JobRun__c record, retries transient callout failures, and broadcasts progress through a Platform Event called Job_Status__e.

Custom objects and fields assumed:

  • JobRun__cStatus__c, Total__c, Success__c, Failed__c, Cursor__c, Attempt__c, LastError__c

  • Platform Event: Job_Status__eJobId__c, Phase__c, Message__c, Success__c, Failed__c


A) Orchestrator — Starts the First Worker and Tracks State

// AccountEnrichmentOrchestrator.cls
public with sharing class AccountEnrichmentOrchestrator {
    public static Id start(Set<Id> accountIds, Integer pageSize) {
        JobRun__c jr = new JobRun__c(
            Status__c = 'Started',
            Total__c = accountIds.size(),
            Success__c = 0,
            Failed__c = 0,
            Attempt__c = 0
        );
        insert jr;

        // Chunk IDs deterministically
        List<Id> ordered = new List<Id>(accountIds);
        ordered.sort();
        List<Id> firstPage = ordered.subList(0, Math.min(pageSize, ordered.size()));
        String remainder = JSON.serialize(ordered.subList(firstPage.size(), ordered.size()));

        update new JobRun__c(Id = jr.Id, Cursor__c = remainder);
        publishStatus(jr.Id, 'Started', 'Enrichment started');

        System.enqueueJob(new AccountEnrichmentWorker(jr.Id, firstPage, pageSize));
        return jr.Id;
    }

    public static void publishStatus(Id jobRunId, String phase, String msg) {
        EventBus.publish(new Job_Status__e(
            JobId__c = String.valueOf(jobRunId),
            Phase__c = phase,
            Message__c = msg
        ));
    }
}

B) Worker Queueable — Processes a Page and Chains the Next

// AccountEnrichmentWorker.cls
public with sharing class AccountEnrichmentWorker implements Queueable, Database.AllowsCallouts {
    private Id jobRunId;
    private List<Id> pageIds;
    private Integer pageSize;

    public AccountEnrichmentWorker(Id jobRunId, List<Id> pageIds, Integer pageSize) {
        this.jobRunId = jobRunId; this.pageIds = pageIds; this.pageSize = pageSize;
    }

    public void execute(QueueableContext qc) {
        JobRun__c jr = [SELECT Id, Success__c, Failed__c, Cursor__c, Attempt__c
                        FROM JobRun__c WHERE Id = :jobRunId LIMIT 1];

        // 1) Rehydrate Accounts
        Map<Id, Account> accts = new Map<Id, Account>([
            SELECT Id, Name, Industry, Enriched__c
            FROM Account WHERE Id IN :pageIds
        ]);

        // 2) Call external API (retryable errors handled)
        List<Account> updates = new List<Account>();
        try {
            List<Map<String, Object>> payload = new List<Map<String, Object>>();
            for (Account a : accts.values()) payload.add(new Map<String, Object>{ 'id' => a.Id, 'name' => a.Name });

            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:EnrichmentService/bulk');
            req.setMethod('POST');
            req.setHeader('Content-Type','application/json');
            req.setBody(JSON.serialize(payload));
            HttpResponse res = new Http().send(req);

            if (res.getStatusCode() >= 500) throw new CalloutException('Transient server error ' + res.getStatusCode());

            List<Object> enriched = (List<Object>) JSON.deserializeUntyped(res.getBody());
            for (Object o : enriched) {
                Map<String, Object> row = (Map<String, Object>) o;
                Id id = (Id) row.get('id');
                Account upd = new Account(
                    Id = id,
                    Enriched__c = true,
                    Industry = (String) row.get('industry') // example enrichment
                );
                updates.add(upd);
            }
        } catch (Exception ex) {
            // Retry for transient failures (cap attempts)
            Integer attempts = (jr.Attempt__c == null ? 0 : Integer.valueOf(jr.Attempt__c)) + 1;
            if (attempts <= 3) {
                update new JobRun__c(Id = jr.Id, Attempt__c = attempts, LastError__c = ex.getMessage());
                // simple backoff via scheduler (delay 5 minutes)
                String cron = nextCronInMinutes(5);
                System.schedule('Retry ' + jr.Id + ' #' + attempts, cron,
                    new RetryLauncher(jobRunId, pageIds, pageSize));
                AccountEnrichmentOrchestrator.publishStatus(jr.Id, 'Retrying', 'Attempt ' + attempts + ': ' + ex.getMessage());
                return;
            } else {
                // Mark all as failed for this page
                update new JobRun__c(Id = jr.Id, Failed__c = (jr.Failed__c == null ? 0 : jr.Failed__c) + pageIds.size(),
                                     LastError__c = ex.getMessage());
                AccountEnrichmentOrchestrator.publishStatus(jr.Id, 'Failed', 'Final failure: ' + ex.getMessage());
                chainNextPage(jr);
                return;
            }
        }

        // 3) Partial DML save with per-row errors
        if (!updates.isEmpty()) {
            Database.SaveResult[] sr = Database.update(updates, /* allOrNone */ false);
            Integer ok = 0, bad = 0;
            for (Database.SaveResult r : sr) {
                if (r.isSuccess()) ok++; else bad++;
            }
            update new JobRun__c(
                Id = jr.Id,
                Success__c = (jr.Success__c == null ? 0 : jr.Success__c) + ok,
                Failed__c = (jr.Failed__c == null ? 0 : jr.Failed__c) + bad,
                Attempt__c = 0,
                LastError__c = null
            );
            AccountEnrichmentOrchestrator.publishStatus(jr.Id, 'Progress',
                'Page processed. Success: ' + ok + ' Fail: ' + bad);
        }

        // 4) Chain the next page
        chainNextPage(jr);
    }

    private void chainNextPage(JobRun__c jr) {
        List<Id> nextIds = (List<Id>) JSON.deserialize(JSON.isNull(jr.Cursor__c) ? '[]' : jr.Cursor__c, List<Id>.class);
        if (nextIds.isEmpty()) {
            update new JobRun__c(Id = jr.Id, Status__c = 'Completed');
            AccountEnrichmentOrchestrator.publishStatus(jr.Id, 'Completed', 'All pages processed');
            return;
        }
        Integer take = Math.min(pageSize, nextIds.size());
        List<Id> page = nextIds.subList(0, take);
        String remainder = JSON.serialize(nextIds.subList(take, nextIds.size()));
        update new JobRun__c(Id = jr.Id, Cursor__c = remainder);

        System.enqueueJob(new AccountEnrichmentWorker(jr.Id, page, pageSize));
    }

    // Helper: create a cron string X minutes from now (nearest minute)
    private static String nextCronInMinutes(Integer minutes) {
        Datetime t = Datetime.now().addMinutes(minutes);
        return String.format('0 {0} {1} {2} {3} ? {4}',
            new List<String>{ String.valueOf(t.minute()), String.valueOf(t.hour()),
                String.valueOf(t.day()), String.valueOf(t.month()), String.valueOf(t.year()) });
    }
}

C) Retry Launcher — A Simple “Delayer” Using Schedulable

// RetryLauncher.cls
global with sharing class RetryLauncher implements Schedulable {
    Id jobRunId; List<Id> pageIds; Integer pageSize;
    public RetryLauncher(Id jobRunId, List<Id> pageIds, Integer pageSize) {
        this.jobRunId = jobRunId; this.pageIds = pageIds; this.pageSize = pageSize;
    }
    global void execute(SchedulableContext sc) {
        System.enqueueJob(new AccountEnrichmentWorker(jobRunId, pageIds, pageSize));
    }
}

D) Monitoring Dashboards and Status Events

// Sample SOQL to check a job's AsyncApexJob row by Id
Id jobId = System.enqueueJob(new AccountEnrichmentWorker('a01xx000000123A', new List<Id>(), 200));
AsyncApexJob j = [SELECT Id, Status, JobType, NumberOfErrors, TotalJobItems, CreatedDate
                  FROM AsyncApexJob WHERE Id = :jobId];

// Example: subscribe to Job_Status__e in a Flow or external system to visualize progress.
// (No Apex code needed here—admins wire it up.)

Chaining strategiesWhy This Approach Works

  • Chaining: Each worker enqueues exactly one next job, ensuring predictable sequencing and no runaway chains.

  • State Management: Large state (IDs, counters) lives in JobRun__c. Each job just passes the record Id and current page.

  • Error Handling: Temporary errors are retried safely; terminal ones are logged and skipped without breaking the flow.

  • Monitoring: Progress and errors are visible via JobRun__c, Platform Events, and standard AsyncApexJob queries.


Final Thoughts

Think of your async architecture like a mini distributed system:
chain intentionally, persist your state, classify your errors, and instrument everything.

By combining an orchestration record (JobRun__c), status events (Job_Status__e), and disciplined chaining, your async jobs become resilient, predictable, and self-healing—a huge win for reliability and supportability in production.

  • October 21, 2025