TL;DR
- Know the four tools (future, Queueable, Batch, Schedulable) well enough to choose between them out loud, and know Platform Events as the decoupled fifth option.
- Interviewers love the edges: what you can't pass to a future method, how many jobs you can chain, what Database.Stateful costs, and why a batch test only runs one execute.
- Always tie async back to a reason: a callout from a trigger, higher limits, large data volumes, or decoupling. Async is never free.
These are the async Apex interview questions I'd ask a Salesforce developer, with the answers I'd expect from a strong candidate. They cover @future, Queueable (chaining and Transaction Finalizers), Batch Apex (Database.Stateful, scope size, QueryLocator vs Iterable), Schedulable, how the limits change, testing with Test.startTest() and Test.stopTest(), recursion and chaining pitfalls, and Platform Events as an async pattern.
Work through them out loud. For each one, say what the feature does, why you'd choose it, and what goes wrong if you misuse it. Interviewers follow up with the third part almost every time.
Fundamentals
What kinds of asynchronous Apex are there, and why use async at all?
The four classic tools are @future methods, Queueable classes, Batch Apex and Schedulable classes. Platform Event and Change Data Capture triggers also run asynchronously, in their own transactions.
You go async for one of four reasons:
- Callouts from a trigger, which can't happen synchronously.
- Higher governor limits, such as more SOQL queries, more heap and more CPU time.
- Large data volumes that no single transaction could process, which is what Batch Apex is for.
- Decoupling. The user's save shouldn't wait for, or fail because of, work they don't need to see.
What the interviewer is really checking: whether you go async for a reason, not as a reflex.
What are the restrictions on @future methods?
- The method must be
staticand returnvoid. - Parameters must be primitives, or arrays and collections of primitives. No sObjects and no custom objects.
- It returns no job ID, so you can't monitor or chain it directly.
- You can't call a future method from another future method or from Batch Apex.
- There's a limit of 50 future calls per transaction.
- For callouts, annotate it
@future(callout=true).
public with sharing class WarehouseNotifier {
@future(callout=true)
public static void notify(Set<Id> orderIds) {
List<Order> orders = [SELECT Id, OrderNumber FROM Order WHERE Id IN :orderIds WITH USER_MODE];
// build one request for all orders and send it
}
}
Future methods still work, but for new code I reach for Queueable.
Why can't you pass an sObject to a future method?
Because the method runs later, in a different transaction. By the time it executes, the record may have been changed by another user, by automation or by the rest of the original transaction, or even deleted. If the platform serialised the sObject, your async code would act on a stale snapshot without knowing it.
So you pass IDs and query fresh data inside the method. Interviewers sometimes push back: "but I could JSON-serialise the record into a String". You can, and that's the trade-off to explain. You've chosen to work with the snapshot deliberately, so you must be sure stale data is acceptable.
Queueable does accept sObjects as member variables. The same staleness caveat applies, which is why I usually still pass IDs.
Why is Queueable usually a better choice than @future?
Queueable does everything a future method does, plus:
- A job ID.
System.enqueueJobreturns an ID you can monitor throughAsyncApexJob. - Complex state. Member variables can hold sObjects, collections and your own classes.
- Chaining. A running job can enqueue the next one.
- Transaction Finalizers for reliable failure handling.
- Callouts, if the class also implements
Database.AllowsCallouts.
The main reason to still write a future method is consistency with an existing codebase. Another is when you need very simple fire-and-forget behaviour and have no reason to monitor it.
Which governor limits change when code runs asynchronously?
The ones that get more generous:
| Limit | Synchronous | Asynchronous |
|---|---|---|
| SOQL queries | 100 | 200 |
| Heap size | 6 MB | 12 MB |
| CPU time | 10 seconds | 60 seconds |
Many others don't change: 150 DML statements, 10,000 DML rows, 50,000 query rows and 100 callouts per transaction.
There's also an org-wide cap on asynchronous Apex executions in a rolling 24-hour period: 250,000, or 200 multiplied by the number of user licences, whichever is greater. Batch, Queueable, future and scheduled executions count towards it. A design that fires one job per record can hit it on a busy day.
What the interviewer is really checking: whether you know async gives more room, not unlimited room.
How do you schedule Apex to run at a set time?
Implement Schedulable, then schedule it with a cron expression. The cron format is seconds, minutes, hours, day of month, month, day of week and an optional year.
public with sharing class NightlyCleanupScheduler implements Schedulable {
public void execute(SchedulableContext context) {
Database.executeBatch(new StaleOpportunityCleanup(), 200);
}
}
// 2:00 a.m. every day
System.schedule('Nightly stale opportunity cleanup', '0 0 2 * * ?', new NightlyCleanupScheduler());
Things to mention:
- An org can have 100 scheduled Apex jobs at a time.
- A scheduled job can't make a callout directly. It starts a Batch or Queueable job that implements
Database.AllowsCallouts. - Keep
executethin and hand the real work to a Batch or Queueable job. A class referenced by a scheduled job is locked against changes while the job is scheduled, unless your org allows deployments with pending jobs.
Intermediate
How does Queueable chaining work, and what are its limits?
Inside execute, a job can call System.enqueueJob to start the next job. That's how you split long work into a sequence.
- A synchronous transaction can enqueue up to 50 Queueable jobs.
- A running async job can enqueue only one child.
- In production there's no fixed limit on chain depth. Developer Edition and trial orgs cap it at a small depth.
Because the depth is unlimited, a bug in the "should I enqueue another?" check can create a chain that never ends. Always have an explicit stop condition, such as "no records left to process" or a maximum attempt count. Newer API versions add AsyncOptions, which lets you cap the depth with MaximumQueueableStackDepth. You can also delay a job with System.enqueueJob(job, delayInMinutes), where the delay is 0 to 10 minutes. That's useful for backing off before a retry.
What is a Transaction Finalizer, and when do you need one?
A Finalizer is a class that implements Finalizer, attached inside a Queueable's execute with System.attachFinalizer. It runs after the Queueable finishes, whether it succeeded or failed. That includes failures you can't catch, such as limit exceptions. It runs in its own transaction with its own limits.
public with sharing class InvoiceSyncFinalizer implements Finalizer {
private final Set<Id> invoiceIds;
public InvoiceSyncFinalizer(Set<Id> invoiceIds) {
this.invoiceIds = invoiceIds;
}
public void execute(FinalizerContext context) {
if (context.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
insert new Integration_Log__c(
Job_Id__c = context.getAsyncApexJobId(),
Message__c = context.getException().getMessage(),
Record_Ids__c = String.join(new List<Id>(invoiceIds), ','));
}
}
}
Use one when the job must not fail silently, for logging, or for a controlled retry. A Finalizer can enqueue one Queueable, including a fresh copy of the failed job. Salesforce caps how many times in a row a failing job can be re-enqueued this way. Each Queueable can have only one Finalizer.
Walk me through the Batch Apex interface.
A batch class implements Database.Batchable<SObject> with three methods:
startreturns the records to process, as aDatabase.QueryLocatoror anIterable.executeruns once per chunk ("scope") of records, each in its own transaction with fresh governor limits.finishruns once at the end. Use it for summaries, notifications or starting the next job.
public with sharing class StaleOpportunityCleanup implements Database.Batchable<SObject>, Database.Stateful {
private Integer closedCount = 0;
public Database.QueryLocator start(Database.BatchableContext context) {
Date cutoff = Date.today().addDays(-90);
return Database.getQueryLocator([
SELECT Id, StageName FROM Opportunity
WHERE IsClosed = false AND CloseDate < :cutoff
WITH USER_MODE
]);
}
public void execute(Database.BatchableContext context, List<Opportunity> scope) {
for (Opportunity opp : scope) {
opp.StageName = 'Closed Lost';
}
for (Database.SaveResult result : Database.update(scope, false, AccessLevel.USER_MODE)) {
if (result.isSuccess()) {
closedCount++;
}
}
}
public void finish(Database.BatchableContext context) {
insert new Batch_Run_Log__c(Job_Id__c = context.getJobId(), Records_Processed__c = closedCount);
}
}
Start it with Database.executeBatch(new StaleOpportunityCleanup(), 200). Partial-success DML (allOrNone set to false) means one bad record doesn't fail the whole chunk.
What's the difference between returning a QueryLocator and an Iterable from start?
A Database.QueryLocator wraps a single SOQL query and can return up to 50 million records. The platform pages through them for you. Use it whenever your data comes from one query, which is almost always.
An Iterable lets you build the work list yourself: records from several queries, data that isn't sObjects, or rows from an external response. The cost is that start is a normal transaction, so the standard limit of 50,000 query rows applies to whatever you query to build it.
Rule of thumb: QueryLocator for volume, Iterable for unusual shapes of work.
What the interviewer is really checking: whether you know the Iterable path doesn't escape the normal query-row limit.
How do you choose a batch scope size?
The default scope is 200 records per execute. With a QueryLocator you can set up to 2,000, and larger values are split into chunks of 2,000.
Choose a scope from what each chunk has to do:
- Simple field updates can use a larger scope. Fewer chunks means less overhead.
- Callouts. Each
executeallows 100 callouts, so with one callout per record the scope must stay under 100. A smaller scope also reduces timeout risk. - Heavy triggers or flows on the object. The DML inside
executefires them, so a smaller scope keeps CPU time per chunk safe. - Record locking. A smaller scope reduces lock contention on shared parent records.
The best answer is "it depends, and I'd measure". Run with a realistic data volume, look at the CPU time and limits per chunk, then tune.
What does Database.Stateful do, and what does it cost?
Normally each execute starts with a fresh copy of the batch object, so instance variables reset between chunks. Implementing Database.Stateful keeps instance variables across chunks. That's how the closedCount counter above survives to finish. Static variables are not kept either way.
The cost: the platform serialises the object's state at the end of every execute and restores it at the start of the next one. A counter is fine. A map that grows with every record you process makes each chunk slower and eventually runs into heap limits.
Often you don't need it at all. If finish needs a total, run an aggregate SOQL query there instead of carrying state through every chunk.
How many batch jobs can run at once, and what is the Apex flex queue?
Up to 5 batch jobs can be queued or active at the same time. Beyond that, submitted jobs wait in the Apex flex queue with a status of Holding. The flex queue holds up to 100 jobs. As slots free up, jobs move from the flex queue into processing.
You can reorder jobs waiting in the flex queue, in Setup or with the FlexQueue class, to let an urgent job jump ahead.
The practical point: in an org with many scheduled batch jobs, yours may not start the moment you submit it. A design that relies on a batch starting "immediately" after something else is fragile. Chain from finish instead.
How do you test async Apex?
Wrap the code that starts the async work in Test.startTest() and Test.stopTest(). Async jobs enqueued in between run synchronously when stopTest is called, so your assertions afterwards see the results. The code inside the block also gets a fresh set of governor limits.
@IsTest
private class StaleOpportunityCleanupTest {
@IsTest
static void closesOnlyStaleOpenOpportunities() {
Account acc = new Account(Name = 'Acme');
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 150; i++) {
opps.add(new Opportunity(Name = 'Stale ' + i, AccountId = acc.Id,
StageName = 'Prospecting', CloseDate = Date.today().addDays(-120)));
}
opps.add(new Opportunity(Name = 'Fresh', AccountId = acc.Id,
StageName = 'Prospecting', CloseDate = Date.today().addDays(30)));
insert opps;
Test.startTest();
Database.executeBatch(new StaleOpportunityCleanup(), 200);
Test.stopTest();
Assert.areEqual(150, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed Lost']);
Assert.areEqual('Prospecting', [SELECT StageName FROM Opportunity WHERE Name = 'Fresh'].StageName);
}
}
Gotchas: a batch test runs only one execute, so keep the matching test data within one scope. Chained Queueables don't run beyond the first job in a test. And callouts in async code still need Test.setMock.
Scenario-based
A trigger needs to call an external API. Would you use future or Queueable, and what can go wrong?
I'd use a Queueable that implements Database.AllowsCallouts. I want the job ID for monitoring, a Finalizer for failures, and the freedom to pass a set of IDs and options.
The trap is how often the trigger enqueues. If Apex inserts 10,000 records in one DML statement, the trigger runs 50 times in the same transaction, in chunks of 200. Anything that enqueues per record fails on the first bulk load. Even one enqueueJob per chunk reaches 50 jobs, the synchronous limit, and leaves nothing for any other automation that wants to enqueue.
Safer designs:
- Enqueue at most one job per trigger chunk with all the relevant IDs, and only for records where a relevant field actually changed. Check
Limits.getQueueableJobs()againstLimits.getLimitQueueableJobs()first, and fall back to the next option when you're close. - Or set a "pending sync" flag in the trigger, and let one scheduled or Queueable sweep process everything that's pending. This scales to any volume.
What the interviewer is really checking: whether you think about bulk behaviour before writing the async call.
A bulk insert fails with a limit exception about too many Queueable jobs. What happened, and how do you fix it?
Something is calling System.enqueueJob too many times in one transaction. Usually it's inside a loop, or once per trigger chunk during a large DML. A synchronous transaction can enqueue 50 jobs. A running async job can enqueue only one.
To diagnose it, check the debug log for where enqueueJob is called and how many times the trigger ran. Then look for automation outside the code you're debugging, because a flow's Apex action or another trigger can enqueue too.
To fix it:
- Collect, then enqueue once. Gather IDs in the handler and enqueue a single job.
- Check the remaining allowance with
Limits.getQueueableJobs()before enqueuing, and mark records as pending instead when it runs low. - For very large volumes, switch to Batch Apex or a flag-and-sweep design instead of one job per transaction.
You need to recalculate a field on three million records every night, and each record needs a callout. How would you design it?
- Batch Apex with
Database.AllowsCallouts, started by a Schedulable at night. - QueryLocator in
start, filtered to records that actually need recalculating (changed since the last run, or flagged), rather than all three million every night. - Scope under 100, because each
executeallows 100 callouts. Also consider the external API's own rate limit. - Better still, batch the callout. If the external API accepts many records in one request, send one request per chunk. That changes the scope maths completely.
- Partial-success DML and a log of failures in each
execute. UseDatabase.RaisesPlatformEventsso unhandled exceptions publish aBatchApexErrorEventyou can subscribe to. - Check the arithmetic: chunks × time per callout, against the overnight window and the external system's capacity.
How do you stop async processing from triggering itself in a loop?
The classic loop: a trigger enqueues a job, the job updates the same records, the update fires the trigger, and the trigger enqueues again.
Defences, used together:
- Only enqueue on relevant changes. Compare
Trigger.newwithTrigger.oldMapfor the fields that matter. A job that updates a sync-status field shouldn't qualify. - Check the context.
System.isQueueable(),System.isBatch()andSystem.isFuture()tell you that you're already running async. Calling a future method whenSystem.isBatch()orSystem.isFuture()is true would throw anyway. - Track processed IDs in a static
Set<Id>rather than a single static Boolean. A Boolean flag breaks bulk processing, because the second chunk of 200 records in the same transaction gets skipped. - A bypass flag the job sets for its own update, if the trigger framework supports one.
How would you use Platform Events as an async pattern?
Publish an event and let a subscriber do the work in its own transaction:
List<Order_Activated__e> events = new List<Order_Activated__e>();
for (Order ord : activatedOrders) {
events.add(new Order_Activated__e(Order_Id__c = ord.Id));
}
List<Database.SaveResult> results = EventBus.publish(events);
An Apex trigger (after insert on the event) or a Platform Event-triggered flow processes the events. Why choose this over Queueable?
- Decoupling. Publishers don't know or care who subscribes. Several subscribers, including external systems through the Pub/Sub API, can react to the same event.
- Publish After Commit means subscribers never see events from rolled-back transactions.
- Batched delivery. Subscribers receive events in batches, so the subscriber must be bulkified.
- Retry control. A trigger can throw
EventBus.RetryableExceptionto have the batch redelivered a limited number of times, or usesetResumeCheckpointso a failure resumes after the last successful event.
Remember that subscribers run as the Automated Process user by default. That affects record ownership, "Created By" and sharing.
Senior / design
How do you choose between future, Queueable, Batch, Schedulable and Platform Events?
| Need | Choose |
|---|---|
| Simple fire-and-forget, legacy codebase | @future |
| Async work with state, monitoring, chaining or a callout from a trigger | Queueable (plus a Finalizer if failure matters) |
| Process a large data set in chunks with fresh limits per chunk | Batch Apex |
| Run something at a set time or interval | Schedulable, which usually starts a Batch or Queueable |
| Decouple publisher from subscribers, or several reactions to one event | Platform Events |
The follow-up is usually "and what would you not use?". For example: not Batch Apex for 50 records that need one callout, because the overhead and queueing delay are pointless. And not a chain of 500 Queueables when a batch with a QueryLocator does the same job with better visibility.
What the interviewer is really checking: whether you match the tool to the data volume, timing and failure needs.
How do you make async processing observable and safe to rerun?
Async code fails where nobody is watching, so I design for that from the start:
- Idempotent jobs. Process records based on a status or a "last processed" marker, so running a job twice does no harm and a failed run can simply be restarted.
- Monitoring. Query
AsyncApexJobfor failed jobs and error messages. Batch jobs expose processed-chunk and error counts. - Failure capture. Finalizers for Queueables,
BatchApexErrorEventfor batches, and a log object that a dashboard reads. - Capacity planning. Count the jobs per day against the daily async execution limit, and remember the five-active-batch limit when many jobs are scheduled at once.
- Deployment hygiene. Know which classes scheduled jobs reference, because deployments can be blocked while those jobs are pending.
How to practise these questions out loud
Async questions almost always lead to "write it". Prepare for that:
- Write each pattern from memory: a Queueable with a Finalizer, a Batch class with a QueryLocator, and a Schedulable that starts the batch. Do it in a Developer Edition org, then write a test for each one with
Test.startTest()andTest.stopTest(). - Break them on purpose. Enqueue inside a loop and insert 10,000 records from Anonymous Apex. Give a batch test more records than one scope. Read the errors, so they're familiar when an interviewer describes them.
- Explain the decision table out loud with a new scenario each time: "send invoices to the ERP", "recalculate scores nightly", "notify three systems when an order ships".
- Time your answers. Aim for about 90 seconds for concept questions and five minutes for design questions, and finish every answer with what happens when it fails.
The Asynchronous Apex and Apex Test Framework modules in Salesforce Development (Visualforce & Apex) build these patterns live. Apex Test Fest goes deeper on testing async code.
Related guides
- Apex learning guide and Apex testing guide: the foundations these questions assume.
- Salesforce developer roadmap: where async Apex fits in the order you learn things.
- Apex bulkification explained: bulkify first, then go async.
- Salesforce integration interview questions: callouts, retries and Platform Events from the integration side.
- Apex test classes beyond 75% coverage: writing tests that assert real behaviour.
Salesforce Development (Visualforce & Apex)
47 episodes · 5.6 hours · 8 modules
Want 700+ pages of this?
The Salesforce Interview Questions & Answers Book goes far deeper than this page — it's included with the Lifetime plan, alongside the Real-Time Projects series.
Frequently asked questions
Is async Apex asked in junior Salesforce developer interviews?
Yes, at least the basics: why async exists, future vs Queueable, and how to test it. Batch Apex design, Finalizers and chaining strategy are more common in mid-level and senior interviews.
Do interviewers expect exact governor limit numbers?
For the common ones, such as SOQL queries, heap and CPU in synchronous versus asynchronous context, yes. For rarer limits, explaining the behaviour and where to look it up is fine and better than guessing.
Will I have to write a Batch class from memory?
Often, either in a shared editor or on a whiteboard. The interface is small, so practise writing start, execute and finish with a QueryLocator and a test until you can do it without notes.
What's the most common mistake candidates make with async Apex questions?
Treating async as a fix for bad code. Moving an unbulkified loop into a future method only moves the limit exception somewhere harder to see. Interviewers want to hear bulkification first, then async for a real reason.
Keep going
Topic guide
Learn Apex: A Hands-On Salesforce Apex Course for Developers
Topic guide
Learn Apex Testing: Write Test Classes That Prove Your Code Works
Career roadmap
Salesforce Developer Roadmap 2026
Blog
Apex Bulkification Explained: From 'Too Many SOQL Queries: 101' to Bulk-Safe Code
Interview questions
Salesforce Integration Interview Questions and Answers (2026)
Blog
Apex Test Classes: Stop Chasing 75% Coverage