Skip to content

Salesforce Development Fundamentals: Part 4 - Asynchronous Apex

Updated 24/04/2026

Salesforce Dev Hero Image

In Part 3, you learned how to write triggers that respect governor limits and process data in bulk. But everything you wrote ran synchronously while the user waited, staring at a spinner. That’s fine for most trigger logic, but some operations don’t belong on the synchronous path:

  • Processing tens of thousands of records that would blow past the 50,000 query-row limit.
  • Making HTTP callouts to external systems (which are flat-out blocked inside a synchronous trigger).
  • Running a nightly job to recalculate aggregates or clean up stale data.
  • Chaining complex multi-step operations that would exhaust CPU time in a single transaction.

This is the domain of Asynchronous Apex. Instead of executing immediately and blocking the user, asynchronous code is placed on a queue and executed by the platform when resources are available, usually within seconds, sometimes minutes.

Salesforce provides four async mechanisms, each designed for a different use case:

MechanismBest ForCan Chain?Accepts sObjects?Governor Limit Boost
@futureSimple fire-and-forget tasks, callouts from triggersNoNo (primitives only)Higher heap & CPU
QueueableComplex jobs, chaining, passing objectsYesYesHigher heap & CPU
BatchProcessing thousands to millions of recordsVia finish()YesFresh limits per chunk
ScheduledTime-based execution (cron jobs)Launches other asyncYesNo (standard limits)

A future method is the simplest form of async Apex. You annotate a static void method with @future, and Salesforce runs it in a separate transaction after the current one commits.

public class AccountCleanupService {
@future
public static void normaliseWebsites(Set<Id> accountIds) {
List<Account> accounts = [
SELECT Id, Website
FROM Account
WHERE Id IN :accountIds AND Website != null
];
for (Account acc : accounts) {
// Ensure websites start with https://
if (!acc.Website.startsWith('https://')) {
acc.Website = 'https://' + acc.Website.removeStart('http://');
}
}
update accounts;
}
}

The rules for a future method are straightforward:

  • It must be static and void (it can’t return a value because the caller doesn’t wait for it).
  • Parameters must be primitive types or collections of primitives (Set<Id>, List<String>, etc.). You cannot pass sObjects or custom Apex types.
  • Because you can’t pass sObjects, you typically pass a Set<Id> and re-query the records inside the method (as shown above).

Call it from a trigger handler just like any other static method:

// Inside your trigger handler
AccountCleanupService.normaliseWebsites(accountIds);

The method returns immediately but the actual work happens later.

One of the most common uses of @future is making HTTP callouts from a trigger context. Synchronous triggers cannot make callouts, but a future method can if the callout=true parameter is set in the annotation, e.g. @future(callout=true). Without it, Salesforce will throw an exception at runtime:

public class OrderNotificationService {
@future(callout=true)
public static void notifyWarehouse(Set<Id> orderIds) {
List<Order> orders = [SELECT Id, OrderNumber, Status FROM Order WHERE Id IN :orderIds];
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Warehouse_API/v1/orders/notify');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(orders));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
System.debug(LoggingLevel.ERROR, 'Warehouse notification failed: ' + res.getBody());
}
}
}

Future methods are simple by design, but that simplicity comes with real constraints:

  • No chaining: A future method cannot call another @future method. If you need sequential async steps, use Queueable instead.
  • No calling from Batch: You cannot invoke a @future method from inside a Batch Apex execute() method. Use Queueable if you need async work from a batch context.
  • No monitoring: You don’t get a job ID back, so there’s no built-in way to track progress or check completion.
  • Limit: You can enqueue a maximum of 50 future calls per transaction. There’s no per-class limit, but 50 across the entire transaction is the ceiling.
  • No guaranteed order: If you enqueue multiple future methods, Salesforce does not guarantee the order they execute in.

For a complete reference on @future method syntax, rules, and edge cases, see the official Future Methods developer guide.


Queueable Apex is the more powerful alternative to @future. You implement the Queueable interface, and Salesforce gives you a proper job with an ID, the ability to pass complex objects, and the option to chain jobs together.

Here’s the basic structure of a Queueable class. This example takes a list of Account IDs, calls an external enrichment API for each one, and updates the records with the returned data:

public class AccountEnrichmentJob implements Queueable, Database.AllowsCallouts {
private List<Id> accountIds;
public AccountEnrichmentJob(List<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [
SELECT Id, Name, BillingCity, Industry
FROM Account
WHERE Id IN :accountIds
];
// Enrich accounts with data from an external API
for (Account acc : accounts) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Enrichment_API/v1/company?name=' + EncodingUtil.urlEncode(acc.Name, 'UTF-8'));
req.setMethod('GET');
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
acc.Industry = (String) body.get('industry');
acc.NumberOfEmployees = (Integer) body.get('employeeCount');
}
}
update accounts;
}
}

