Advanced Asynchronous Apex Guide

Executive summary

Salesforce asynchronous execution is not a single feature. It is a family of distinct runtime models with very different guarantees around transaction boundaries, ordering, retries, state transfer, user context, observability, and scale. The durable “server-side async” tools are Queueable Apex, Batch Apex, Scheduled Apex, Future methods, and event-driven subscribers such as platform event triggers. By contrast, imperative or wired @AuraEnabled calls are asynchronous from the browser’s perspective but still run as ordinary server transactions unless they explicitly use Continuation or enqueue server-side work. That distinction is the root cause of many design mistakes.

The current best default for most new Apex-based async work is Queueable Apex, not @future, because Queueable supports job IDs, complex state, chaining, finalizers, deduplication signatures, and newer controls such as maximum stack depth and minimum queue delay. @future still has legitimate uses, especially legacy mixed-DML isolation or very small fire-and-forget jobs, but it remains more constrained: parameters must be primitive or collections of primitives, execution order is not guaranteed, and orchestration is poorer. Salesforce’s own docs explicitly recommend Queueable instead of future methods.

For data-heavy workloads, Batch Apex remains the right primitive when you need to process very large data sets, including query locators up to large record volumes, per-scope governor resets, and fault isolation by chunk. But Batch has its own shape: only five jobs can be queued or active concurrently, an additional 100 can sit in the flex queue, batch execution order is not guaranteed, and non-transactional side effects such as callouts must be idempotent because scopes can be retried or restarted. Salesforce’s architect guidance is especially strong here: avoid tiny, fast batch scopes that flood the queue, and schedule large jobs off-peak.

For high-volume, decoupled architectures, Platform Events and Change Data Capture often outperform trigger-fired async jobs conceptually, because they provide durable streams, replay, multiple subscribers, failure isolation from the original save, and stronger external integration patterns. But they introduce their own operational realities: asynchronous platform event publishing is at least once, not exactly once, replay IDs are opaque and should not be treated as arithmetic sequences or universal dedupe keys, and subscriber triggers often run under Automated Process unless explicitly overridden through subscriber configuration.

Two counterintuitive behaviors matter disproportionately in production. First, a Queueable’s serialized state is taken when the job is enqueued, so mutating the original object afterward does not change what the queued job sees; however, a finalizer attached to that queueable uses the finalizer object’s state at the end of queueable execution, which makes finalizers unusually powerful for retries, recovery, and postmortem logging. Second, Async SOQL is now historical, not strategic: Salesforce retired it in Summer ’23 and recommends Bulk API query or Batch Apex instead.

Async Apex landscape and decision matrix

When architects say “use async,” they usually mean one of four goals: reduce end-user latency, escape synchronous limits, decouple work into a new transaction, or build durable event-driven processing. Those goals do not map one-to-one to a single mechanism. A concise decision heuristic is: use Queueable for most transactional background work and callouts, Batch for very large data sets or chunk-level fault isolation, Scheduled to start work at a later time, Platform Events/CDC when you need loose coupling and durable replay, Continuation for long-running UI callouts, and @future mainly for legacy compatibility or narrow isolation scenarios. If the requirement is “Lightning screen shouldn’t block,” imperative @AuraEnabled may be enough; if the requirement is “server must continue later or retry later,” it is not enough by itself.

The table below compares the major mechanisms the user requested, including two important “not-quite-Apex-async” entries: @AuraEnabled client patterns and the retired Async SOQL feature.

