Asynchronous Apex comes up in the second technical round for almost every Salesforce developer role, and it is where interviewers separate people who have written code from people who have only read about it. The ten questions below cover future methods, Queueable, Batch Apex, Schedulable, Continuation and Platform Events. Each answer gives the definition, the limits you are expected to quote from memory, and the design reasoning an interviewer listens for. There is a decision table for the four async options, a complete stateful batch class, and a Queueable that chains itself.
Choosing the right asynchronous tool
Most async questions are one question asked four ways: do you know when and where your code runs. Our trainers see this every batch. A candidate writes a Queueable class on the whiteboard and then cannot say whether it runs in the same transaction, whether it sees uncommitted data, or what happens to it when the calling transaction rolls back. Syntax is the easy half.
Difference between @future and Queueable
Both run Apex in a separate transaction with its own governor limits, but Queueable is the newer and more capable of the two, and in new code it is the default choice.
A future method is a static method marked with the @future annotation. It returns void, accepts only primitives, arrays of primitives and collections of primitives, and gives you nothing back when you call it. You fire it and forget it.
public class AccountFutureService {
@future(callout=true)
public static void syncToErp(Set<Id> accountIds) {
List<Account> accs = [SELECT Id, Name, BillingCity FROM Account WHERE Id IN :accountIds];
// build and send the callout here
}
}
A Queueable class implements the Queueable interface and is submitted with System.enqueueJob, which returns the Id of an AsyncApexJob record. That Id is the first practical difference. You can store it, query it, and show the user the status of the job.
public class AccountQueueableService implements Queueable, Database.AllowsCallouts {
private List<Account> accounts; // sObjects held as member state
public AccountQueueableService(List<Account> accounts) {
this.accounts = accounts;
}
public void execute(QueueableContext context) {
// full sObject records are available here, no re-query needed
}
}
Id jobId = System.enqueueJob(new AccountQueueableService(scopeAccounts));
The four differences worth stating in an interview are the parameter types, the returned job Id, chaining support, and error handling. Queueable accepts any type through its constructor, hands back an AsyncApexJob Id you can monitor, can enqueue a child job, and supports Transaction Finalizers for post-execution logic. A future method does none of these.
One thing candidates get wrong: neither of them runs inside the calling transaction. If the trigger that called your future method rolls back after the call, the job is never enqueued, because the enqueue itself is part of that transaction. Once it commits, the job is queued and runs when a worker is free. There is no guaranteed delay and no guaranteed order.
What is Future method limitation?
This question is asked because the limitations are the reason Queueable exists. Be precise and list them in order.
No sObject parameters. A future method accepts primitives, arrays of primitives and collections of primitives. You cannot pass an Account, a List<Opportunity> or a custom Apex type. The reason is that the record may have changed between the time the job was queued and the time it ran, so the platform forces you to pass Ids and re-query inside the method. Passing a JSON string of the record is a workaround, but you are then working with stale data, which is exactly what the restriction warns against.
No chaining. A future method cannot call another future method, and it cannot be called from a batch Apex method. If your design needs step two to run after step one, future methods are the wrong tool.
No return value. The signature must be static void. You get nothing back, and no job Id at call time, so monitoring means querying AsyncApexJob by class name and guessing which row is yours.
50 per transaction. A single Apex transaction can make at most 50 future calls. In a trigger firing on 200 records, one future call per record breaks this immediately, which is why future calls are bulkified the same way DML is: collect the Ids into a set, make one call, pass the set.
Two more that round out the answer. Future methods count against the 24-hour asynchronous Apex limit of 250,000 executions or the number of user licences multiplied by 200, whichever is higher. And they cannot be called from a Visualforce getter or setter, or from a constructor.
Difference between Queueable and Batch Apex
Queueable runs your logic once in one asynchronous transaction. Batch Apex splits a large record set into chunks and runs your logic once per chunk, each chunk in its own transaction with fresh governor limits. That single sentence is the answer, and everything else follows from it.
Use Queueable when the work fits in one transaction: a callout after a trigger, a few hundred records to update, a calculation on a related set. Use Batch when the volume is large enough that one transaction cannot hold it, because the 50,000 record query limit or the 10,000 DML row limit or the 6 MB heap will stop you.
Batch Apex also gives you three methods instead of one. start defines the scope, execute processes each chunk, and finish runs once at the end for notification or for kicking off the next job.
Batch is the only option that can read more than 50,000 rows. A Database.QueryLocator returned from start retrieves up to 50 million records, because the platform streams them rather than loading them into the transaction. A Queueable is bound by the ordinary query limit of 50,000 rows.
Queueable wins on how fast it starts. Batch jobs sit in the flex queue and only five can be queued or active at one time.
Chaining is the reason many teams pick Queueable over Batch for sequential work. From inside a running Queueable job you can enqueue exactly one child job, that child can enqueue one more, and the sequence continues until you stop it.
public class ContactSyncQueueable implements Queueable, Database.AllowsCallouts {
private List<Contact> batchToProcess;
private List<Contact> remaining;
public ContactSyncQueueable(List<Contact> allContacts) {
Integer chunkSize = 100;
this.batchToProcess = new List<Contact>();
this.remaining = new List<Contact>();
for (Integer i = 0; i < allContacts.size(); i++) {
if (i < chunkSize) {
batchToProcess.add(allContacts[i]);
} else {
remaining.add(allContacts[i]);
}
}
}
public void execute(QueueableContext context) {
for (Contact c : batchToProcess) {
// one callout per contact, or build a single bulk payload here
c.Sync_Status__c = 'Sent';
}
update batchToProcess;
// chain the next job only if there is work left,
// and never inside a test, where chaining is not allowed
if (!remaining.isEmpty() && !Test.isRunningTest()) {
System.enqueueJob(new ContactSyncQueueable(remaining));
}
}
}
Two rules about that Test.isRunningTest() guard, because this is the detail interviewers use to separate people who have written Queueable code from people who have only read about it.
Chaining does not work in test context. If a Queueable running inside a test enqueues another Queueable, the platform throws an error. The standard pattern is the guard above. Your test then asserts that the first job ran, and unit tests the chained job separately by enqueuing it directly.
In a test, only one level runs. You enqueue the job before Test.stopTest(), and that single job executes when stopTest is called. There is no second hop. Chain depth in a test is effectively one.
In production the picture is different. There is no fixed limit on chain depth in most editions, but Developer Edition and trial orgs cap the stack at 5. A single transaction can enqueue up to 50 jobs, while a running job can enqueue only one child.
Picking between the four options. This is the table to have in your head before the interview starts.
| When to use it | sObject parameters | Can it chain | Key limits | |
|---|---|---|---|---|
| @future | Simple fire-and-forget work, mixed DML separation, a quick callout from a trigger in legacy code | No. Primitives, arrays of primitives and collections of primitives only | No. Cannot call another future method or be called from batch | 50 calls per transaction. static void only. No job Id returned |
| Queueable | Default choice for new async work. Callouts, complex objects, sequential steps | Yes. Passed through the constructor and held as member variables | Yes. One child job per running job. Depth limited to 5 in Developer Edition and trial orgs, no fixed limit elsewhere | 50 jobs enqueued per transaction. Standard transaction limits apply per job. Chaining is blocked inside test context |
| Batch Apex | Volume that cannot fit in one transaction. Data cleanup, recalculation, mass updates | Yes, through the class constructor and through the scope list passed to execute |
Yes, indirectly. Call Database.executeBatch or System.enqueueJob from finish |
Scope defaults to 200, maximum 2,000. QueryLocator up to 50 million rows. Five batch jobs queued or active at a time |
| Schedulable | Work that must run at a fixed time or on a repeating cron pattern | Yes, through the class constructor when you schedule the instance | Not a chaining tool. It starts other async jobs instead | 100 scheduled Apex jobs at one time. Synchronous callouts are not allowed from the scheduled context |
What the interviewer is actually checking
- Whether you pick Queueable by default and can say why, rather than reaching for
@futurebecause it was in the first tutorial you read. - Whether you know that async code runs in a separate transaction and therefore cannot see uncommitted data from the caller.
- Whether you can quote the future method restrictions without hedging, since they are the most commonly tested limit set in Apex.
- Whether you bulkify async calls the same way you bulkify DML.
- Whether you know chaining is blocked inside test context and can show the
Test.isRunningTest()guard without being prompted.
Batch Apex in depth
What is Batchable interface?
Database.Batchable<sObject> is the interface a class implements to run as a batch job. It requires three methods.
start(Database.BatchableContext bc) runs once and returns either a Database.QueryLocator or an Iterable<sObject>. The QueryLocator form is the one you want for record processing, because it supports up to 50 million rows and the platform chunks it for you. The Iterable form is for data that does not come from a single SOQL query, and it is bound by ordinary query and heap limits.
execute(Database.BatchableContext bc, List<sObject> scope) runs once per chunk, each call a separate transaction with a fresh set of governor limits. This is the entire point of Batch Apex.
finish(Database.BatchableContext bc) runs once after the last chunk, in its own transaction. Use it to send a summary email, write an audit record, or start the next job.
You start the job with Database.executeBatch(new MyBatch()), which returns the Id of the AsyncApexJob row. The optional second argument is the scope size.
The scope default is 200. With no second argument, each execute call receives up to 200 records. The maximum you can pass is 2,000. Reduce the scope when each record is expensive to process, for example when a heavy trigger fires on the update, or when you are hitting CPU time or heap inside execute. One exception is worth remembering: if start returns a QueryLocator containing a relationship subquery, the chunk size drops to 200 whatever you asked for.
Add Database.AllowsCallouts to the class declaration if any method makes an HTTP callout.
What is Stateful batch?
By default, a batch job is stateless. Every call to execute gets a fresh instance of your class, so instance variables reset and static variables reset. If you increment a counter in execute, it will read zero at the start of the next chunk.
Implementing Database.Stateful tells the platform to keep the instance member variables across all execute calls and into finish. Static variables are still reset. This is how you carry a running total, a list of failed record Ids, or a summary to email at the end.
The cost is serialisation. The instance is serialised between chunks, so keep the retained state small. Holding a growing List<sObject> of every processed record across five thousand chunks is how people hit heap errors in a job that looked safe on a thousand records.
Here is a complete stateful batch class that recalculates a rollup on Accounts, counts what it did, collects failures, and emails a summary.
public class AccountRevenueBatch implements Database.Batchable<sObject>, Database.Stateful {
private Integer recordsProcessed = 0;
private Integer recordsFailed = 0;
private List<String> failureMessages = new List<String>();
private Date runDate;
public AccountRevenueBatch(Date runDate) {
this.runDate = runDate;
}
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, Annual_Revenue_Calculated__c,
(SELECT Id, Amount FROM Opportunities WHERE StageName = 'Closed Won')
FROM Account
WHERE Active__c = true
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
List<Account> toUpdate = new List<Account>();
for (Account acc : scope) {
Decimal total = 0;
for (Opportunity opp : acc.Opportunities) {
if (opp.Amount != null) {
total += opp.Amount;
}
}
acc.Annual_Revenue_Calculated__c = total;
toUpdate.add(acc);
}
Database.SaveResult[] results = Database.update(toUpdate, false);
for (Integer i = 0; i < results.size(); i++) {
if (results[i].isSuccess()) {
recordsProcessed++;
} else {
recordsFailed++;
if (failureMessages.size() < 100) {
failureMessages.add(
toUpdate[i].Id + ': ' + results[i].getErrors()[0].getMessage()
);
}
}
}
}
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems,
CreatedBy.Email
FROM AsyncApexJob
WHERE Id = :bc.getJobId()
];
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[] { job.CreatedBy.Email });
mail.setSubject('Account revenue batch ' + job.Status + ' for ' + runDate);
mail.setPlainTextBody(
'Chunks: ' + job.JobItemsProcessed + ' of ' + job.TotalJobItems + '\n' +
'Records updated: ' + recordsProcessed + '\n' +
'Records failed: ' + recordsFailed + '\n\n' +
String.join(failureMessages, '\n')
);
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
}
Point out three details when you present it. The counters survive between chunks only because of Database.Stateful. The failure list is capped at 100 entries so the serialised state does not grow without bound. And Database.update(toUpdate, false) allows partial success, so one bad record does not roll back the whole chunk.
Start it with a smaller scope if the Account trigger is heavy:
Id batchId = Database.executeBatch(new AccountRevenueBatch(Date.today()), 50);
On testing: inside Test.startTest() and Test.stopTest(), only one execute call runs, so keep the test data to 200 records or fewer if the assertion has to cover everything the job touched.
How do you process 1 million records?
This is a design question, not a syntax question. The interviewer wants to hear you reason about volume, limits and recovery. Walk through it in order.
Start with Batch Apex and a QueryLocator. One million rows cannot be queried in a single transaction, where the limit is 50,000. Database.getQueryLocator streams up to 50 million rows and the platform hands them to execute in chunks.
Do the arithmetic on the chunks. At the default scope of 200, one million records is 5,000 execute calls plus start and finish. Each counts as one asynchronous Apex execution against the 24-hour limit of 250,000 or licences multiplied by 200, whichever is higher. Comfortable for one job, and worth saying aloud because it shows you know the ceiling exists.
Tune the scope to the work, not to the count. If execute only stamps a field and no trigger fires, push the scope towards 2,000 to cut the number of transactions. If each record fires a trigger chain, a flow and a rollup, drop to 50 or 100 so you do not hit CPU time inside a chunk.
Make the query selective. Filter on an indexed field and keep the WHERE clause narrow. A non-selective query over a million-row object can time out even inside a QueryLocator, particularly when the filter uses != or a leading wildcard.
Allow partial success and record the failures. Use Database.update(records, false) and store the failing Ids somewhere durable, so the job can be rerun for those records alone rather than for the full million.
Suppress what you do not need. If the update only sets a technical field, a static flag can let your triggers skip the heavy logic during the batch run. Say this carefully, because skipping validation carelessly is a real risk and the interviewer will ask how the flag is controlled.
Say why you rejected the alternatives. Bulk API from an external tool is often better for a one-time load, because it does not consume Apex async limits at all. Batch Apex is right when the logic has to live in the org and run repeatedly. Queueable chaining suits records that must be walked in sequence rather than in parallel chunks.
Memorised definitions collapse the moment an interviewer asks for a real project example, so attach this answer to something you built. Which object, how many records, what scope size you settled on and why, what broke on the first run.
What the interviewer is actually checking
- Whether you know the scope default is 200 and can explain when to move it in either direction.
- Whether you understand that each
executeis a separate transaction with fresh limits, which is the whole reason Batch exists. - Whether you know
Database.Statefulkeeps instance variables and not static variables, and that the retained state is serialised. - Whether you plan for failure and restart on a million-record job instead of assuming it will complete.
Scheduling Apex
What is Schedulable Apex?
Schedulable Apex is a class that implements the System.Schedulable interface so the platform can run it at a set time. The interface has one method.
public class WeeklyRevenueScheduler implements Schedulable {
public void execute(SchedulableContext sc) {
Database.executeBatch(new AccountRevenueBatch(Date.today()), 100);
}
}
That pattern is the one to show. A schedulable class is a trigger for time, not a place for heavy logic. It runs in an asynchronous transaction with ordinary governor limits, so the standard design is for execute to start a batch job or enqueue a Queueable and do nothing else.
Two limitations to know. Synchronous callouts are not supported from scheduled Apex, so if the scheduled work needs an HTTP call, put it inside a Queueable or batch class with Database.AllowsCallouts and start that from the scheduler. And a scheduled class cannot be modified or deleted while it has a pending job, which is why deployments to orgs with active schedules often need the job stopped first.
What is Apex Scheduler?
The Apex Scheduler is the platform service that holds and fires scheduled jobs. You interact with it through System.schedule, through the Schedule Apex button in Setup, and through the CronTrigger object.
System.schedule takes a job name, a cron expression and an instance of your schedulable class, and returns the Id of the CronTrigger record.
String cron = '0 0 2 ? * MON-FRI'; // 02:00 every weekday
Id jobId = System.schedule('Weekday Revenue Rollup', cron, new WeeklyRevenueScheduler());
The cron expression has seven fields: seconds, minutes, hours, day of month, month, day of week, and an optional year. The ? is used in either the day of month or the day of week field when the other one is specified, because the two cannot both be set.
Things interviewers ask about the scheduler:
- The limit is 100 scheduled Apex jobs at one time in the org.
- The Setup UI cannot express every schedule. The Schedule Apex screen handles daily, weekly and monthly patterns. Anything finer, such as every fifteen minutes, needs
System.schedulefrom anonymous Apex, usually called several times with different cron expressions. - Scheduled time is approximate. The job starts at or after the scheduled time depending on service availability, so do not build logic that assumes the exact second.
System.abortJob(jobId)cancels a scheduled job. Find the Id by queryingCronTriggerorCronJobDetailby name.System.scheduleBatch(new MyBatch(), 'job name', 30)runs a batch job once in thirty minutes without a schedulable class at all, which is the neat answer when the question is about a one-time delayed run.
For testing, schedule the job inside Test.startTest() and Test.stopTest(). It runs synchronously at stopTest whatever the cron expression says, so you can assert immediately.
What the interviewer is actually checking
- Whether you keep logic out of the scheduler and start a batch or Queueable from it instead.
- Whether you know the cron expression has a seconds field and how the
?works. - Whether you know the 100 job limit and how to get a schedule finer than the Setup UI allows.
- Whether you have hit the deployment problem of a scheduled class that cannot be changed while a job is pending.
Callouts and events
What is Continuation in Apex?
A Continuation makes a long-running callout without holding a server thread while it waits. Ordinary Apex callouts are synchronous and the transaction blocks until the response arrives. A Continuation releases the thread, and the platform resumes your Apex through a callback method when the response comes back.
It is used from Visualforce and from Lightning components. In a Lightning context the Apex method is annotated @AuraEnabled(continuation=true) and returns a Continuation object rather than data.
The shape of the code: build a Continuation with a timeout, add one or more HttpRequest objects to it, set the continuationMethod to the name of the callback, and return the Continuation. The callback receives the labels of the completed requests, pulls each response with Continuation.getResponse(label), and returns the processed result to the client.
Limits to quote:
- Up to three parallel callouts in a single Continuation.
- Default timeout 30 seconds, maximum 120 seconds.
- Maximum response size of 1 MB.
- The callout does not count against the synchronous Apex callout timeout in the usual way, because the request is not held open on a thread.
The honest framing for an interview: Continuation solves one specific problem, a slow external service called from a screen where the user is waiting. If nobody is waiting, a Queueable with Database.AllowsCallouts is simpler and is what most projects use. Say that, because it shows you are choosing rather than reciting.
What is Platform Events?
Platform Events are the publish and subscribe messaging layer built into the platform. Instead of one piece of code calling another directly, a publisher puts a message on the event bus and any number of subscribers receive it independently.
An event is defined in Setup like an object, with a name ending in __e and a set of custom fields. You publish with EventBus.publish, which takes a list of event records and returns Database.SaveResult objects.
List<Order_Shipped__e> events = new List<Order_Shipped__e>();
for (Order__c o : shippedOrders) {
events.add(new Order_Shipped__e(
Order_Number__c = o.Name,
Shipped_On__c = System.now()
));
}
List<Database.SaveResult> results = EventBus.publish(events);
Subscribers can be an Apex trigger, a Flow, a Lightning component using the empApi module, or an external system over CometD or Pub/Sub.
The parts interviewers test:
The trigger is after insert only. There is no update or delete, because an event is a message and not a stored record you can edit.
It runs as the Automated Process user. Debug logs sit under that user, not yours, which is the most common reason a developer says the trigger is not firing when it actually is.
Events arrive in batches of up to 2,000 per trigger execution, so a platform event trigger has to be bulkified like any other.
Publish behaviour matters. Publish After Commit sends the event only if the publishing transaction commits, which is what you want when the event announces a saved record. Publish Immediately sends it whether or not the transaction commits, which suits logging that must survive a rollback.
High-volume events are retained for 72 hours and each carries a ReplayId, so a subscriber that was offline can resume from where it stopped instead of losing the messages.
Use them when the publisher and the subscriber should be decoupled: an external system reacting to a Salesforce change, or one save starting several unrelated pieces of work that should not fail together.
A debugging question hides in this topic and comes up often. If the interviewer says the platform event trigger is not firing, the checklist is the Automated Process user's debug logs, whether the publish returned success in the SaveResult, whether the behaviour is After Commit on a transaction that rolled back, and whether the trigger threw an unhandled exception on an earlier event in the same batch.
What the interviewer is actually checking
- Whether you can say what a Continuation is for and, more importantly, when not to reach for it.
- Whether you check the
Database.SaveResultreturned byEventBus.publishinstead of assuming the event went out. - Whether you know a platform event trigger is
after insertonly and runs as the Automated Process user. - Whether you can describe a decoupling requirement in your own words rather than defining publish and subscribe from a slide.
Closing
Asynchronous Apex rewards candidates who can explain execution context: when the code runs, where it runs, what data it can reach, and what happens when it fails. Two candidates can write the same Queueable class, and the one who can explain why the calling transaction's uncommitted records are invisible to it gets the offer. Practise by opening a job you have written, tracing it from the enqueue call to the AsyncApexJob row, and saying the whole path out loud.
For structured practice with code review and mock interview rounds, our Salesforce training in Pune with placement support covers asynchronous Apex, governor limits and integration patterns with assignments on a real org.