The class implements the Queueable interface, which requires a single method: execute(QueueableContext context). This is where all your job logic lives. The constructor accepts whatever data the job needs to work with (in this case, a list of Account IDs), and because Queueable supports complex types, you can pass sObjects, custom classes, or any other Apex type. Adding Database.AllowsCallouts as a second interface tells Salesforce this job is permitted to make HTTP callouts.

You enqueue the job with System.enqueueJob, which returns an AsyncApexJob ID you can use to monitor progress:

// From a trigger handler or any Apex context
Id jobId = System.enqueueJob(new AccountEnrichmentJob(accountIds));
System.debug('Enqueued enrichment job: ' + jobId);

You can check the job’s status by querying the AsyncApexJob object:

AsyncApexJob job = [
SELECT Id, Status, NumberOfErrors, JobItemsProcessed
FROM AsyncApexJob
WHERE Id = :jobId
];
System.debug('Job status: ' + job.Status);

One of Queueable’s biggest advantages over @future is the ability to chain, enqueuing the next job from within the execute method of the current one:

public class Step1Job implements Queueable {
public void execute(QueueableContext context) {
// ... do step 1 work ...
// Chain to step 2
System.enqueueJob(new Step2Job());
}
}
public class Step2Job implements Queueable {
public void execute(QueueableContext context) {
// ... do step 2 work ...
// Chain to step 3, or stop here
if (moreWorkToDo) {
System.enqueueJob(new Step3Job());
}
}
}

⚖️ Queueable vs. @future — When to Choose Which

Section titled “⚖️ Queueable vs. @future — When to Choose Which”
Consideration@futureQueueable
Pass complex objects / sObjects
Chain to another async job
Get a job ID for monitoring
Maximum per transaction5050
Simplest possible syntaxSlightly more setup

Rule of thumb: If you just need to make a quick callout or do a small background update with primitive IDs, @future is fine. For anything more complex, reach for Queueable.

For a complete reference on the Queueable interface, chaining rules, and advanced usage, see the official Queueable Apex developer guide:


When you need to process thousands or even millions of records, @future won’t scale and while Queueable could technically handle it through chaining, you’d have to manually split the work and enqueue each step yourself. Batch Apex solves this natively. You give it a query, and the platform automatically breaks the results into chunks, with each chunk running in its own transaction and getting a fresh set of governor limits. It can also query up to 50 million rows, far beyond the 50,000 row limit that applies to @future and Queueable.

To run batch apex, you implement the Database.Batchable<sObject> interface, which requires three methods:

  1. start() — Return a Database.QueryLocator (or Iterable) that defines the full dataset to process. This is where Batch gets its superpower: a QueryLocator can retrieve up to 50 million rows.

  2. execute() — Called once per chunk, receiving a List<sObject> of records (default 200 per chunk, configurable up to 2,000). Each invocation runs in its own transaction with a fresh set of governor limits.

  3. finish() — Called once after all chunks have been processed. Use it for post-processing tasks like sending a summary email, logging results, or chaining to another batch or queueable job.

public class InactiveAccountCleanupBatch implements Database.Batchable<sObject> {
// 1. START — define the dataset
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, LastActivityDate
FROM Account
WHERE LastActivityDate < LAST_N_DAYS:365
AND IsActive__c = true
]);
}
// 2. EXECUTE — process each chunk (default 200 records)
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
acc.IsActive__c = false;
acc.Description = 'Marked inactive by cleanup batch on ' + Date.today();
}
// Use Database.update for partial success in batch contexts
Database.update(scope, false);
}
// 3. FINISH — post-processing
public void finish(Database.BatchableContext bc) {
// Query the job to get summary info
AsyncApexJob job = [
SELECT TotalJobItems, JobItemsProcessed, NumberOfErrors
FROM AsyncApexJob
WHERE Id = :bc.getJobId()
];
System.debug('Batch complete. Processed: ' + job.JobItemsProcessed
+ ' chunks, Errors: ' + job.NumberOfErrors);
// Optionally chain to another batch or send a notification
}
}

Execute a batch with Database.executeBatch. The optional second parameter controls the chunk size:

// Default chunk size (200 records per execute() call)
Id batchId = Database.executeBatch(new InactiveAccountCleanupBatch());
// Custom chunk size — smaller chunks if each record does heavy processing
Id batchId = Database.executeBatch(new InactiveAccountCleanupBatch(), 50);

To make HTTP callouts from a batch, implement Database.AllowsCallouts alongside Database.Batchable:

public class AccountVerificationBatch
implements Database.Batchable<sObject>, Database.AllowsCallouts {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, Website FROM Account WHERE Verified__c = false
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
// Each callout counts against the 100-per-transaction limit
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Verification_API/v1/verify?domain='
+ EncodingUtil.urlEncode(acc.Website, 'UTF-8'));
req.setMethod('GET');
HttpResponse res = new Http().send(req);
acc.Verified__c = (res.getStatusCode() == 200);
}
Database.update(scope, false);
}
public void finish(Database.BatchableContext bc) {
System.debug('Verification batch complete.');
}
}

There are a few important limits to keep in mind when working with Batch Apex:

  • 5 concurrent batches: You can only have 5 batch jobs queued or active at a time per org.
  • start() uses standard limits: The start() method runs with standard governor limits, not the elevated async limits. The 50-million-row allowance only applies to Database.QueryLocator, not to your other logic in start().
  • Stateless by default: Member variables reset between each execute() call. If you need to maintain state across chunks (e.g., a running error count), implement Database.Stateful, which is covered below.

By default, batch Apex is stateless. Because each execute() invocation runs in its own transaction, member variables are reset between chunks. That means if you declare an integer counter and increment it in one chunk, it will be back to zero when the next chunk starts.

If you need to accumulate data across chunks, like counting errors, collecting failed record IDs, or building a summary for a notification email, implement Database.Stateful alongside Database.Batchable. This tells Salesforce to serialize and preserve your instance variables between execute() calls:

public class ErrorTrackingBatch
implements Database.Batchable<sObject>, Database.Stateful {
// These variables persist across execute() calls because of Database.Stateful.
// Without it, both would reset to their initial values before each chunk.
public Integer totalErrors = 0;
public List<String> failedAccountNames = new List<String>();
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name FROM Account WHERE NeedsUpdate__c = true
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
// ... process records ...
// Use Database.update with allOrNone = false so one failure
// doesn't roll back the entire chunk
Database.SaveResult[] results = Database.update(scope, false);
// Loop through results to track which records failed
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
totalErrors++;
failedAccountNames.add(scope[i].Name);
}
}
}
public void finish(Database.BatchableContext bc) {
// By the time finish() runs, totalErrors and failedAccountNames
// contain the accumulated data from every chunk
System.debug('Total errors across all chunks: ' + totalErrors);
if (!failedAccountNames.isEmpty()) {
System.debug('Failed accounts: ' + String.join(failedAccountNames, ', '));
}
}
}

For full details on the Database.Batchable interface, stateful batches, and advanced patterns, see the official Batch Apex developer guide:


Scheduled Apex lets you run code automatically at a specific time or on a recurring schedule, such as every night at 2 AM or on the first day of each month. If you’ve used scheduled tasks in Windows or cron jobs in Linux, it’s the same concept.

To set this up, you implement the Schedulable interface and define an execute method that contains the logic you want to run on schedule.

public class NightlyCleanupScheduler implements Schedulable {
public void execute(SchedulableContext sc) {
// Kick off the batch job every night
Database.executeBatch(new InactiveAccountCleanupBatch(), 200);
}
}

You can schedule a job in two ways: programmatically using System.schedule with a cron expression, or through the UI in Setup → Apex Classes by clicking the Schedule Apex button.

Here’s the programmatic approach, which gives you full control over the schedule:

// Run every night at 2:00 AM
String cronExpression = '0 0 2 * * ?';
String jobId = System.schedule('Nightly Account Cleanup', cronExpression, new NightlyCleanupScheduler());

A cron expression is a string that defines when a scheduled job should run. Salesforce cron expressions have seven fields, separated by spaces, read left to right:

Seconds Minutes Hours Day-of-Month Month Day-of-Week Year(optional)
0 0 2 * * ?

This example reads: at 0 seconds, 0 minutes, hour 2 (2 AM), on every day of the month, in every month, on any day of the week.

Here’s what each field accepts:

FieldPositionValuesExample
Seconds1st0–590
Minutes2nd0–590
Hours3rd0–232 (2 AM)
Day of Month4th1–31, ?, L, W* (every day)
Month5th1–12 or JANDEC* (every month)
Day of Week6th1–7 or SUNSAT, ?, L, #? (any day)
Year (optional)7th1970–2099

Here are some common patterns:

// Every weekday at 6:30 AM
'0 30 6 ? * MON-FRI'
// First day of every month at midnight
'0 0 0 1 * ?'
// Every hour on the hour
'0 0 * * * ?'

Once a job is scheduled, you’ll want to check on it or cancel it if something changes. You can do this through the UI by navigating to Setup → Scheduled Jobs, which shows a list of all scheduled jobs with their next run time, status, and the user who created them.