MechanismWhat actually becomes asynchronousState transferTypical sweet spotImportant limits and behaviorsMain drawbacksSources
Queueable ApexNew server transaction when resources are availableComplex serializable instance state; job ID returnedMost new background jobs, callouts, sequential orchestrationUp to 50 enqueues in a synchronous transaction, but only 1 in an asynchronous transaction; supports chaining, async options, dedupe signatures, finalizers, and monitoring through AsyncApexJobNot ideal for tens of millions of rows; serialized state can surprise you; ordering between separate jobs is not guaranteed
Future methodsNew server transactionPrimitive params onlySmall fire-and-forget work, some mixed-DML isolation, legacy codeParams must be primitive/primitive collections; order not guaranteed; daily limits are org-shared; callouts require callout=trueNo job ID, no chaining, no finalizers, weaker orchestration
Batch Apexstart, each execute scope, and finish run in separate transactionsQueryLocator/Iterable payload; Database.Stateful preserves only instance members across scopesVery large record sets, chunked fault isolation, scope-level retriesFive queued/active jobs concurrently; 100 more in Holding flex queue; per-scope limits reset; order between scopes isn’t guaranteedStartup overhead; design must be idempotent for non-transactional work; too many small jobs can flood the queue
Scheduled ApexCron-triggered transaction at or after the requested timeConstructor or queried state at runtimeTime-based kickoff, periodic maintenance, orchestration starterMax 100 scheduled jobs; scheduler runs in system context; for callouts, schedule Queueable or Batch rather than direct synchronous callout workCan overlap with previous runs if recurrence is careless; scheduled time is not a hard real-time SLA
Platform Events with Apex triggerEvent bus delivery to subscriber transactionEvent payload and replay/retry metadataDecoupled business events, multi-subscriber flows, external integrationsDefault trigger batch size 2,000; default running user is Automated Process; replay retention usually 72 hours; publish model is at least once; retryable triggers refire up to 9 times after the first attemptRequires idempotent consumers and correlation keys; replay IDs are opaque, not arithmetic
ContinuationLong-running external callout is offloaded; callback resumes in a new transactionstate object passed to callbackUI-driven long-running external requestsUp to 3 callouts per continuation; timeout up to 120s; continuation callouts no longer matter for the old long-running request limit advantage since Winter ’20, but still improve UXSpecialized model; not a general replacement for Queueable/Batch
@AuraEnabled wire / imperative patternsClient-side JS gets async behavior; server Apex is still an ordinary transaction unless it itself enqueues work or returns a ContinuationJSON-serializable request/response payloadsLWC/Aura server round-trips, cacheable reads, UI workflows@wire requires cacheable=true; imperative calls can use cacheable=true but must refresh record caches explicitly; don’t overload @AuraEnabled methods because call selection is non-deterministicEasy to mistake for “Async Apex” when it is often just “async network call from client to standard server transaction”
Apex Flex QueueNot a job type; a holding queue for batch jobsN/ACapacity management and reordering of batch workUp to 100 Holding batch jobs; up to 5 queued/active jobs can process simultaneously; reordering available through FlexQueueApplies to Batch, not Queueable; still requires org-level capacity discipline
Async SOQLHistorical async query service for big objectsN/AHistorically: big-object aggregate/query workloadsRetired in Summer ’23; Salesforce recommends Bulk API query or Batch Apex insteadShould not be chosen for new architecture

One especially useful architectural connection is this: if you are tempted to enqueue a Queueable directly from a hot trigger path under heavy data loads, Salesforce’s decision guidance now points you toward CDC or another more decoupled pattern for scale and resilience. A failure in the subscriber no longer rolls back the original save, and multiple internal or external subscribers can consume the same stream. That is often a better long-term design than spending years building org-local queue throttling logic.

Execution semantics and transaction boundaries

The single most important mental model is that every asynchronous hop is a new Apex entry point. That means fresh governor counters, a fresh commit boundary, and potentially a different security context. Batch scopes are separate transactions, Continuation resumes in a new transaction, platform event subscribers run in their own deliveries, and Queueable/Future jobs are deferred until system resources are available. This is why async is so useful for mixed DML avoidance, latency hiding, and retry design—but also why cross-transaction consistency must be designed explicitly rather than assumed.

For ordinary record saves, Salesforce documents post-commit logic such as sending email and enqueuing asynchronous Apex jobs in no guaranteed order. That matters more than many teams expect. If your design requires strict sequencing across jobs, do not rely on “which async thing gets picked up first.” Encode the sequence explicitly through job chaining, durable state, or events. The same warning applies to future methods, whose docs explicitly call out that they do not necessarily execute in the order they are called. Batch scope order is also not guaranteed.

