Queueable vs Future vs Batch Apex when to use
Compare Queueable, @future, and Batch Apex on heap size, callout support, chainability, and visibility — pick the right async primitive for a 50k-record CRM sync where partial failure must be observable.

Queueable vs Future vs Batch Apex: Picking the Right Async Primitive for a 50k-Record CRM Sync
The first time I had to debug a nightly Apex sync, it was 2 a.m. on a Saturday and a customer had just emailed support asking why their contact record was three days stale. The job had been quietly half-failing for a week — no alerts, no AsyncApexJob rows that meant anything, just a single @future method dropping records on the floor. I rebuilt that sync twice before I understood why the original choice of primitive was wrong. This article is the version of that lesson I wish I'd had on Friday afternoon, before the pager went off.
The platform gives you three async primitives that look interchangeable in the docs: @future, the Queueable interface, and Database.Batchable. They aren't. Each one trades a different mix of heap size, callout support, chainability, retry semantics, and visibility — and for a 50k-record nightly sync, the wrong choice is the difference between a job that quietly self-heals and one that wakes you up on the weekend.
Below I'll walk through all three, score them against the sync scenario, and land on a working implementation that survives partial failure with a queryable audit log.
The Three Primitives at a Glance
Which of these three primitives can actually survive 50,000 records without dropping any on the floor? The table below answers that — and the answer is not the one most Apex tutorials reach for first:
| Capability | @future | Queueable | Batch Apex |
|---|---|---|---|
| Heap size (sync context) | 6 MB | 12 MB | 12 MB per chunk |
| Heap size (async context) | 12 MB | 12 MB | 12 MB per execute invocation |
| Callouts | Yes (with callout=true) | Yes (implements Database.AllowsCallouts) | Yes in execute, not in start |
| Chaining | No | Yes (System.enqueueJob from inside execute) | Yes (Database.executeBatch from finish) |
| Pass custom types | Primitives + collections of primitives only | Any serializable object, including custom classes | SObject scope passed automatically |
| Per-transaction queue cap | 50 per transaction | 50 per transaction, plus chain depth limits | 5 concurrent batch jobs per org |
| Visibility | Limited (no clean job id surfaced) | AsyncApexJob row per enqueue | AsyncApexJob plus BatchApexErrorEvent |
| Best fit | Fire-and-forget under 100 records | Mid-volume work needing state + chaining | True bulk (10k+ records) needing checkpoints |
Those numeric ceilings come straight from the official Apex limits reference, and they're the first lens I reach for when evaluating any async design. A 50k-record CRM sync blows past @future immediately. Whether Queueable or Batch wins depends on whether you need per-chunk checkpoints with a built-in job dashboard (Batch) or stateful chaining with arbitrary custom payloads (Queueable).
@future: The Primitive That Refuses to Scale
Why has Salesforce kept this primitive in the platform for a decade after introducing two replacements that handle most modern use cases better? It exists because the platform needed a way to defer callouts out of trigger context without rewriting the trigger framework. That history shows.
public class ContactSyncFutureExample {
@future(callout=true)
public static void syncContacts(Set<Id> contactIds) {
List<Contact> contacts = [SELECT Id, Email FROM Contact WHERE Id IN :contactIds];
for (Contact c : contacts) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:DownstreamCRM/contacts/' + c.Id);
req.setMethod('POST');
new Http().send(req);
}
}
}
Read that signature carefully. The method has to be static, it has to return void, and the parameter types are restricted to primitives and collections of primitives. You can't pass a List<Contact>. You can't pass a custom Apex class. You can't even get a job id back, which means you have no first-class handle to track the work.
For a 50k-record sync, the problems compound:
- The 12 MB async heap is shared across every record your SOQL query pulls back. Grab 50k contacts with even a handful of related fields and you'll hit the heap ceiling before the first callout fires.
- There's no chaining. If you want to process records in chunks, the trigger or controller that calls
@futurehas to enqueue one method invocation per chunk. You're capped at 50 future calls per transaction, which forces awkward batch sizes around 1000 records per chunk and brittle pacing. - Errors disappear. A thrown exception inside a future method logs to the debug console only if a debug log happens to be active at that moment. There's no
AsyncApexJobrow that gives you a useful status. The downstream consequence is exactly the failure mode I opened with: an operator finds out by customer complaint. - Testing partial failure is awkward. Mocking future calls means dancing around
Test.startTest()/Test.stopTest()boundaries, and there's no clean assertion surface for "two records succeeded, one threw, one retried."
My honest reading: @future is a legacy primitive kept in the platform for backward compatibility. If you're writing new code in 2026, the rare cases where @future is the right answer are exotic — a one-shot trigger-context callout under 100 records where you genuinely don't care about visibility, where you have no plan to chain, and where the payload genuinely fits a primitive signature. The 50k CRM sync isn't one of those cases.
Queueable: The Modern Default
A colleague once rewrote a 200-line @future pipeline into 40 lines of Queueable in a single afternoon — and the only line that actually mattered was the one calling System.enqueueJob() from inside execute(). Three differences matter most. First, you can pass arbitrary serializable objects to a queueable, so you carry context across the async boundary without re-querying. Second, you get a job id back from System.enqueueJob() — that gives you a row in AsyncApexJob you can query, monitor, and surface in an admin UI. Third, queueables chain naturally. From inside execute(), you call System.enqueueJob() again to enqueue the next chunk, and the chained job is tied to the current job's id.
public class ContactSyncQueueable implements Queueable, Database.AllowsCallouts {
private final List<Id> contactIds;
private final Integer chunkIndex;
private final Integer chunkSize;
public ContactSyncQueueable(List<Id> contactIds, Integer chunkIndex, Integer chunkSize) {
this.contactIds = contactIds;
this.chunkIndex = chunkIndex;
this.chunkSize = chunkSize;
}
public void execute(QueueableContext context) {
Integer startIdx = chunkIndex * chunkSize;
Integer endIdx = Math.min(startIdx + chunkSize, contactIds.size());
List<Id> slice = new List<Id>();
for (Integer i = startIdx; i < endIdx; i++) {
slice.add(contactIds[i]);
}
List<Sync_Log__c> logs = new List<Sync_Log__c>();
for (Contact c : [SELECT Id, Email FROM Contact WHERE Id IN :slice]) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:DownstreamCRM/contacts/' + c.Id);
req.setMethod('POST');
HttpResponse res = new Http().send(req);
logs.add(new Sync_Log__c(
Contact__c = c.Id,
Status__c = res.getStatusCode() == 200 ? 'OK' : 'FAILED',
Status_Code__c = res.getStatusCode(),
Job_Id__c = context.getJobId()
));
} catch (Exception e) {
logs.add(new Sync_Log__c(
Contact__c = c.Id,
Status__c = 'ERROR',
Error_Message__c = e.getMessage(),
Job_Id__c = context.getJobId()
));
}
}
insert logs;
if (endIdx < contactIds.size()) {
System.enqueueJob(new ContactSyncQueueable(contactIds, chunkIndex + 1, chunkSize));
}
}
}
Two things are worth pointing at. The job id from QueueableContext is written onto every sync log, so the operations team can pull all results for a single run with a query against Sync_Log__c WHERE Job_Id__c = :jobId. And the chain only continues when there's work left. There's no separate scheduler — the queueable schedules itself.
The catch is chain depth. Each chained queueable counts as part of the same logical job tree, and Salesforce enforces a depth limit of 5 in test context, with a much higher production limit but still bounded behavior under stress. For 50k records at 200 per chunk, you'll need 250 chained invocations. That works in production. It doesn't work if you ever want to run the full sync inside a single test method without faking out Test.stopTest() boundaries.
The 12 MB heap can still bite you if your sync includes related-object joins like Account.Owner.Email. Each chained execution starts fresh, so you don't accumulate heap across chunks, but a single fat chunk can still blow up. Keep chunks at 200 records or below whenever there's any chance of nested SObject relationships in the SELECT clause.
Batch Apex: The Bulk Workhorse with a Checkpoint Story
Database.Batchable is the primitive Salesforce ships specifically for jobs that process more rows than fit in a single async heap. The runtime chunks the source query for you, hands each chunk to execute() with a fresh 12 MB heap, and orchestrates the whole thing with a single AsyncApexJob row plus per-chunk error tracking via BatchApexErrorEvent.
public class ContactSyncBatch implements Database.Batchable<SObject>, Database.AllowsCallouts, Database.Stateful {
public Integer totalProcessed = 0;
public Integer totalFailed = 0;
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Email FROM Contact WHERE Last_Sync_At__c < :Datetime.now().addHours(-24)
]);
}
public void execute(Database.BatchableContext bc, List<Contact> scope) {
List<Sync_Log__c> logs = new List<Sync_Log__c>();
for (Contact c : scope) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:DownstreamCRM/contacts/' + c.Id);
req.setMethod('POST');
HttpResponse res = new Http().send(req);
Boolean ok = res.getStatusCode() == 200;
if (ok) { totalProcessed++; } else { totalFailed++; }
logs.add(new Sync_Log__c(
Contact__c = c.Id,
Status__c = ok ? 'OK' : 'FAILED',
Status_Code__c = res.getStatusCode(),
Job_Id__c = bc.getJobId()
));
} catch (Exception e) {
totalFailed++;
logs.add(new Sync_Log__c(
Contact__c = c.Id,
Status__c = 'ERROR',
Error_Message__c = e.getMessage(),
Job_Id__c = bc.getJobId()
));
}
}
insert logs;
}
public void finish(Database.BatchableContext bc) {
Sync_Run_Summary__c summary = new Sync_Run_Summary__c(
Job_Id__c = bc.getJobId(),
Processed__c = totalProcessed,
Failed__c = totalFailed,
Finished_At__c = Datetime.now()
);
insert summary;
}
}
Database.Stateful is the keyword that makes the running totals work. Without it, instance variables reset between chunks. With it, the runtime serializes the instance between invocations, so you can carry a running count and write one summary row in finish() even though the inserts in execute() are scattered across hundreds of separate transactions.
The right chunk size for a callout-driven batch sits between 100 and 200 records. Salesforce allows up to 2000 per chunk for Database.QueryLocator, but each chunk is one transaction with the standard 100-callout limit. A chunk size of 200 with one callout per record sits comfortably under that ceiling and yields a 250-chunk plan for 50,000 records, finishing inside the nightly window even on a moderately latent downstream API.
Visibility is where Batch Apex earns its keep for this scenario. The AsyncApexJob row exposes TotalJobItems, JobItemsProcessed, NumberOfErrors, and Status. Hook a Lightning Web Component to a SOQL query on AsyncApexJob WHERE ApexClass.Name = 'ContactSyncBatch' ORDER BY CreatedDate DESC LIMIT 5 and you've got a live admin dashboard with no extra infrastructure. Combine that with the Sync_Log__c rows and operations can answer "which contacts failed last night" with a single query.
The trade-off is concurrency. Only 5 batch jobs can be queued or running at once per org. If you already have analytics rollups, dedupe routines, and report exports competing for those slots, your nightly sync may sit in the holding queue. Queueable doesn't have the 5-job ceiling — it has chain depth instead. Pick your constraint.
Decision: 50k Records with Observable Partial Failure
Map the requirements onto the trade-off table:
- 50,000 records put you well past anything
@futurecan handle cleanly. - Partial failure must be observable. Operations needs per-record status, not just a binary "job done".
- Callouts to a downstream CRM, ideally one per record so failures land on individual contacts.
- A predictable nightly window with retry on the next night if a chunk fails.
@future is out on requirements 1 and 2. Queueable and Batch both clear the bar. The differentiator is the exact shape of requirement 2.
Pick Batch Apex when you want one AsyncApexJob row per nightly run, a single Sync_Run_Summary__c written at the end, and the built-in batch dashboard as the operator UI. The 5-concurrent-job ceiling is acceptable here because the sync runs once a day and isn't racing other batches.
Pick Queueable when you need stateful chaining with arbitrary payloads — for example, when the sync depends on a custom risk score computed in an earlier chain link, or when you want to enqueue from inside a trigger without committing to a Database.executeBatch invocation. Queueables also pass enum and Map<String, Object> payloads cleanly, which Batch can't do without staging them through a custom object first.
For the canonical 50k CRM sync scenario I opened with, Batch Apex is the default answer. The implementation above gives you a Sync_Run_Summary__c per run with Processed__c and Failed__c counters, plus a Sync_Log__c row per record carrying the job id, status code, and error message when applicable. That's the observable partial failure the requirement demanded, and it costs roughly 200 lines of Apex plus two custom objects.
Edge Cases and Watch-outs
A few traps I've personally tripped over that don't always make it into the quickstart guides:
Test.startTest()/Test.stopTest()only completes one chained queueable per pair. To test a chain of three, you either need three pairs (which the compiler doesn't allow) or you stub out the chain with a flag and assert each link in isolation. Plan your test architecture before you commit to chaining depth.start()in Batch Apex does NOT support callouts. Onlyexecute()andfinish()do. If your source data lives in the downstream CRM rather than in Salesforce, you can't call out instart(). You either pre-stage the ids in a custom object via an earlier queueable, or you switch the whole pipeline to Queueable.- Batch jobs can't enqueue queueables from
execute(). The handoff from batch to queueable happens infinish()viaSystem.enqueueJob(). If you want a "Batch processes 50k records, then a Queueable does a single rollup callout" pattern, that rollup goes infinish(). - The 12 MB heap is per
executeinvocation, not per chunk row. A chunk of 200 records carrying 4 KB of state each is 800 KB. Adding child-relationship rollups in the SOQL select clause can push that past 12 MB without warning. Profile withLimits.getHeapSize()in production-shaped sandboxes before you size chunks above 100. - A working CRM sync should implement idempotency at the downstream API. If a chunk fails mid-flight, the next-night retry will re-send some records that already succeeded. The downstream CRM needs a stable external id — the Salesforce Contact id is the obvious choice — so a duplicate POST is a no-op rather than a duplicate record.
The decision matrix isn't about which primitive is "better" in the abstract. It's about which one matches the observability story your operations team needs. For 50,000 records with per-record audit and a once-nightly cadence, Batch Apex is the right tool. For mid-volume work with rich custom payloads and chained state, Queueable is the right tool. For new code in 2026, @future is almost never the right tool.
References: