Top 100 Asynchronous Apex & Batch Apex Interview Questions (2026)
Top 100 Asynchronous Apex & Batch Apex Interview Questions
Future Methods, Queueable Apex, Batch Apex, and Scheduled Apex — the four async tools every real Salesforce Developer interview eventually tests. Every question here includes genuine, working code, not just definitions.
Batch Apex specifically gets the deepest coverage, since it's the tool most interviews probe hardest — the three-method interface, stateful batches, chaining, and the governor limit nuances that trip up even experienced developers.
A Future method runs Apex code asynchronously in its own separate thread, outside the current transaction's limits. You use it specifically for callouts from a trigger context (since triggers cannot make callouts directly) or for lower-priority work that does not need to block the user's current operation.
public class CalloutHelper {
@future(callout=true)
public static void notifyExternalSystem(String accountId) {
// Callout logic here
}
}
An Account trigger needed to notify an external ERP system whenever a new Account was created, but triggers cannot make callouts directly. We wrapped the callout in a @future(callout=true) method called from the trigger handler.
"A Future method runs Apex asynchronously, mainly used to make callouts from a trigger context where direct callouts aren't allowed."
Use the @future annotation directly above a method declaration. The method must be static and must return void.
public class MyAsyncClass {
@future
public static void doAsyncWork(Set<Id> recordIds) {
// logic here
}
}
When building XYZ Company's lead enrichment process, we declared a static void method annotated with @future, taking a Set of Lead Ids as the only parameter.
"@future annotation above a static void method — that's the complete required syntax."
Future methods execute in a completely separate thread with no access to the calling instance's state. Since there is no actual object instance carried over into that new thread, the method cannot be an instance method — it must be static.
A developer on our team initially tried writing a non-static Future method and got a compile error immediately, since the platform has no way to serialize and pass instance state into the new asynchronous thread.
"No instance state carries into the new thread, so the method must be static — there's no object instance to call it on."
Future method calls are serialized and queued for asynchronous execution, and at the time they execute, the original data context may have changed or no longer exist in memory. Primitives (Id, String, Integer, Boolean, and collections of these) serialize cleanly; complex sObjects do not serialize reliably across that boundary.
@future
public static void processRecords(Set<Id> recordIds) {
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :recordIds];
// re-query fresh data instead of passing sObjects directly
}
Instead of passing Account records directly into a Future method, we passed only the Account Ids and re-queried fresh data inside the method — ensuring we always worked with current, accurate data.
"Future methods only accept primitives because sObjects can't serialize reliably across the async execution boundary — pass Ids, then re-query."
No. Future methods cannot call other Future methods. Attempting this throws a runtime exception, since Salesforce does not allow nesting Future calls within an already-asynchronous Future context.
A developer tried chaining two Future methods together for a multi-step external sync process at XYZ Company, and it failed at runtime — we had to redesign using Queueable Apex instead, which genuinely supports chaining.
"No — Future methods cannot call other Future methods; Queueable Apex is the correct tool when you need chaining."
An org can make up to 250,000 Future method calls (or licenses x a per-license limit, whichever is greater) per 24-hour period — but more practically relevant, only 50 Future method calls are allowed per single Apex transaction.
When XYZ Company's bulk Lead import triggered 500 Future calls in a single transaction, it immediately hit the 50-per-transaction limit — we had to refactor to a single Future call processing a full batch of Ids instead.
"50 Future calls per single transaction is the practical limit that actually matters day to day — bulkify by passing a collection, not one call per record."
Add callout=true as a parameter to the @future annotation. Without this explicit flag, attempting an HTTP callout inside a Future method throws a runtime exception.
@future(callout=true)
public static void sendToExternalAPI(String payload) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.xyzcompany.com/sync');
req.setMethod('POST');
req.setBody(payload);
Http http = new Http();
HttpResponse res = http.send(req);
}
XYZ Company's Future method posting Opportunity data to an external billing system explicitly declared callout=true — omitting it caused an immediate CalloutException during testing.
"Add callout=true to the @future annotation — without it, any HTTP callout attempt inside the method throws an exception."
It explicitly grants the Future method permission to make external HTTP callouts. It's required any time the Future method's logic includes an HttpRequest/Http.send() call — Salesforce requires this explicit declaration as a safety measure.
We initially forgot the callout=true flag on a Future method calling XYZ Company's external tax calculation API, and every execution failed silently in the background until we checked Apex Jobs and found the error.
"callout=true explicitly grants permission for external HTTP calls — required whenever the Future method's body makes a callout."
Not directly in a simple way — Future methods don't expose a Job Id you can query the way Batch or Queueable jobs do. You can only indirectly observe completion through side effects (like checking if a record was updated) or by reviewing the Apex Jobs page in Setup.
When debugging a silent failure in XYZ Company's Future-based notification method, we couldn't query a Job Id directly — we had to rely on Setup's Apex Jobs monitor and debug logs to understand what happened.
"Future methods don't expose a queryable Job Id — Apex Jobs in Setup and debug logs are your main visibility tools."
Batch Apex execute() already runs asynchronously. Salesforce does not allow Future methods to be called from an already-asynchronous context — attempting this throws a runtime exception.
A developer tried calling a Future method for callouts inside XYZ Company's nightly Batch Apex job's execute() method, which failed immediately — we moved the callout logic to use HTTP calls directly within the batch context instead, since Batch Apex itself supports callouts when implementing the right interface.
"You can't call a Future method from inside Batch Apex's execute() — it's already asynchronous, and Future can't nest inside another async context."
The exception is logged, but since the Future method runs asynchronously and disconnected from the original transaction, the calling code has no way to catch it directly. The failure typically shows up only in the Apex Jobs page or via debug logs, often going unnoticed without proper error handling and logging built into the method itself.
@future
public static void processAsync(Set<Id> ids) {
try {
// logic here
} catch (Exception e) {
// log to a custom Error_Log__c object for visibility
insert new Error_Log__c(Message__c = e.getMessage());
}
}
After a silent Future method failure went unnoticed for two days at XYZ Company, we added a try-catch block that logs failures to a custom Error_Log__c object, giving the team genuine visibility into async failures.
"Unhandled exceptions in Future methods fail silently from the caller's perspective — always build in explicit error logging."
Yes, this is one of the most common Future method use cases. The key consideration is that the trigger should pass only Ids (not full sObjects) and the Future method should re-query fresh data, since trigger context records can't be passed directly as Future parameters.
trigger AccountTrigger on Account (after insert) {
Set<Id> accountIds = new Map<Id, Account>(Trigger.new).keySet();
AccountAsyncHandler.notifyExternalSystem(accountIds);
}
XYZ Company's Account trigger collects newly inserted Account Ids and passes only the Id set to a Future method, which then re-queries the Accounts fresh before making its external callout.
"Yes — triggers commonly call Future methods, but always pass only Ids, then re-query fresh data inside the Future method itself."
Future method execution order is not guaranteed. Salesforce queues them for asynchronous processing, but does not promise they'll run in the exact sequence they were called — this matters significantly if your logic has any dependency on ordering.
XYZ Company initially assumed two Future calls (one updating a record, one sending a notification about that update) would run in the order called — they didn't always, causing notifications referencing stale data. We redesigned to combine both into one Future call.
"Future method execution order is never guaranteed — never design logic that depends on one Future call finishing before another starts."
Future methods don't execute synchronously during a test unless you explicitly wrap the calling code between Test.startTest() and Test.stopTest() — Test.stopTest() forces all queued asynchronous Apex (Future, Queueable, Batch) to run before the test continues.
@isTest
static void testFutureMethod() {
Test.startTest();
MyAsyncClass.doAsyncWork(new Set<Id>{testAccountId});
Test.stopTest(); // forces Future method to execute here
Account updated = [SELECT Status__c FROM Account WHERE Id = :testAccountId];
System.assertEquals('Processed', updated.Status__c);
}
Our test for XYZ Company's Future-based status updater initially failed because we forgot Test.stopTest() — the assertion ran before the Future method ever actually executed.
"Always wrap Future method calls with Test.startTest()/Test.stopTest() in tests — stopTest() is what actually forces the async code to run."
Future methods are simpler but more limited — they only accept primitive parameters, can't be chained, and offer no job monitoring. Queueable Apex accepts complex object parameters (including sObjects), supports genuine job chaining, and exposes a Job Id you can monitor via AsyncApexJob.
When XYZ Company needed to chain three sequential async steps for a data migration process, Future methods genuinely couldn't do it — we used Queueable Apex specifically because chaining was a hard requirement.
"Queueable supports complex parameters, chaining, and job monitoring; Future methods are simpler but more limited on all three fronts."
Yes, as long as the List contains primitive types (like List<String> or List<Id>) or collections of primitives. You cannot pass a List of sObjects directly.
@future
public static void bulkProcess(List<String> recordIdStrings) {
// process the list of Id strings
}
XYZ Company's bulk discount-approval Future method accepts a List<Id> of Opportunity Ids, allowing one Future call to handle an entire batch of records instead of one call per record.
"Yes, but only Lists of primitives — never a List of sObjects directly as a Future method parameter."
If the org exceeds its daily limit for asynchronous Apex executions, additional Future method calls will fail to be queued, typically throwing a LimitException at the point the @future call is made.
During a high-volume month-end process, XYZ Company hit the org's daily async limit, causing new Future calls to fail outright — we had to redesign the process to use Batch Apex, which handles large volumes more efficiently within the same governor framework.
"Hitting the daily async Apex limit causes new Future calls to throw a LimitException — high-volume scenarios usually call for Batch Apex instead."
Avoid Future methods when you need: genuine job chaining (use Queueable instead), processing very large data volumes with governor-limit resets per chunk (use Batch Apex instead), or precise scheduled timing (use Scheduled Apex instead). Future is best reserved for simple, fire-and-forget async tasks like a single callout.
XYZ Company initially tried using a Future method to process 50,000 records nightly, hit governor limits immediately, and migrated to Batch Apex, which is specifically designed to reset limits across smaller chunks.
"Avoid Future methods for chaining, large data volumes, or precise scheduling — each of those has a genuinely better-suited async tool."
Queueable Apex is a more powerful asynchronous tool that supports complex object parameters, genuine job chaining, and job monitoring via a Job Id — capabilities Future methods genuinely lack.
public class ProcessOpportunities implements Queueable {
public void execute(QueueableContext context) {
// async logic here
}
}
XYZ Company replaced an aging Future-based discount notification system with Queueable Apex specifically to gain job chaining and proper monitoring, which the old Future approach never supported.
"Queueable supports complex parameters, chaining, and monitoring — genuinely more capable than Future methods."
The class must implement the Queueable interface, which requires defining a single execute method.
public class MyQueueableJob implements Queueable {
public void execute(QueueableContext context) {
// logic
}
}
Every async job in XYZ Company's order processing pipeline implements Queueable as the standard pattern across the team.
"Implement the Queueable interface and define its single required execute method."
public void execute(QueueableContext context) — it must be public, return void, and accept a single QueueableContext parameter.
public void execute(QueueableContext context) {
// your async logic
}
A code review at XYZ Company caught a developer who'd accidentally omitted the QueueableContext parameter, causing an immediate compile error before it ever reached production.
"public void execute(QueueableContext context) — exact required signature, no variations."
Instantiate the class and pass it to System.enqueueJob(), which returns the Job Id as a String.
ProcessOpportunities job = new ProcessOpportunities();
Id jobId = System.enqueueJob(job);
XYZ Company's Opportunity trigger enqueues a Queueable job and stores the returned Job Id in a custom field for later status tracking.
"System.enqueueJob(new YourClass()) — returns a Job Id you can use for tracking."
Chaining means calling System.enqueueJob() again from inside the execute method of a currently-running Queueable job, effectively starting a new job once the current one finishes — enabling genuine sequential multi-step async processing.
public class StepOneJob implements Queueable {
public void execute(QueueableContext context) {
// do step one work
System.enqueueJob(new StepTwoJob()); // chain to next job
}
}
XYZ Company's three-step data migration (validate, transform, load) uses chained Queueable jobs, where each step enqueues the next only after successfully completing its own work.
"Chaining means enqueueing a new Queueable job from inside another job's execute method — enabling genuine sequential async steps."
In most orgs, you can chain up to 5 jobs deep in a single chain sequence. (Unlimited chaining is available in specific contexts, but the standard practical limit to know for interviews is 5.)
XYZ Company's original 7-step chained migration design had to be consolidated into fewer, more substantial steps after hitting the practical chain depth consideration during testing.
"5 chained jobs deep is the standard practical limit most interviews expect you to know."
Yes — since Queueable is implemented as a class (not a static method), you can pass any data, including full sObjects and custom objects, directly into the constructor. This avoids the re-query step Future methods require.
public class ProcessAccounts implements Queueable {
private List<Account> accountsToProcess;
public ProcessAccounts(List<Account> accounts) {
this.accountsToProcess = accounts;
}
public void execute(QueueableContext context) {
// use accountsToProcess directly, no re-query needed
}
}
XYZ Company's Queueable job receives full Account records directly through its constructor, eliminating an unnecessary re-query that the old Future-based version required.
"Yes — Queueable's constructor can accept full sObjects directly, unlike Future methods which require re-querying."
Implement Database.AllowsCallouts alongside Queueable on the class declaration.
public class SyncToExternalAPI implements Queueable, Database.AllowsCallouts {
public void execute(QueueableContext context) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.xyzcompany.com/sync');
req.setMethod('POST');
Http http = new Http();
http.send(req);
}
}
XYZ Company's Queueable job syncing Opportunity data to an external system implements both Queueable and Database.AllowsCallouts on the class declaration.
"Implement Database.AllowsCallouts alongside Queueable — without it, any callout attempt throws an exception."
Database.AllowsCallouts — this is added to the class declaration's implements clause, alongside Queueable itself.
A failed callout in XYZ Company's integration job traced back to a missing Database.AllowsCallouts declaration — adding it immediately fixed the issue.
"Database.AllowsCallouts, added alongside Queueable in the class's implements clause."
An org can enqueue up to 50 Queueable jobs per 24-hour period via System.enqueueJob() from synchronous Apex (this is a shared limit with Future method calls in some contexts) — but specifically within a single transaction, the synchronous limit is also a key consideration.
XYZ Company hit unexpected throttling during a high-volume batch trigger that tried enqueueing many individual Queueable jobs — we redesigned to enqueue one job processing a full collection instead.
"There's a meaningful daily limit on enqueued Queueable jobs — always bulkify rather than enqueueing one job per record."
Yes — the Job Id returned by System.enqueueJob() can be used to query the AsyncApexJob object, which exposes fields like Status, JobType, NumberOfErrors, and CompletedDate.
Id jobId = System.enqueueJob(new MyQueueableJob());
AsyncApexJob job = [SELECT Status, NumberOfErrors
FROM AsyncApexJob WHERE Id = :jobId];
XYZ Company's admin dashboard queries AsyncApexJob using stored Job Ids to show real-time status of in-progress data sync jobs to the operations team.
"Yes — query AsyncApexJob using the returned Job Id to check Status, NumberOfErrors, and completion details."
AsyncApexJob — this single object tracks status for Future methods, Queueable jobs, Batch Apex jobs, and Scheduled Apex jobs alike.
XYZ Company built a unified "Async Job Monitor" admin page that queries AsyncApexJob across all four async types in one consolidated view.
"AsyncApexJob — the single object that tracks status across all four async Apex types."
Queueable Apex runs as a single execution with standard governor limits applied once — it's meant for moderate-sized async work. Batch Apex splits processing into multiple smaller chunks, with governor limits resetting for each chunk, making it suited for genuinely large data volumes (millions of records).
XYZ Company used Queueable for a few thousand-record reconciliation job, but switched to Batch Apex entirely for their 2-million-record annual data cleanup, since Queueable's single-execution limits weren't sufficient.
"Queueable suits moderate-sized work in one execution; Batch Apex scales to millions of records by resetting limits per chunk."
Like Future methods, an unhandled exception in a Queueable job's execute method fails silently from the caller's perspective. The failure is visible via AsyncApexJob's NumberOfErrors field or debug logs, but proper try-catch error handling and logging should be built into the execute method itself.
public void execute(QueueableContext context) {
try {
// logic
} catch (Exception e) {
insert new Error_Log__c(Message__c = e.getMessage());
}
}
After a silent Queueable failure went unnoticed at XYZ Company, the team added explicit try-catch logging to every Queueable execute method going forward, as a standard team practice.
"Unhandled exceptions fail silently from the caller's view — check AsyncApexJob.NumberOfErrors or build in explicit logging."
Yes, this is a common pattern — but be mindful of bulk trigger context, ensuring you enqueue ONE Queueable job handling a full collection of records, not one job per individual record.
trigger OpportunityTrigger on Opportunity (after update) {
System.enqueueJob(new ProcessOpportunities(Trigger.new));
}
XYZ Company's Opportunity trigger enqueues a single Queueable job passing the entire Trigger.new collection, correctly handling bulk updates in one job rather than many.
"Yes, but always enqueue one job for the full record collection — never one job per individual record in a trigger."
Multi-step data processing pipelines where each step genuinely depends on the previous step completing successfully — like validate, then transform, then load, in a data migration or integration scenario.
XYZ Company's customer data enrichment pipeline chains three Queueable jobs: first validating incoming data, then calling an external enrichment API, then finally updating the Salesforce records with enriched data.
"Sequential multi-step pipelines (validate → transform → load) are the classic real-world chaining use case."
Wrap the enqueueJob call between Test.startTest() and Test.stopTest() — exactly like Future methods, Test.stopTest() forces the queued job to actually execute before the test continues.
@isTest
static void testQueueableJob() {
Test.startTest();
System.enqueueJob(new ProcessOpportunities(testOpps));
Test.stopTest();
Opportunity updated = [SELECT Status__c FROM Opportunity WHERE Id = :testOppId];
System.assertEquals('Processed', updated.Status__c);
}
XYZ Company's test suite for the Queueable discount processor wraps the enqueue call in Test.startTest()/stopTest(), confirming the job actually ran before asserting on its results.
"Test.startTest()/Test.stopTest() around the enqueueJob call — stopTest() is what forces the job to actually run in tests."
A Finalizer is a special callback that runs after a Queueable job completes, whether it succeeds or fails — giving you a guaranteed way to perform cleanup or follow-up actions regardless of outcome. Finalizers are specifically a Queueable-only feature.
public void execute(QueueableContext context) {
System.attachFinalizer(new MyFinalizer());
// main job logic
}
XYZ Company attaches a Finalizer to their critical billing-sync Queueable job specifically to send an alert notification if the job fails, ensuring failures are never silently missed.
"Finalizers are a Queueable-exclusive feature providing guaranteed post-execution cleanup or follow-up logic, success or failure."
Queueable Apex has less overhead and a simpler single-method interface compared to Batch Apex's three-method structure, making it a better fit when you genuinely don't need Batch's chunk-based processing for smaller, moderate-sized workloads.
XYZ Company's nightly job processing roughly 500 records uses Queueable rather than Batch Apex, since the volume doesn't genuinely require Batch's chunking — simpler code with equivalent results.
"For smaller, moderate workloads, Queueable's simpler single-method structure is genuinely sufficient — Batch Apex's complexity isn't always necessary."
Yes — System.enqueueJob() has an overload that accepts a minimum delay in minutes, allowing you to schedule a Queueable job to run after a specified delay, distinct from full Scheduled Apex's CRON-based recurring scheduling.
// Run after a minimum 10-minute delay
System.enqueueJob(new MyQueueableJob(), 10);
XYZ Company delays a follow-up Queueable reminder job by 30 minutes after an initial customer action, using the delayed enqueueJob overload rather than building full Scheduled Apex for this one-time delay.
"Yes — System.enqueueJob() supports a minimum-delay-in-minutes overload for one-time delayed execution, distinct from recurring Scheduled Apex."
Batch Apex processes large numbers of records (thousands to millions) by splitting them into smaller chunks, with governor limits resetting for each chunk. Use it specifically when a job's data volume would otherwise exceed standard transaction limits in a single execution.
global class CleanupOldRecords implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id FROM Lead WHERE IsConverted = false AND CreatedDate < LAST_N_DAYS:365');
}
global void execute(Database.BatchableContext bc, List<sObject> scope) {
delete scope;
}
global void finish(Database.BatchableContext bc) {
// post-processing
}
}
XYZ Company runs a nightly Batch Apex job cleaning up over 200,000 stale, unconverted Leads older than a year — far beyond what a single synchronous transaction could safely handle.
"Batch Apex processes large data volumes in chunks, resetting governor limits per chunk — use it whenever data volume would exceed single-transaction limits."
Database.Batchable<sObject> — and the class declaration itself must use the global access modifier.
global class MyBatchClass implements Database.Batchable<sObject> {
// three required methods
}
Every Batch Apex class at XYZ Company follows the exact same global class ... implements Database.Batchable<sObject> pattern as a team standard.
"Database.Batchable<sObject> — and the class itself must be declared global."
start, execute, and finish — every Batch Apex class must implement all three, even if one of them (commonly finish) ends up doing very little.
global class MyBatch implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) { }
global void execute(Database.BatchableContext bc, List<sObject> scope) { }
global void finish(Database.BatchableContext bc) { }
}
A junior developer at XYZ Company initially forgot to implement finish(), and the class failed to compile until all three required methods were present.
"start, execute, and finish — all three are mandatory, even if finish does minimal work."
It runs once at the very beginning of the job and defines the full set of records to be processed, typically by returning a Database.QueryLocator (or an Iterable).
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id, Email FROM Contact WHERE Email = null'
);
}
XYZ Company's data quality Batch job's start method defines the full set of Contacts missing an email address, which then gets automatically split into chunks for execute.
"start() runs once, defining the complete record set to process — typically via a QueryLocator."
execute() contains the actual processing logic and is called once per chunk — the number of times depends on total record count divided by batch size.
global void execute(Database.BatchableContext bc, List<sObject> scope) {
List<Contact> contacts = (List<Contact>) scope;
for (Contact c : contacts) {
c.Email_Missing__c = true;
}
update contacts;
}
With 100,000 records and a batch size of 200, XYZ Company's execute method runs 500 separate times, each handling its own 200-record chunk independently.
"execute() is called once per chunk — with default batch size 200, a 100,000-record job calls execute() 500 times."
finish() runs once at the very end, after all chunks have completed execute(). Commonly used for completion emails, chaining a next Batch job, or logging final statistics.
global void finish(Database.BatchableContext bc) {
AsyncApexJob job = [SELECT Id, Status, NumberOfErrors
FROM AsyncApexJob WHERE Id = :bc.getJobId()];
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setSubject('Batch Job Complete: ' + job.Status);
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{mail});
}
XYZ Company's finish() method sends a summary email to the Admin team showing total records processed and any errors, every time the nightly cleanup batch completes.
"finish() runs once after all chunks complete — commonly used for completion emails or chaining the next batch job."
The default batch size is 200 records per chunk. The maximum allowed batch size is 2,000 records per chunk.
XYZ Company's simple field-update batch job uses the maximum 2,000 batch size for efficiency, while a more complex batch involving callouts per record uses a much smaller size to stay within callout limits per transaction.
"Default is 200, maximum is 2,000 — choose based on how much processing each individual chunk needs to do."
Pass the desired batch size as the second parameter to Database.executeBatch().
MyBatchClass batchJob = new MyBatchClass();
Database.executeBatch(batchJob, 500);
XYZ Company's record cleanup batch explicitly sets a batch size of 500, balancing processing speed against the complexity of each chunk's logic.
"Database.executeBatch(yourBatchInstance, customSize) — the second parameter sets your batch size."
Database.QueryLocator handles up to 50 million records via a SOQL query. A custom Iterable is used when the record set requires custom logic beyond a simple query, but is limited to a much smaller record count.
global Iterable<Account> start(Database.BatchableContext bc) {
return new MyCustomAccountIterator();
}
XYZ Company uses QueryLocator for the vast majority of batch jobs, but used a custom Iterable once for a complex scenario requiring records generated from an external API response rather than a direct query.
"QueryLocator handles up to 50 million records via SOQL; custom Iterable is for custom-generated record sets with a much lower ceiling."
When your record set genuinely cannot be defined by a simple SOQL query — for example, records derived from a calculation, an external data source, or complex custom logic a single query can't express.
XYZ Company needed to batch-process a custom-calculated list of "at-risk" Accounts based on logic too complex for a single SOQL WHERE clause, so a custom Iterable built that list programmatically instead.
"Use Iterable when your record set requires custom logic beyond what a single SOQL query can express."
Instantiate the batch class and pass it to Database.executeBatch(), optionally with a custom batch size as the second parameter.
MyBatchClass batchJob = new MyBatchClass();
Id batchJobId = Database.executeBatch(batchJob);
XYZ Company's nightly scheduled job calls Database.executeBatch() to kick off the Lead cleanup batch, storing the returned Job Id for status tracking.
"Database.executeBatch(new YourBatchClass()) — returns a Job Id you can use for tracking."
An org can have up to 5 Batch Apex jobs queued or actively executing at the same time.
XYZ Company hit this limit during a busy migration weekend when too many batch jobs were triggered simultaneously — subsequent Database.executeBatch() calls failed until earlier jobs completed.
"5 queued or active Batch jobs is the org-wide limit at any given moment."
Implementing the Database.Stateful marker interface allows instance member variables to retain their values ACROSS multiple execute() calls — by default, Batch Apex is stateless, meaning variables reset between chunks.
global class CountingBatch implements Database.Batchable<sObject>, Database.Stateful {
global Integer totalProcessed = 0;
global void execute(Database.BatchableContext bc, List<sObject> scope) {
totalProcessed += scope.size();
}
global void finish(Database.BatchableContext bc) {
System.debug('Total processed: ' + totalProcessed);
}
}
XYZ Company's data migration batch uses Database.Stateful to maintain a running count of successfully migrated records across all chunks, reporting the final total in finish().
"Database.Stateful keeps instance variables persistent across chunks — without it, variables reset to their initial value on every execute() call."
Stateless. Instance variables reset to their initial values at the start of every single execute() call unless Database.Stateful is explicitly implemented.
A developer at XYZ Company was confused why a running total kept resetting to zero in every chunk — the class wasn't marked Database.Stateful, so the variable reinitialized every execute() call as expected default behavior.
"Stateless by default — Database.Stateful must be explicitly added to retain variable values across chunks."
Query the AsyncApexJob object using the Job Id returned by Database.executeBatch(), checking fields like Status, JobItemsProcessed, TotalJobItems, and NumberOfErrors.
Id jobId = Database.executeBatch(new MyBatchClass());
AsyncApexJob job = [SELECT Status, JobItemsProcessed, TotalJobItems, NumberOfErrors
FROM AsyncApexJob WHERE Id = :jobId];
XYZ Company's Admin dashboard polls AsyncApexJob using stored Job Ids, showing operations staff real-time progress on long-running nightly batch jobs.
"Query AsyncApexJob with the returned Job Id — Status, JobItemsProcessed, and TotalJobItems give genuine progress visibility."
AsyncApexJob — with Batch-specific fields like TotalJobItems and JobItemsProcessed giving genuine chunk-level progress.
XYZ Company's monitoring tool uses AsyncApexJob's JobItemsProcessed/TotalJobItems ratio to calculate and display a genuine percentage-complete progress bar for active batch jobs.
"AsyncApexJob — with JobItemsProcessed and TotalJobItems giving genuine, calculable progress percentage."
Yes — calling Database.executeBatch() for a second batch class from inside the first batch's finish() method is the standard correct pattern for chaining sequential batch jobs.
global void finish(Database.BatchableContext bc) {
Database.executeBatch(new SecondBatchClass());
}
XYZ Company chains a validation batch into a cleanup batch — finish() on the validation job automatically kicks off the cleanup job for any records flagged as invalid.
"Yes — calling Database.executeBatch() for the next class from inside finish() is the standard batch-chaining pattern."
Most per-transaction governor limits reset with each execute() call — including SOQL query rows (50,000), DML statements (150), and CPU time — since each execute() call is treated as its own discrete transaction.
XYZ Company's batch job processing complex records with multiple related-object lookups relies specifically on this per-chunk limit reset, which would be impossible to achieve in a single massive synchronous transaction.
"Most per-transaction limits (SOQL rows, DML statements, CPU time) reset with every execute() call — each chunk is its own fresh transaction."
Use Database DML methods with the allOrNone parameter set to false (e.g., Database.update(records, false)), which allows individual record failures within a chunk without halting the entire chunk or job.
global void execute(Database.BatchableContext bc, List<sObject> scope) {
List<Database.SaveResult> results = Database.update(scope, false);
for (Database.SaveResult sr : results) {
if (!sr.isSuccess()) {
// log the specific failure
}
}
}
XYZ Company's batch update job uses Database.update(records, false) so that one record failing validation doesn't stop the other 199 records in that chunk from successfully updating.
"Database.update(records, false) — partial success mode lets individual record failures not halt the whole chunk or job."
It's the method that starts a Batch Apex job. It accepts the batch class instance as the first (required) parameter, and an optional second parameter for custom batch size.
Database.executeBatch(Database.Batchable<sObject> instance, Integer batchSize)
XYZ Company always explicitly specifies a batch size as the second parameter, making the team's intent clear in code reviews.
"Database.executeBatch(batchInstance, optionalBatchSize) — starts the job and returns its Job Id."
Yes — by writing a separate Scheduled Apex class (implementing Schedulable) whose execute method calls Database.executeBatch(), then scheduling that class via System.schedule().
global class ScheduledBatchRunner implements Schedulable {
global void execute(SchedulableContext sc) {
Database.executeBatch(new MyBatchClass());
}
}
// System.schedule('Nightly Cleanup', '0 0 2 * * ?', new ScheduledBatchRunner());
XYZ Company's nightly 2 AM cleanup combines Scheduled Apex (for timing) with Batch Apex (for the actual large-scale processing) — exactly this two-class pattern.
"Write a Schedulable class whose execute() calls Database.executeBatch() — Scheduled Apex handles timing, Batch Apex handles processing."
Up to 250,000 batch Apex executions per rolling 24-hour period — distinct from the 5-concurrent-job limit.
XYZ Company's high-volume batch operations have never come close to this daily ceiling — the 5-concurrent-job limit is almost always the more practically relevant constraint.
"250,000 batch executions per day is the generous daily ceiling — the 5-concurrent-job limit is usually the more practical constraint."
Queueable Apex executes as a single transaction — it genuinely cannot handle a million records without hitting SOQL or DML limits. Batch Apex's chunk-based processing with limits resetting per chunk is specifically designed for exactly this scale.
XYZ Company's annual million-record archive cleanup uses Batch Apex specifically because Queueable's single-transaction limits would be hit almost immediately at that scale.
"At million-record scale, Queueable's single-transaction limits are insufficient — Batch Apex's per-chunk limit reset is specifically built for this."
Querying child/related records individually inside a loop within execute() quickly hits the 100-query-per-transaction limit. Always use a single bulk query for all related records across the entire chunk at once, then match them in memory.
global void execute(Database.BatchableContext bc, List<sObject> scope) {
Set<Id> accountIds = new Map<Id, Account>((List<Account>)scope).keySet();
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
}
A junior developer at XYZ Company initially queried related Contacts inside a per-Account loop, hitting SOQL limits at scale — the fix was a single bulk query for the whole chunk.
"Never query inside a loop, even within a single execute() chunk — always bulk-query once for the entire chunk."
Call System.abortJob() with the Job Id, or manually abort it through Setup's Apex Jobs page.
System.abortJob(batchJobId);
When XYZ Company discovered a batch job processing the wrong record set, an Admin immediately aborted it via Setup's Apex Jobs page rather than letting it complete and cause further data issues.
"System.abortJob(jobId) programmatically, or manually through Setup's Apex Jobs page."
Batch Apex alone runs once, immediately when invoked. Scheduled Apex adds recurring, time-based triggering via CRON expressions — combining them means a Schedulable class's execute() calls Database.executeBatch() on a recurring schedule.
XYZ Company's batch cleanup class itself has no built-in recurrence — a separate Scheduled Apex class is what makes it run automatically every night at 2 AM.
"Batch Apex alone runs once when called; Scheduled Apex adds the recurring trigger that calls it automatically on a schedule."
No. Batch Apex execute() is already running asynchronously, and Salesforce doesn't allow Future methods to be called from an already-asynchronous context.
A developer tried calling a Future method for an external callout from inside XYZ Company's batch execute() — it failed immediately. Implementing Database.AllowsCallouts on the batch class was the correct fix instead.
"No — implement Database.AllowsCallouts on the batch class instead if you need callouts from execute()."
That specific chunk fails and its changes roll back, but other chunks that have already completed are NOT automatically rolled back — each execute() call is its own separate transaction.
When one problematic chunk caused an unhandled exception in XYZ Company's batch job, only that specific 200-record chunk failed — the other 499 chunks completed successfully.
"Each chunk is its own independent transaction — one chunk's failure doesn't roll back other already-completed chunks."
Wrap the Database.executeBatch() call between Test.startTest() and Test.stopTest(), which forces the batch job to fully execute synchronously within the test context, then query and assert on the actual resulting data.
@isTest
static void testBatchCleanup() {
insert testLeads;
Test.startTest();
Database.executeBatch(new CleanupOldRecords());
Test.stopTest();
List<Lead> remaining = [SELECT Id FROM Lead WHERE Id IN :testLeads];
System.assertEquals(0, remaining.size(), 'Old leads should be deleted');
}
XYZ Company's batch test suite always wraps executeBatch() in Test.startTest()/stopTest(), then re-queries and asserts on actual data state afterward.
"Test.startTest()/Test.stopTest() around executeBatch() forces full synchronous completion — then query and assert on actual resulting data."
A nightly job that identifies and archives old Closed Lost Opportunities, stale unconverted Leads, or expired temporary records — anything where data volume is large enough that a scheduled flow or simple automation would hit governor limits.
global class ArchiveStaleOpportunities implements Database.Batchable<sObject> {
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator(
'SELECT Id FROM Opportunity WHERE StageName = \'Closed Lost\' AND LastModifiedDate < LAST_N_DAYS:730'
);
}
global void execute(Database.BatchableContext bc, List<sObject> scope) {
for (Opportunity o : (List<Opportunity>) scope) {
o.Archived__c = true;
}
update (List<Opportunity>) scope;
}
global void finish(Database.BatchableContext bc) { }
}
XYZ Company runs this exact pattern nightly, archiving Closed Lost Opportunities older than two years, keeping the active pipeline clean without permanently deleting historical data.
"Archiving stale records at large volume (old Leads, Closed Lost Opps, expired temp data) is the classic real-world Batch Apex use case."
Scheduled Apex runs Apex code automatically at specified recurring times, similar to a cron job. It requires implementing the Schedulable interface.
global class NightlyCleanup implements Schedulable {
global void execute(SchedulableContext sc) {
// logic to run on schedule
}
}
XYZ Company's nightly data quality check implements Schedulable, running automatically every night without any manual trigger needed.
"Scheduled Apex automates recurring execution by implementing the Schedulable interface."
global void execute(SchedulableContext sc) — public/global, returns void, accepts a single SchedulableContext parameter.
global void execute(SchedulableContext sc) {
// scheduled logic here
}
Every Scheduled Apex class at XYZ Company follows this exact signature as a team standard.
"global void execute(SchedulableContext sc) — exact required signature."
Call System.schedule(), passing a job name, a CRON expression string, and an instance of your Schedulable class.
String cronExpression = '0 0 2 * * ?'; // every day at 2 AM
System.schedule('Nightly Cleanup Job', cronExpression, new NightlyCleanup());
XYZ Company's Admin scheduled the nightly cleanup job to run at 2 AM using System.schedule(), executed once from Developer Console to set up the recurring job.
"System.schedule(jobName, cronExpression, schedulableInstance) — sets up the recurring execution."
A CRON expression defines a recurring schedule. Salesforce uses a 7-field format: Seconds Minutes Hours Day_of_month Month Day_of_week Optional_year.
'0 0 2 * * ?' // every day at 2:00 AM
'0 30 9 ? * MON-FRI' // 9:30 AM, weekdays only
'0 0 0 1 * ?' // midnight on the 1st of every month
XYZ Company uses '0 0 2 * * ?' for nightly batch kickoffs and '0 30 9 ? * MON-FRI' for weekday-only morning reports.
"A 7-field CRON expression (Seconds Minutes Hours Day Month DayOfWeek Year) defines exactly when the job runs."
An org can have up to 100 scheduled Apex jobs active at any given time.
XYZ Company consolidated several narrowly-scoped scheduled jobs into fewer, more comprehensive ones after approaching this limit during a period of rapid automation growth.
"100 active scheduled jobs is the typical org-wide ceiling."
Yes — this is the standard pattern for running Batch Apex on a recurring schedule. The Schedulable class's execute method simply calls Database.executeBatch().
global class ScheduledBatchTrigger implements Schedulable {
global void execute(SchedulableContext sc) {
Database.executeBatch(new CleanupOldRecords(), 200);
}
}
XYZ Company's nightly Lead cleanup combines this exact pattern — a Schedulable class scheduled for 2 AM that calls Database.executeBatch() for the actual cleanup logic.
"Yes — the Schedulable execute() method calling Database.executeBatch() is the standard pattern for scheduling recurring Batch jobs."
Navigate to Setup → Scheduled Jobs, or query the CronTrigger object directly via SOQL/Apex.
List<CronTrigger> jobs = [SELECT Id, CronJobDetail.Name, NextFireTime
FROM CronTrigger];
XYZ Company's Admin regularly checks Setup's Scheduled Jobs page to confirm the nightly cleanup job's NextFireTime is set correctly after any CRON expression changes.
"Setup → Scheduled Jobs in the UI, or query the CronTrigger object directly in Apex/SOQL."
Call System.abortJob() with the Job Id (obtainable from CronTrigger), or manually abort it from Setup's Scheduled Jobs page.
System.abortJob(scheduledJobId);
When XYZ Company needed to pause the nightly cleanup during a data migration weekend, an Admin aborted the scheduled job via Setup, then rescheduled it once migration completed.
"System.abortJob(jobId) programmatically, or manually through Setup's Scheduled Jobs page."
Yes — Setup includes an 'Apex Classes' page with a 'Schedule Apex' button, letting an Admin configure the schedule through a simpler UI without writing System.schedule() code directly.
XYZ Company's Admin team schedules most recurring jobs through the UI directly, reserving System.schedule() code for cases needing more precise or unusual CRON timing.
"Yes — Setup's Apex Classes page has a UI-based Schedule Apex option, no code required for standard scheduling needs."
Scheduled Apex cannot run more frequently than once every hour through standard System.schedule() patterns — true sub-hourly precision isn't genuinely supported by standard Scheduled Apex.
XYZ Company initially wanted a 15-minute recurring sync job, but Scheduled Apex's practical granularity didn't support that — they redesigned using a Queueable self-rescheduling chain pattern instead.
"Standard Scheduled Apex doesn't support sub-hourly precision — finer-grained recurring needs typically require a different pattern."
The job execution fails for that occurrence, but the job itself remains scheduled and will attempt to run again at its next scheduled time, unless manually aborted.
When XYZ Company's scheduled job hit an unexpected null reference one night, that single occurrence failed, but the job correctly attempted and succeeded at its next scheduled run the following night.
"A failed execution doesn't unschedule the job — it remains active and will attempt again at its next scheduled time."
Inside the execute method, call System.schedule() again for the same class with a new future CRON time — effectively having the job schedule its own next occurrence.
global class SelfReschedulingJob implements Schedulable {
global void execute(SchedulableContext sc) {
// do the actual work
Datetime nextRun = Datetime.now().addMinutes(15);
String cron = nextRun.second() + ' ' + nextRun.minute() + ' ' + nextRun.hour()
+ ' ' + nextRun.day() + ' ' + nextRun.month() + ' ? ' + nextRun.year();
System.schedule('NextRun-' + nextRun, cron, new SelfReschedulingJob());
}
}
XYZ Company achieves genuine 15-minute interval processing using this self-rescheduling pattern, working around standard Scheduled Apex's hourly-minimum limitation.
"Self-rescheduling: call System.schedule() again from inside execute() with a new dynamically-calculated future time."
Scheduled Apex provides the TIMING; Batch Apex provides the SCALE. They're complementary — Scheduled Apex's execute() typically just calls Database.executeBatch() to kick off the actual large-scale processing.
XYZ Company's nightly multi-million-record archive job uses Scheduled Apex for the 2 AM trigger timing, and Batch Apex for the actual chunked processing of millions of records.
"Scheduled Apex handles timing; Batch Apex handles scale — they're typically combined, not alternatives to each other."
No — Scheduled Apex's execute() cannot make synchronous callouts directly. The standard workaround is having execute() enqueue a Queueable job (with Database.AllowsCallouts) to perform the actual callout.
global class ScheduledSync implements Schedulable {
global void execute(SchedulableContext sc) {
System.enqueueJob(new CalloutQueueable());
}
}
global class CalloutQueueable implements Queueable, Database.AllowsCallouts {
global void execute(QueueableContext context) {
// actual callout logic here
}
}
XYZ Company's nightly external API sync uses a Scheduled Apex class that enqueues a Queueable job to perform the actual HTTP callout.
"No, not directly — enqueue a Queueable job (with Database.AllowsCallouts) to actually perform any callout."
Use Test.startTest()/Test.stopTest() around the System.schedule() call, which forces the scheduled execution to run within the test context.
@isTest
static void testScheduledJob() {
Test.startTest();
System.schedule('Test Job', '0 0 2 * * ?', new NightlyCleanup());
Test.stopTest();
// assert on expected results
}
XYZ Company's test for the nightly cleanup scheduled job wraps the System.schedule() call in Test.startTest()/stopTest(), confirming the execute() logic ran and produced correct results.
"Test.startTest()/Test.stopTest() around System.schedule() forces the scheduled execute() to actually run within the test."
System.schedule() itself returns the Job Id directly as its return value (a String representing the CronTrigger Id).
String jobId = System.schedule('My Job', '0 0 2 * * ?', new MyScheduledClass());
XYZ Company stores the returned Job Id in a Custom Setting whenever scheduling a new recurring job, making it easy to programmatically reference or abort that job later.
"System.schedule() returns the Job Id directly — capture it for later reference or abort calls."
A nightly data quality check that flags incomplete records, a weekly summary report emailed to leadership, or a recurring sync job keeping Salesforce aligned with an external system on a predictable cadence.
XYZ Company's Scheduled Apex job runs every Monday at 6 AM, generating and emailing a weekly pipeline summary report to the VP of Sales before the team's Monday morning meeting.
"Recurring data quality checks, scheduled reports, or predictable external sync jobs are classic real-world Scheduled Apex use cases."
Genuinely rarely in new code — Queueable does everything Future does, plus more. Future mainly persists in legacy codebases or for extremely lightweight fire-and-forget tasks.
XYZ Company's newer codebase exclusively uses Queueable for all new async work, only maintaining a few legacy Future methods inherited from before the team standardized on Queueable.
"Rarely in new code — Queueable is generally the more capable choice; Future mainly persists in legacy code."
When data volume is moderate and you need chaining or complex object parameters, but don't genuinely need Batch's chunk-based limit resets for massive-scale processing.
XYZ Company's multi-step but moderate-volume order validation pipeline uses chained Queueable jobs rather than Batch Apex, since chaining was the genuine requirement, not raw scale.
"Choose Queueable when you need chaining or complex parameters at moderate scale, without needing Batch's chunk-based processing."
When data volume is large enough that a single transaction's governor limits would be exceeded — Batch Apex's per-chunk limit reset is specifically designed for million-record-scale processing.
XYZ Company's 3-million-record annual archive job uses Batch Apex because no other async tool could process that volume without hitting governor limits.
"Choose Batch Apex when data volume would exceed what a single transaction can safely handle."
Most orgs share a combined daily limit of 250,000 asynchronous Apex method executions across Future, Queueable, and Batch executions combined.
XYZ Company monitors their combined daily async execution count via a custom dashboard, ensuring high-volume days don't risk hitting this shared ceiling across all async types combined.
"250,000 combined daily async executions is shared across Future, Queueable, and Batch — not separate limits per type."
AsyncApexJob is the single object Salesforce uses to track all four async Apex types. Key fields: Id, Status, JobType, MethodName, NumberOfErrors, JobItemsProcessed, TotalJobItems, CreatedDate, CompletedDate.
AsyncApexJob job = [SELECT Status, JobType, NumberOfErrors,
JobItemsProcessed, TotalJobItems
FROM AsyncApexJob WHERE Id = :jobId];
XYZ Company's unified "Async Job Monitor" admin tool queries AsyncApexJob across all job types, giving operations staff one consolidated view.
"AsyncApexJob — Status, JobType, NumberOfErrors, JobItemsProcessed, and TotalJobItems are the key fields for monitoring."
None are truly real-time — all queue for async execution with some delay. Future and Queueable tend to execute soonest; Batch has more inherent delay due to chunking overhead; Scheduled Apex executes at its defined CRON time, not immediately upon setup.
XYZ Company learned not to rely on any async Apex type for sub-second processing — for that, they redesigned to use a synchronous, carefully-bulkified approach instead.
"None are truly real-time — choose based on data volume and chaining needs, not assumed immediacy."
Asynchronous Apex contexts generally get higher limits than synchronous contexts — with the genuine advantage that Batch Apex's chunking resets limits per execute() call, effectively enabling unlimited total rows processed across the full job.
XYZ Company moved a SOQL-heavy reporting process from a synchronous Visualforce controller into a Queueable job specifically to take advantage of async Apex's more generous limit allowances.
"Async contexts provide more generous governor limits, especially when combined with Batch's per-chunk resets."
Yes, with constraints — you can enqueue a Queueable job and call a Future method in the same transaction, but a Future method cannot itself call another Future method, and async methods generally cannot be called from within an already-asynchronous context.
XYZ Company's Account trigger both calls a Future method for a quick callout AND enqueues a separate Queueable job for complex follow-up processing, both within the same trigger transaction — valid since neither is calling the other from an async context.
"Yes — the key constraint is never calling one async type from already inside another async execution context."
Salesforce does not allow callouts after a DML operation within the same synchronous transaction. Running the callout asynchronously separates it into its own transaction, avoiding this restriction entirely.
XYZ Company's Account trigger needed to insert a record AND make a callout — attempting both synchronously threw a DML-then-callout exception, which a Future method wrapping just the callout resolved cleanly.
"Synchronous transactions block callouts after a DML operation — async execution sidesteps this restriction."
Both share the same 50,000-row SOQL limit per transaction — but Batch Apex's chunking means this 50,000-row limit resets with every execute() call, allowing effectively unlimited total rows processed across the full job.
XYZ Company's batch job never approaches the 50,000-row limit within any single execute() call, even though the job processes millions of Accounts in total across all its chunks combined.
"The 50,000-row limit is the same per-transaction in both, but Batch Apex's per-chunk reset means the effective total across a full job is far higher."
Platform Events are a separate, publish-subscribe-based async mechanism for event-driven architecture — genuinely distinct from Future/Queueable/Batch/Scheduled, which are all about deferring or scaling Apex code execution rather than broadcasting events.
XYZ Company uses Platform Events to notify multiple downstream systems whenever a high-value Opportunity closes, while using Batch Apex separately for the unrelated nightly data cleanup.
"Platform Events handle event-driven notifications; the four async tools handle deferred or scaled code execution — related concepts, different purposes."
A Finalizer is a guaranteed callback that runs after a Queueable job completes, success or failure — exclusively a Queueable Apex feature.
XYZ Company migrated a critical billing-sync job from Future to Queueable purely to gain Finalizer support, ensuring failures always trigger a guaranteed alert notification.
"Finalizers are a Queueable-exclusive feature — Future, Batch, and Scheduled Apex have no equivalent guaranteed post-execution callback."
Scheduled Flows and Scheduled Reports offer simpler, declarative no-code recurring automation for straightforward needs. True Scheduled Apex is reserved for genuinely complex logic declarative tools can't express, or for triggering Batch Apex on a recurring basis.
XYZ Company uses a Scheduled Flow for simple weekly reminder emails, but reserves actual Scheduled Apex for triggering their complex nightly Batch cleanup job.
"Use declarative scheduled tools when sufficient; reserve true Scheduled Apex for complex logic or triggering Batch jobs."
(1) Simple callout from trigger, no chaining? → Future. (2) Chaining, complex params, or job monitoring at moderate scale? → Queueable. (3) Data volume large enough to need chunk-based limit resets? → Batch Apex. (4) Recurring, scheduled execution? → Scheduled Apex (often combined with Batch).
A strong XYZ Company interview candidate walked through this exact decision tree, naming the specific governor-limit reasoning behind each choice rather than just naming the four tools.
"Walk through volume, chaining needs, parameter complexity, and recurrence requirements — that's the decision framework interviewers want to hear."
Querying or performing DML inside a loop, even within properly bulkified async code — the bulkification discipline from trigger handling applies just as strictly inside async Apex, and many candidates incorrectly assume async automatically solves bulkification for them.
A candidate at XYZ Company's interview correctly caught that even inside Batch Apex's execute() method, querying related records inside a per-record loop is still a genuine anti-pattern.
"SOQL or DML inside a loop — even within async Apex's own execute methods — remains the single most common, most heavily-tested mistake."
All four async Apex tools solve the same root problem — staying within governor limits while doing real work — but each does it differently. Future is simplest but most limited. Queueable adds chaining and monitoring. Batch Apex scales to millions of records via chunking. Scheduled Apex adds the recurring trigger. Know the genuine tradeoffs, not just the syntax, and you'll handle any async Apex question confidently.
New interview questions every week
Follow for fresh Salesforce Q&A, free courses, and real interview experiences — straight from the trenches.
Follow Us ↗