State persistence and serialization

Queueable and Batch behave very differently here. A Queueable’s instance state is serialized for later execution; a Batch class resets static state between transactions, and Database.Stateful preserves only instance variables across scopes, not static variables. In practice, this means that “stateful batch totals” can be safe, but “static recursion flags across batch scopes” are not. It also means a queued object is a snapshot at enqueue time, not a live reference to the in-memory object you still hold in the caller. That second rule is under-documented in many teams and is a common source of debugging confusion.

A subtle but powerful exception is the Transaction Finalizer model. Salesforce documents that finalizers attach to Queueable jobs, and a failed Queueable can be successively re-enqueued five times via finalizer logic. Community deep dives, including respected Salesforce Stack Exchange explanations, also show the counterintuitive behavior that the finalizer object’s state is evaluated at the end of queueable execution, not purely when it is attached. That makes finalizers valuable for accumulating processed IDs, partial progress, or retry metadata even if the queueable later throws an exception.

Security context

Security context is not uniform across async types. Scheduled Apex runs in system context. Platform event Apex triggers run as Automated Process by default, though you can override the running user and batch size via subscriber configuration. For asynchronous Apex classes generally, inherited sharing is a useful advanced technique because Salesforce documents that inherited-sharing classes run in with sharing mode for asynchronous operations. Independent of sharing, object/FLS enforcement should be made explicit through USERMODE or SYSTEMMODE in queries and DML rather than relying on version-sensitive defaults.

One practical implication: logging and observability may appear under a different user than the one who initiated the work. Platform event trigger logs are created under Automated Process by default, and publish callbacks for platform events also run asynchronously under Automated Process. If your audit trail must preserve requester identity, include that identity in your persisted work item, event payload, or callback state rather than assuming UserInfo.getUserId() will always refer back to the human originator.

Platform events, streaming, and replay semantics

Platform events are durable enough to support replay, but not enough to let you ignore idempotency. Salesforce stores platform events and CDC events for 72 hours on the event bus, and replay IDs are opaque positions in that stream. They are not guaranteed contiguous, and under maintenance or org moves they are not a safe universal event identity; for platform events, Salesforce recommends using the event id or EventUuid model where relevant for uniqueness. In addition, the at-least-once publish model means duplicate deliveries are a real architectural possibility, not a theoretical one.

For browser subscribers, lightning/empApi is still relevant, but it has practical constraints that are easy to overlook in design reviews: it uses a shared CometD connection for a single user session, supports desktop browsers only, and is not supported in the Salesforce mobile app. For external systems, Salesforce positions Pub/Sub API as the newer unified API for publishing and subscribing to platform events, CDC, and other event streams.

Continuations and UI async patterns

Continuation is often misunderstood as “just another async Apex feature.” It is more precise to think of it as a specialized UI callout orchestration facility. The callout work is offloaded, and when the responses return, the callback runs in a new transaction. A single Continuation can hold up to three callouts, and the global continuation timeout is 120 seconds. Salesforce also notes an important historical shift: since Winter ’20, Continuations no longer provide a governor-limit advantage around the old long-running request rule, but they still remain valuable because they improve user experience and keep the UI responsive.

One more subtlety: the browser-side async patterns around @AuraEnabled are client-cache-aware, not “server-work-aware.” Wired Apex requires cacheable=true. Imperative Apex can also call a cacheable method, which means client-side caches may satisfy it before another network trip is issued. Salesforce’s Continuation example even shows the same continuation method being used by both @wire and imperative calls, where the imperative call can return the already cached result. That can be excellent for UX and terrible for debugging if you expected a fresh server hit.

Limits, retries, idempotency, and data consistency

All async choices share the same fundamental truth: they consume org-level capacity, not just per-transaction capacity. Salesforce documents a daily asynchronous Apex execution limit shared across Batch, Future, Queueable, and Scheduled Apex, and exposes it through org limits APIs. This means a design that “only” enqueues one Queueable per trigger invocation can still become an org-wide outage if it is fed by a large data load or chatty integration. Salesforce’s record-triggered architecture guidance explicitly warns that 20,000 loaded records can translate into many trigger invocations and thus many async enqueues.

Key limit patterns that drive architecture

From a synchronous transaction, you can enqueue up to 50 Queueables; from an asynchronous transaction, you can enqueue only 1. This is one of the most important interview-grade traps in the whole platform because code that works perfectly in UI tests can fail under Batch, platform-event-triggered flows to Apex, or other async origins. Batch itself is constrained to 5 active/queued jobs plus 100 Holding jobs in Flex Queue, and Scheduled Apex is limited to 100 scheduled jobs. Platform event trigger deliveries can arrive in batches of 2,000 event messages, which creates very different heap and DML stress from a normal sObject trigger batch of 200 records.

For callouts, a single Apex transaction can make up to 100 callouts, and total callout timeout is capped at 120 seconds across a normal transaction. Queueables can make callouts if they implement Database.AllowsCallouts, futures need @future(callout=true), and schedulables are generally better used to kick off a Queueable or Batch for callout work. Continuations have their own specialized timeout model.

Error handling and retry choices

Salesforce provides very different retry hooks depending on the primitive. For Queueable, Transaction Finalizers are the native hook. For Batch, a highly useful pattern is BatchApexErrorEvent, which gives granular chunk-failure visibility and supports logging and targeted retries. For platform-event subscribers, Salesforce gives you two different resilience tools: resume checkpoints using setResumeCheckpoint() when you have already processed some events successfully, and EventBus.RetryableException when you need the entire batch replayed after a transient failure; Salesforce documents up to nine retries after the initial run before the trigger goes into an error state. For event publishing itself, Apex publish callbacks provide the final result of asynchronous event publication, which the immediate SaveResult from EventBus.publish() does not guarantee.

Idempotency is not optional

Salesforce architect guidance is unusually direct on this point: callouts and other non-transactional operations from batch jobs must follow idempotent design because execute methods can run multiple times, including after maintenance or restart conditions. The same design principle should be extended to Queueables, Future methods that perform integrations, and especially Platform Event subscribers because event delivery is at least once. The safest broad pattern is: issue a deterministic idempotency key, persist it, and make the downstream receiver or your own subscriber logic treat duplicate keys as no-ops or upserts.

Queueable Apex now provides a native assist for one class of idempotency: duplicate signatures. You can build a QueueableDuplicateSignature, pass it through AsyncOptions, and Salesforce will throw DuplicateMessageException if the same signed job is enqueued again. This is especially valuable for trigger-based architectures where the same records may cause multiple invocations during one save cascade. It is not a universal dedupe layer—it prevents duplicate job submission, not duplicate downstream side effects—but it is an excellent first line of defense.

Mixed DML and cross-transaction consistency

Mixed DML remains a classic reason to force a transaction boundary. Salesforce docs explicitly note that future methods can be used to isolate DML on different sObject types to prevent the mixed-DML error, and the big-object guidance similarly points to asynchronous Apex, especially Queueable, to isolate DML on incompatible object types. But the tradeoff is important: once you split the work across transactions, you lose atomicity. The original business transaction may commit even if the async setup-object update fails later. In other words, async solves a database constraint by turning a single atomic unit into an eventually consistent process. That is often the right trade—but it is a trade.

Code patterns, edge cases, and mitigation strategies

Queueable with callout, duplicate suppression, finalizer retry, and bounded chaining

This pattern combines the modern Queueable toolkit: dedupe signatures, async options, callouts, and a finalizer for bounded retries. It is a strong default for outbound integration jobs that must tolerate transient failures without turning into runaway recursion. Salesforce supports all of these primitives directly.