You can also manage them programmatically. Scheduled jobs are stored in the CronTrigger object, and you can query it to see what’s currently scheduled:

// List all scheduled Apex jobs (JobType '7' = scheduled Apex)
List<CronTrigger> jobs = [
SELECT Id, CronJobDetail.Name, State, NextFireTime
FROM CronTrigger
WHERE CronJobDetail.JobType = '7'
];
// Abort a scheduled job by its ID
System.abortJob(jobId);

There are a couple of important limits to be aware of:

  • 100 job cap: You can have a maximum of 100 scheduled Apex jobs at any time in an org.
  • Standard governor limits: Unlike @future and Queueable, scheduled jobs don’t get elevated governor limits. They run with the same synchronous limits as regular Apex. This is why most Scheduled Apex classes are lightweight: they simply launch a Batch or Queueable job that does the heavy lifting with elevated limits.

For the full reference on the Schedulable interface, cron syntax, and scheduling limits, see the official Scheduled Apex developer guide:


With four options available, how do you decide which one to use? Here’s a decision flow:

  1. Do you need to run at a specific time? → Use Scheduled Apex (which typically launches a Batch or Queueable inside its execute method).

  2. Are you processing more than 50,000 records? → Use Batch Apex. It’s the only mechanism that can query up to 50 million rows and process them in governor-limit-safe chunks.

  3. Do you need to chain jobs, pass complex objects, or monitor progress? → Use Queueable Apex.

  4. Is it a simple, one-off background task with only primitive parameters? → Use @future.

In practice, most production orgs use a combination:

  • A Scheduled job runs nightly and kicks off a Batch to process large datasets.
  • A trigger handler enqueues a Queueable to make a callout or perform a complex multi-step operation.
  • A quick @future method handles a Mixed DML workaround. Salesforce doesn’t allow you to update certain setup objects (like User or PermissionSet) and regular data objects (like Account) in the same transaction. By pushing one of the operations into a @future method, it runs in a separate transaction and the conflict is avoided.

If you want hands-on practice with all four async patterns, the Asynchronous Apex Trailhead module walks you through each one with interactive challenges:


Async jobs don’t give you immediate feedback like synchronous code. When a trigger runs, you see the result right away: the record saves, or you get an error. But when you enqueue a Queueable or kick off a Batch, the code runs in the background and you won’t know whether it succeeded or failed unless you actively check. This makes monitoring and debugging async jobs an essential skill. Here’s how to keep track of them.

All async Apex jobs (Batch, Queueable, Future, Scheduled) are tracked in the AsyncApexJob object. You can query it to see what’s running, what completed, and what failed:

List<AsyncApexJob> recentJobs = [
SELECT Id, ApexClass.Name, Status, JobType,
NumberOfErrors, JobItemsProcessed, TotalJobItems,
CreatedDate, CompletedDate
FROM AsyncApexJob
WHERE CreatedDate = TODAY
ORDER BY CreatedDate DESC
LIMIT 20
];

The Status field tells you where the job is in its lifecycle: Holding, Queued, Preparing, Processing, Completed, Failed, or Aborted.

Navigate to Setup → Apex Jobs to see a real-time view of all async job activity. This is often the first place to check when something isn’t working as expected.

Async Apex typically runs as the user who enqueued or scheduled it, not under a separate system user. To capture debug logs, you need a trace flag set for that user. If you’re not seeing any log output for an async job, this is almost always the reason: the trace flag is either missing or set for the wrong user.


You now have four powerful tools for moving work off the synchronous path:

  • @future for the simplest fire-and-forget tasks and quick callouts from triggers.
  • Queueable for complex jobs that need object parameters, chaining, and monitoring.
  • Batch for processing massive datasets in governor-limit-safe chunks.
  • Scheduled for time-based execution that typically launches Batch or Queueable work.

These patterns, combined with the trigger and bulkification knowledge from Part 3, give you the tools to build solutions that handle real-world data volumes and integration requirements. You can now write code that runs efficiently both when the user is watching and when it’s running quietly in the background.

These async patterns also unlock better integration designs. Instead of making a user wait while Salesforce talks to another system, you can queue callouts, process records in batches, and publish events for other systems to consume.

In Part 5, we’ll cover Integrations: Named Credentials, REST callouts, custom Apex APIs, Platform Events, Change Data Capture, and the design choices that help Salesforce communicate safely with the rest of your architecture.

Async patterns also unlock smarter integration designs. In Part 5 — Integrations, you’ll learn how Salesforce connects safely to the systems around it using Named Credentials, REST callouts, custom Apex APIs, Platform Events, and Change Data Capture — and when each pattern is the right tool.