Future methods were Salesforce's first answer to asynchronous Apex. They work, but they have a critical limitation: you can't pass sObjects into them, you can't chain them, and you have no way to track or monitor the job. Queueable Apex was built to fix all of that.
A Queueable class implements the Queueable interface, gets submitted with System.enqueueJob(), and runs in its own asynchronous transaction with higher limits than synchronous code — and higher limits than future methods on several dimensions.
There is exactly one method to implement:
public class ProcessAccountsJob implements Queueable {
public void execute(QueueableContext ctx) {
// your logic here
}
}
QueueableContext gives you access to the job ID — the same ID you'd see in Setup → Apex Jobs. You can store it, log it, or use it to track progress from another process.
To run it, call System.enqueueJob() and pass an instance of your class:
Id jobId = System.enqueueJob(new ProcessAccountsJob());
System.debug('Job ID: ' + jobId);
That's it. The job is queued and will run as soon as a worker thread is available. You get the job ID back synchronously, so you can store it if needed.
Unlike future methods, Queueable classes are objects. You can give them properties and set them through the constructor. This is the right pattern for passing complex data:
public class UpdateContactsJob implements Queueable {
private List<Contact> contacts;
private String newStatus;
public UpdateContactsJob(List<Contact> contacts, String newStatus) {
this.contacts = contacts;
this.newStatus = newStatus;
}
public void execute(QueueableContext ctx) {
for (Contact c : contacts) {
c.Status__c = newStatus;
}
update contacts;
}
}
Calling it:
List<Contact> toUpdate = [SELECT Id, Status__c FROM Contact WHERE AccountId = :acctId];
System.enqueueJob(new UpdateContactsJob(toUpdate, 'Active'));
You can pass sObjects, lists, maps — anything. Future methods can't do this. That's the most practical difference day to day.
Queueable jobs can enqueue another job from within their own execute() method. This is called chaining, and it's the mechanism for processing large datasets in stages without hitting limits:
public class StageOneJob implements Queueable {
public void execute(QueueableContext ctx) {
// do stage one work
List<Account> accounts = [SELECT Id FROM Account WHERE Stage__c = 'Pending' LIMIT 200];
for (Account a : accounts) {
a.Stage__c = 'Processing';
}
update accounts;
// enqueue stage two
if (!Test.isRunningTest()) {
System.enqueueJob(new StageTwoJob(accounts));
}
}
}
The Test.isRunningTest() guard is important — you can only enqueue one Queueable per transaction in a test context, so the chained enqueue must be skipped to keep your test from failing with a limit error.
If you want to track the running job or log it against a record, pull the job ID from the context object:
public void execute(QueueableContext ctx) {
Id jobId = ctx.getJobId();
// Log to a custom object
Job_Log__c log = new Job_Log__c(
Job_Id__c = jobId,
Status__c = 'Running',
Started_At__c = System.now()
);
insert log;
// ... do work ...
log.Status__c = 'Completed';
log.Ended_At__c = System.now();
update log;
}
This pattern is useful in long-running pipelines where you want visibility into what's happening — not just "did it run" but "when did it start, when did it finish, what state is it in now".
If your Queueable job needs to make HTTP callouts (REST or SOAP to external systems), add the Database.AllowsCallouts marker interface:
public class SyncToExternalSystem implements Queueable, Database.AllowsCallouts {
private List<Id> recordIds;
public SyncToExternalSystem(List<Id> recordIds) {
this.recordIds = recordIds;
}
public void execute(QueueableContext ctx) {
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:MyNamedCredential/api/sync');
req.setMethod('POST');
// build body, send, handle response...
HttpResponse res = http.send(req);
System.debug(res.getStatusCode());
}
}
Without Database.AllowsCallouts, any callout inside a Queueable will throw a CalloutException at runtime. With it, callouts work exactly as they do in synchronous Apex — because the Queueable runs in its own transaction.
Queueable jobs run with the same limits as other asynchronous Apex contexts — higher than synchronous Apex on several dimensions:
| Limit | Sync Apex | Queueable / Future / Batch Execute |
|---|---|---|
| SOQL queries | 100 | 200 |
| DML statements | 150 | 150 |
| DML rows | 10,000 | 10,000 |
| CPU time | 10,000ms | 60,000ms |
| Heap size | 6MB | 12MB |
| Callouts per transaction | 100 | 100 |
The CPU time and heap limits are the ones that matter most. Queueable gives you 6× the CPU time of sync Apex — enough to process meaningful volumes without hitting the wall.
One important constraint: in production, you can only have 50 Queueable jobs queued per org at any one time. In a test, you can only enqueue one Queueable per Test.startTest()/stopTest() block (which is why chaining requires the Test.isRunningTest() guard).
Three asynchronous options in Apex. The decision tree is straightforward:
Use a Future method when you need simple fire-and-forget logic — no complex state, no chaining, no monitoring. The main use case today is making callouts from a trigger context (triggers can't call callouts directly; future methods can).
Use Queueable when you need to pass complex data (sObjects, lists, maps), want the job ID for tracking, need to chain stages, or need callouts with more control. Queueable is the modern replacement for future methods in almost every scenario.
Use Batch when you need to process more than ~200 records. Batch's start() method returns a QueryLocator that can iterate up to 50 million records, chunking them into execute() calls of up to 200 records each. Batch is the right tool for mass data operations — not for background jobs that happen to touch a few records.
| Feature | Future | Queueable | Batch |
|---|---|---|---|
| Pass sObjects | ✗ | ✓ | ✓ |
| Chain jobs | ✗ | ✓ | ✗ |
| Get job ID | ✗ | ✓ | ✓ |
| Callouts | ✓ | ✓ (with marker) | ✓ (with marker) |
| Process millions of records | ✗ | ✗ | ✓ |
| Schedule via CRON | ✗ | ✗ | ✓ (via Schedulable) |
Wrap the enqueue call in Test.startTest() and Test.stopTest(). The stopTest() call forces all queued async work to execute synchronously — so by the time you assert, the job has run:
@isTest
private class ProcessAccountsJobTest {
@TestSetup
static void setup() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 5; i++) {
accounts.add(new Account(Name = 'Test Acct ' + i, Status__c = 'Pending'));
}
insert accounts;
}
@isTest
static void testJobProcessesAccounts() {
List<Account> pending = [SELECT Id, Status__c FROM Account WHERE Status__c = 'Pending'];
System.assertEquals(5, pending.size(), 'Setup should have inserted 5 pending accounts');
Test.startTest();
System.enqueueJob(new ProcessAccountsJob());
Test.stopTest();
List<Account> processed = [SELECT Id, Status__c FROM Account WHERE Status__c = 'Processed'];
System.assertEquals(5, processed.size(), 'All accounts should be processed after job runs');
}
}
One common mistake: asserting before Test.stopTest(). At that point the job has been queued but not executed — your assert will fail against the pre-job state. Always assert after stopTest().
A Queueable that enriches newly created leads by calling an external data provider and updating the lead record:
public class EnrichLeadJob implements Queueable, Database.AllowsCallouts {
private List<Lead> leads;
public EnrichLeadJob(List<Lead> leads) {
this.leads = leads;
}
public void execute(QueueableContext ctx) {
List<Lead> toUpdate = new List<Lead>();
for (Lead lead : leads) {
String enrichedTitle = fetchTitle(lead.Email);
if (enrichedTitle != null) {
lead.Title = enrichedTitle;
toUpdate.add(lead);
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
}
private String fetchTitle(String email) {
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:LeadEnrichment/api/lookup?email=' + EncodingUtil.urlEncode(email, 'UTF-8'));
req.setMethod('GET');
req.setTimeout(10000);
try {
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
return (String) body.get('title');
}
} catch (Exception e) {
System.debug('Enrichment failed for ' + email + ': ' + e.getMessage());
}
return null;
}
}
This is exactly what Queueable is designed for. The constructor accepts data, the execute method works with it, the callout interface is explicit, and the logic is testable with a mock HTTP callout.
The pattern is simple: implement the interface, pass your data through the constructor, submit with System.enqueueJob(). Use it whenever you need async processing with real data — not just primitive values. If you need chaining, add Database.AllowsCallouts if you need callouts, get the job ID from context if you need tracking. That covers 95% of what you'll encounter in production.
Future methods aren't wrong — they're just limited. Queueable is what you reach for when those limits matter.