public with sharing class InvoicePushJob implements Queueable, Database.AllowsCallouts {
    private Set<Id> invoiceIds;
    private Integer attempt;
    public InvoicePushJob(Set<Id> invoiceIds, Integer attempt) {
        this.invoiceIds = invoiceIds == null ? new Set<Id>() : invoiceIds.clone();
        this.attempt = attempt == null ? 1 : attempt;
    }
    public static Id enqueue(Set<Id> invoiceIds) {
        QueueableDuplicateSignature sig =
            new QueueableDuplicateSignature.Builder()
                .addString('InvoicePushJob')
                .addString(String.join(new List<String>((Set<String>)((Set<Object>)invoiceIds)), ','))
                .build();
        AsyncOptions opts = new AsyncOptions();
        opts.DuplicateSignature = sig;
        opts.MaximumQueueableStackDepth = 5;
        opts.MinimumQueueableDelayInMinutes = 1;
        return System.enqueueJob(new InvoicePushJob(invoiceIds, 1), opts);
    }
    public void execute(QueueableContext qc) {
        System.attachFinalizer(new InvoicePushFinalizer(invoiceIds, attempt));
        List<Invoice__c> invoices = [
            SELECT Id, External_Id__c, Status__c, Amount__c
            FROM Invoice__c
            WHERE Id IN :invoiceIds
        ];
        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Billing_API/invoices');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        // Idempotency key is critical for safe retries.
        req.setHeader('Idempotency-Key', 'invoice-push-' + attempt + '-' + UserInfo.getOrganizationId());
        req.setBody(JSON.serialize(invoices));
        Http http = new Http();
        HttpResponse res = http.send(req);
        if (res.getStatusCode() >= 500) {
            // Force finalizer retry path
            throw new CalloutException('Remote system unavailable: ' + res.getStatus());
        }
        for (Invoice__c inv : invoices) {
            inv.Status__c = 'Sent';
        }
        update invoices;
    }
    public class InvoicePushFinalizer implements Finalizer {
        private Set<Id> invoiceIds;
        private Integer attempt;
        public InvoicePushFinalizer(Set<Id> invoiceIds, Integer attempt) {
            this.invoiceIds = invoiceIds == null ? new Set<Id>() : invoiceIds.clone();
            this.attempt = attempt;
        }
        public void execute(FinalizerContext fc) {
            if (fc.getResult() == ParentJobResult.UNHANDLED_EXCEPTION && attempt < 5) {
                AsyncOptions opts = new AsyncOptions();
                opts.MaximumQueueableStackDepth = 5;
                opts.MinimumQueueableDelayInMinutes = Math.min(attempt * 2, 10);
                System.enqueueJob(new InvoicePushJob(invoiceIds, attempt + 1), opts);
            }
        }
    }
}

The architecture point is more important than the syntax: retries here are bounded, deduplicated at submit time, and protected by an idempotency key at the integration boundary. Without all three, retries can turn into duplicates or infinite async churn. The finalizer is also the right place to emit a platform event or persist a diagnostic record after repeated failure.

Platform event subscriber with checkpoint-and-retry behavior

Platform-event subscribers need two layers of resilience: per-message progress preservation and transient replay. Salesforce’s trigger context supports both. Use a resume checkpoint after each successfully processed message, and throw EventBus.RetryableException only when you want the whole batch retried from the last checkpoint.

trigger OrderEventSubscriber on Order_Event__e (after insert) {
    EventBus.TriggerContext ctx = EventBus.TriggerContext.currentContext();
    for (Order_Event__e evt : Trigger.New) {
        try {
            // Process a single message atomically.
            Order__c orderToUpdate = [
                SELECT Id, Status__c
                FROM Order__c
                WHERE External_Order_Id__c = :evt.External_Order_Id__c
                LIMIT 1
            ];
            orderToUpdate.Status__c = evt.New_Status__c;
            update orderToUpdate;
            // Mark this message as safely processed.
            ctx.setResumeCheckpoint(evt.ReplayId);
        } catch (DmlException permanentProblem) {
            // Log and continue, or publish to a DLQ-style custom object/event.
            // Do NOT retry if the message itself is invalid.
        } catch (Exception transientProblem) {
            // Causes a retry of the trigger invocation from the last checkpoint.
            throw new EventBus.RetryableException('Temporary failure: ' + transientProblem.getMessage());
        }
    }
}

