Standard Apex vs Async Lib
If you already know how to write a Queueable, a Database.Batchable, or a Schedulable in plain Apex, this page maps each of those to its Async Lib equivalent. Same concepts, less boilerplate, no "Too many queueable jobs" errors.
Async Lib only wraps the enqueue, chain, and schedule parts. Your business logic stays where it always was, so things like Database.Stateful, QueryLocator, and finalizer context work exactly the same.
At a glance
| Standard Apex | Async Lib |
|---|---|
implements Queueable + execute(context) | extends QueueableJob + work() |
System.enqueueJob(job) | Async.queueable(job).enqueue() |
Enqueue next job inside execute() | .chain(new NextJob()) |
| 1 child queueable per transaction (hard limit) | Automatic overflow to a scheduled batch, no limit |
System.attachFinalizer + implements Finalizer | extends QueueableJob.Finalizer + attachFinalizer() |
| Hand-rolled paging over a list or cursor | Async.chunk(job, source).chunkSize(200).enqueue() |
| Track retries yourself to log a final failure | override onFinalFailure(Async.FailureContext) |
Database.executeBatch(job, scope) | Async.batchable(job).scopeSize(scope).execute() |
implements Schedulable + System.schedule | Async.schedulable(job).cronExpression(...).schedule() |
| Hand-written cron string | CronBuilder fluent helpers |
Queueable
Defining a job
In standard Apex you implements Queueable and put your logic in execute(QueueableContext). With Async Lib you extends QueueableJob and override work(). The Salesforce QueueableContext is still available through the job context.
Standard Apex
public class AccountProcessorJob implements Queueable {
private List<Id> accountIds;
public AccountProcessorJob(List<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// ... process accounts ...
update accounts;
}
}Async Lib
public class AccountProcessorJob extends QueueableJob {
private List<Id> accountIds;
public AccountProcessorJob(List<Id> accountIds) {
this.accountIds = accountIds;
}
public override void work() {
QueueableContext context = Async.getQueueableJobContext().queueableCtx;
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// ... process accounts ...
update accounts;
}
}Enqueuing
Standard Apex
System.enqueueJob(new AccountProcessorJob(accountIds));Async Lib
Async.queueable(new AccountProcessorJob(accountIds))
.priority(5)
.enqueue();The builder adds options you'd otherwise hand-roll: priority, delay, retry, rollback/continue-on-failure, and more. See the Queueable API for the full list.
Chaining jobs
Standard Apex lets you enqueue one child queueable from inside a running queueable. Go past that and you hit System.AsyncException: Too many queueable jobs added to the queue: 2. Async Lib chains as many jobs as you want and automatically overflows to a scheduled batch when the platform limit is reached.
Standard Apex
public class FirstJob implements Queueable {
public void execute(QueueableContext context) {
// ... work ...
System.enqueueJob(new SecondJob()); // only one allowed per transaction
}
}Async Lib
Async.queueable(new FirstJob())
.chain(new SecondJob())
.chain(new ThirdJob())
.enqueue();Async Lib also adds dependsOn(...) so a chained job can run only when an earlier one succeeded, failed, or finished. There is no standard-Apex equivalent.
Failure behaves the same as standard Apex: if FirstJob throws, the jobs it chained inside work() are rolled back with the transaction, exactly as System.enqueueJob would be. Jobs that were already in the chain still run, which is where dependsOn(...) comes in. See What a Failed Job Does to the Chain.
Processing a large data set
In standard Apex you carry the position yourself: slice the list, enqueue the next job with the new offset, and remember where you were.
Standard Apex
public class RecalcJob implements Queueable {
private List<Id> ids;
private Integer position;
public void execute(QueueableContext context) {
List<Id> page = new List<Id>();
for (Integer i = position; i < Math.min(position + 200, ids.size()); i++) {
page.add(ids[i]);
}
// ... work on page ...
if (position + 200 < ids.size()) {
System.enqueueJob(new RecalcJob(ids, position + 200)); // one chance, no tracking
}
}
}Async Lib
public class RecalcJob extends ChunkJob {
public override void work(List<SObject> chunk) {
// ... work on chunk ...
}
}
Async.chunk(new RecalcJob(), ChunkSource.of(accounts)).chunkSize(200).enqueue();The framework carries the position, retries a page that throws, records an AsyncResult__c per page, and can page a Database.Cursor instead of a list when the set is too big to hold in memory. See the Chunk API.
Your job keeps its own members across the whole run, so a running total or a map built on one page is still there on the next one. This is what Database.Stateful gives a batch, except you do not have to ask for it.
public class RevenueRollupJob extends ChunkJob {
private Map<Id, Decimal> revenueByOwner = new Map<Id, Decimal>();
private Integer processed = 0;
public override void work(List<SObject> chunk) {
for (Opportunity opp : (List<Opportunity>) chunk) {
Decimal current = revenueByOwner.get(opp.OwnerId);
revenueByOwner.put(opp.OwnerId, (current == null ? 0 : current) + opp.Amount);
}
processed += chunk.size();
}
}Each page starts from the state the previous page left behind, so revenueByOwner keeps growing and processed keeps counting for the length of the run. Keep the members serializable and keep them small: they travel with the job on every hop.
Finalizers
A finalizer runs after the job completes, whether it succeeded or threw. The shape is the same in both worlds; Async Lib just attaches it through the builder and exposes the FinalizerContext through the job context.
Standard Apex
public class CleanupFinalizer implements Finalizer {
public void execute(FinalizerContext context) {
if (context.getResult() == ParentJobResult.SUCCESS) {
System.debug('Job succeeded');
} else {
System.debug('Job failed: ' + context.getException().getMessage());
}
}
}
public class MainJob implements Queueable {
public void execute(QueueableContext context) {
System.attachFinalizer(new CleanupFinalizer());
// ... work ...
}
}Async Lib
public class CleanupFinalizer extends QueueableJob.Finalizer {
public override void work() {
FinalizerContext context = Async.getQueueableJobContext().finalizerCtx;
if (context.getResult() == ParentJobResult.SUCCESS) {
System.debug('Job succeeded');
} else {
System.debug('Job failed: ' + context.getException().getMessage());
}
}
}
public class MainJob extends QueueableJob {
public override void work() {
Async.queueable(new CleanupFinalizer()).attachFinalizer();
// ... work ...
}
}Reacting to a job that failed for good
A finalizer tells you the job ended. It does not tell you whether a retry is still coming, and it cannot see a failure your own catch swallowed. Logging from one means logging on every attempt and hoping the last one wins.
Standard Apex
public class SyncJob implements Queueable {
public void execute(QueueableContext context) {
System.attachFinalizer(new LogFinalizer());
try {
doWork();
} catch (Exception ex) {
// Committing partial work means the finalizer sees SUCCESS and
// context.getException() is null, so this catch is the only place
// that knows anything failed. Retry state is yours to track too.
insert new IntegrationLog__c(Message__c = ex.getMessage());
}
}
}Async Lib
public class SyncJob extends QueueableJob {
public override void work() {
doWork();
}
public override void onFinalFailure(Async.FailureContext failureCtx) {
insert new IntegrationLog__c(
Message__c = failureCtx.failure?.message,
StackTrace__c = failureCtx.failure?.stackTrace,
Outcome__c = failureCtx.retryOutcome.name(),
Attempts__c = failureCtx.retryAttempt,
History__c = failureCtx.retryHistory
);
}
}onFinalFailure fires once, only when the job will not run again, and it fires whether the exception propagated or was swallowed by continueOnJobExecuteFail. See onFinalFailure.
Batchable
Your batch class does not change. It is a normal Database.Batchable<SObject> with start(), execute(), and finish(). Async Lib only replaces the Database.executeBatch(...) call, adding scheduling and result tracking on top.
Standard Apex
Database.executeBatch(new AccountCleanupBatch(), 200);Async Lib
Async.batchable(new AccountCleanupBatch())
.scopeSize(200)
.execute();Does Database.Stateful work the same?
Yes. State is kept on your batch class, which Async Lib never touches. Implement Database.Stateful exactly as you do today.
public class AccountCleanupBatch implements Database.Batchable<SObject>, Database.Stateful {
public Integer deletedCount = 0;
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id FROM Account WHERE IsActive__c = false');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
delete scope;
deletedCount += scope.size(); // preserved across batches by Database.Stateful
}
public void finish(Database.BatchableContext bc) {
System.debug('Deleted ' + deletedCount + ' accounts');
}
}How do I use QueryLocator?
The same way. Return it from start() as usual (see the example above). Async Lib hands your job straight to Database.executeBatch, so the query locator, chunking, and 50-million-row limit all behave identically.
Should I move my logic into work()?
No. work() belongs to queueable jobs (QueueableJob). A batch keeps its start() / execute() / finish() methods. The only thing that moves is how you kick it off: Async.batchable(job).execute() instead of Database.executeBatch(job).
Schedulable
Your Schedulable class is unchanged. Async Lib wraps System.schedule, builds the cron expression for you, and can skip scheduling when a job of the same name already exists (so you don't have to catch the "already scheduled" exception).
Standard Apex
public class NightlyJob implements Schedulable {
public void execute(SchedulableContext context) {
// ... work ...
}
}
// daily at 02:00 — cron string written by hand
System.schedule('Nightly Job', '0 0 2 * * ? *', new NightlyJob());Async Lib
Async.schedulable(new NightlyJob())
.name('Nightly Job')
.cronExpression('0 0 2 * * ? *')
.skipWhenAlreadyScheduled()
.schedule();Building the cron expression
Instead of remembering cron field order, use CronBuilder.
Standard Apex
// every day at 02:00
System.schedule('Nightly Job', '0 0 2 * * ? *', new NightlyJob());Async Lib
Async.schedulable(new NightlyJob())
.name('Nightly Job')
.cronExpression(new CronBuilder().everyDay(2, 0))
.schedule();See the Schedulable API for the full set of CronBuilder helpers (everyHour, everyXHours, everyMonth, and so on).
Scheduling a queueable or batch
Standard Apex has no direct way to schedule a queueable. With Async Lib, any queueable or batch builder converts to a schedulable with asSchedulable().
Async.queueable(new AccountProcessorJob(accountIds))
.asSchedulable()
.name('Hourly Account Processing')
.cronExpression(new CronBuilder().everyHour(0))
.schedule();