This is a classic place where many teams misuse retries. If the exception is permanent—for example, bad payload semantics or a missing required related record—retrying nine times only delays failure and burns delivery capacity. Reserve RetryableException for truly transient conditions such as lock contention or temporary integration unavailability.

Continuation with @AuraEnabled and proper cacheability

Salesforce’s Continuation rules are strict: the method returning the Continuation must use @AuraEnabled(continuation=true), and if you want the result cached or used by @wire, the callback and, as best practice, all methods in the chain should also be marked cacheable=true. There must be a space, not a comma, between continuation=true and cacheable=true.

public with sharing class InventoryContinuationController {
    @AuraEnabled(continuation=true cacheable=true)
    public static Continuation startRequest(String sku) {
        Continuation con = new Continuation(40);
        con.continuationMethod = 'processResponse';
        con.state = sku;
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('callout:Inventory_API/stock?sku=' + EncodingUtil.urlEncode(sku, 'UTF-8'));
        con.addHttpRequest(req);
        return con;
    }
    @AuraEnabled(cacheable=true)
    public static InventoryResponse processResponse(List<String> labels, Object state) {
        HttpResponse res = Continuation.getResponse(labels[0]);
        if (res.getStatusCode() != 200) {
            throw new AuraHandledException('Inventory service failed: ' + res.getStatus());
        }
        return (InventoryResponse) JSON.deserialize(res.getBody(), InventoryResponse.class);
    }
    public class InventoryResponse {
        @AuraEnabled public String sku;
        @AuraEnabled public Integer available;
    }
}

The non-obvious behavior to remember is that wired and imperative calls can both interact with the client cache. If a user clicks a button expecting a “fresh” imperative call but the method is cacheable and the framework already has a value, they can see cached data unless you explicitly refresh the right caches.

Mixed DML mitigation with Queueable

A common production-safe pattern is to commit ordinary business data in the original transaction and then isolate setup-object DML in a second transaction. This is the cleanest general-purpose replacement for many legacy @future mixed-DML workarounds because Queueable provides monitoring and richer orchestration.

public with sharing class UserProvisioningService {
    public static void provisionContactAndAssignments(Contact c, Id permissionSetId) {
        insert c; // non-setup object
        System.enqueueJob(new AssignPermissionSetJob(
            new Set<Id>{ c.OwnerId }, // or target user IDs
            permissionSetId
        ));
    }
    public class AssignPermissionSetJob implements Queueable {
        private Set<Id> userIds;
        private Id permissionSetId;
        public AssignPermissionSetJob(Set<Id> userIds, Id permissionSetId) {
            this.userIds = userIds;
            this.permissionSetId = permissionSetId;
        }
        public void execute(QueueableContext qc) {
            List<PermissionSetAssignment> psa = new List<PermissionSetAssignment>();
            for (Id userId : userIds) {
                psa.add(new PermissionSetAssignment(
                    AssigneeId = userId,
                    PermissionSetId = permissionSetId
                ));
            }
            insert psa; // setup object in a separate transaction
        }
    }
}

The tradeoff is deliberate eventual consistency. If the queueable fails, the Contact can still exist while the setup assignment does not. The correct mitigation is observability plus replay, not pretending the operation is still atomic.

Edge-case reproductions that senior developers should know

The first reproduction shows the Queueable snapshot behavior.

public class SnapshotQueueable implements Queueable {
    public Integer counter;
    public SnapshotQueueable(Integer counter) { this.counter = counter; }
    public void execute(QueueableContext qc) {
        System.debug('Counter seen by queueable = ' + counter);
    }
}
SnapshotQueueable q = new SnapshotQueueable(0);
System.enqueueJob(q);
q.counter = 10; // The queued job still sees 0.

That behavior is well explained by the community’s analysis of Queueable serialization timing and is consistent with how queued work must be persisted for later execution. Finalizers are the contrast case: their state is taken at the end of Queueable execution, which is why they are so useful for progressive logging and retry metadata.

The second reproduction is the “don’t overload @AuraEnabled methods” trap.

public with sharing class BadController {
    @AuraEnabled(cacheable=true)
    public static String find(String value) { return 'string'; }
    @AuraEnabled(cacheable=true)
    public static String find(Id value) { return 'id'; }
}

Salesforce’s LWC docs explicitly warn that overloaded @AuraEnabled methods are selected non-deterministically. This is the sort of bug that can “work” for months and then fail after metadata or runtime changes.

The third reproduction is a test-only trap: Queueable chaining in tests.

@IsTest
static void queueableTestPattern() {
    Test.startTest();
    System.enqueueJob(new FirstJobThatWouldNormallyChain());
    Test.stopTest();
    // Assert outcome of the first job.
    // Test the second job independently, or emulate the chaining path directly.
}

Salesforce’s queueable docs and long-standing community guidance note that you can’t freely chain queueables in an Apex test the same way you do in production. The robust testing pattern is to test each stage independently and guard the chain path when necessary.

Testing, observability, and performance tuning

Testing async Apex is easier when you separate three concerns: the business logic, the asynchronous wrapper, and the platform lifecycle hooks. Salesforce documents that asynchronous processes started after Test.startTest() are executed synchronously after Test.stopTest(). That rule covers futures, queueables, batches, and scheduled jobs. For platform events, use the test event bus broker (Test.getEventBus() / Test.EventBus.deliver()) to deliver messages and test retried delivery behavior. For Batch, remember that a test context has its own reduced queue/flex-queue behavior.

For Queueable tests specifically, the most reliable strategy is not “prove Salesforce chained correctly” but “prove each unit of work behaves correctly under the conditions it will see.” That often means invoking downstream classes directly or designing chain stages as discrete, independently testable components. Similarly, for Continuations, use tests to validate callback parsing and error handling, while treating the Continuation transport itself as platform behavior.

Observability differs by primitive. Queueable, Future, Batch, and Scheduled jobs are observable through Apex Jobs / AsyncApexJob. Batch holding state can also be observed via FlexQueueItem and manipulated through the FlexQueue class. Platform event usage can be monitored via PlatformEventUsageMetric, while subscriber operational state appears in EventBusSubscriber and logs often surface under Automated Process. There is also a dedicated event log type for Continuation callouts. If you expose job data through custom UIs, remember that access to some setup and queue objects requires View Setup and Configuration outside true system-context execution.

Performance tuning principles that actually move the needle

The architect guidance for Batch is unusually practical: use larger, meaningful scopes rather than many tiny, fast jobs; schedule huge workloads off-peak; avoid flooding the flex queue from hot trigger paths; and remember that only five batch threads can run at once. For many record-processing problems that are “too large for a single Queueable but not really batch-scale operations,” Salesforce’s newer guidance also points to alternative patterns, including combining Queueable and Batch strategies or, in newer releases, considering cursors for some large-query use cases.

At the trigger architecture level, high-volume systems should prefer CDC or events over “enqueue one queueable per trigger invocation” whenever business requirements allow it. The reason is not elegance; it is survival. CDC provides durable replay, natural fan-out, and failure isolation from the original save transaction. Queueable-from-trigger remains viable, but only with hard safeguards around enqueues-per-context, deduplication, and org-level async consumption.

A final performance point that is widely missed in Lightning work: cacheable=true is not only a correctness annotation. It is a performance contract. Wired Apex requires it; imperative Apex can benefit from it; but once you mutate data through imperative Apex, you must coordinate UI cache refreshes through refreshApex() for wired values or notifyRecordUpdateAvailable() for Lightning Data Service record caches. Otherwise, your backend may be correct while your UI looks wrong.

Advanced interview questions with answers

The questions below emphasize architecture, not trivia. Items marked Tricky are the ones that frequently expose sloppy mental models.

QuestionAnswerWhy it mattersSources
Tricky: Why does an imperative @AuraEnabled call in LWC feel asynchronous but often not count as “Async Apex”?Because the browser call is asynchronous from JavaScript’s perspective, but the Apex method still runs as a normal server transaction unless it explicitly uses Continuation or enqueues Queueable/Future/Batch work.It separates client async from server async and prevents bad limit assumptions.
Tricky: Why can a Queueable see “old” constructor state even if you mutate the object after System.enqueueJob()?Because the Queueable payload is serialized at enqueue time; the queued job executes from that persisted snapshot, not the later in-memory mutation.Explains stale-state bugs and why post-enqueue mutation is ineffective.
What survives between Batch scopes with Database.Stateful?Only instance member variables. Static variables do not retain values between transactions.Distinguishes safe aggregate counters from broken static flags.
Tricky: Why can a trigger-fired Queueable work in UI testing but fail during a Batch load?Because a synchronous transaction can enqueue up to 50 Queueables, while an asynchronous transaction can enqueue only 1. A trigger invoked from Batch is running in async context.This is one of the most common real-world production failure modes.
Why is @future usually a poorer orchestration primitive than Queueable?Future methods allow only primitive params, don’t guarantee execution order, and don’t provide job IDs, chaining, finalizers, or dedupe signatures.Shows why Salesforce recommends Queueable for most new async code.
Tricky: Why is replay ID a bad universal dedupe key for platform events?Replay IDs are opaque stream positions, not guaranteed contiguous, and under maintenance they are not guaranteed to remain unique enough for universal application-level identity. Use event IDs / UUIDs for identity.Prevents subtle duplicate-processing and replay bugs.
How do you get the final success or failure of EventBus.publish() in Apex?Use Apex publish callbacks with EventBus.EventPublishSuccessCallback and/or EventBus.EventPublishFailureCallback. The immediate SaveResult only confirms enqueueing for publication, not final publication success.Essential for resilient event publishing.
Tricky: Why can platform-event systems still create duplicates even if the publisher “succeeded once”?Because asynchronous platform-event publishing follows an at-least-once delivery model. Retries and consumer recovery can surface duplicates, so consumers must be idempotent.Event-driven systems fail without this assumption.
How do you write a resilient platform-event trigger when only part of a batch has been processed successfully?Set a resume checkpoint after each successful message, and throw EventBus.RetryableException only for transient failures that justify replay from the last checkpoint.Demonstrates real subscriber design maturity.
Why is Async SOQL not a recommended answer anymore?Because Salesforce retired Async SOQL in Summer ’23 and directs customers toward Bulk API query or Batch Apex.Tests whether the candidate knows current platform direction rather than memorized old features.
Tricky: If a Continuation no longer helps with the old long-running request limit, why still use it?Because it still improves user experience by offloading long-running UI callouts and resuming through a callback transaction without blocking the page request lifecycle in the same way.Distinguishes “limit advantage” from “UX advantage.”
What is the most robust general answer to mixed DML in async architectures?Split setup-object and non-setup-object work across transactions—often with Queueable—then add observability and replay because you traded atomicity for eventual consistency.Shows deeper understanding than simply saying “use future.”

Closing analytical takeaway

If there is one mature way to think about Salesforce async, it is this: choose the primitive whose failure semantics match your business semantics. If you need per-record chunk isolation, choose Batch. If you need a durable event log with fan-out and replay, choose Platform Events or CDC. If you need sequential background work with rich orchestration, choose Queueable plus Finalizer. If you merely need the browser not to block, an imperative @AuraEnabled call may already be enough. Many outages come from using the right code in the wrong semantic container. Salesforce’s official docs and architect guidance, taken together, are remarkably consistent on that point.

Leave a Reply