In my Urla Shoes sandbox — the practice org I keep coming back to on this blog — I wanted something very ordinary: every time a new Contact is inserted, call an external API and enrich the record with extra details. My first instinct was to do it right there, in the trigger, while the record saves.
Salesforce said no. A callout — a request your code makes to a system outside Salesforce — is not allowed in that moment, because the record is mid-save and the platform will not let you hold an open database transaction hostage while some external server thinks about answering.
The platform’s answer to this problem is one small word: later. Do the work later, outside the user’s request. That “later” is asynchronous Apex, and today I want to walk through its three modern shapes — Queueable, Batch, and Scheduled jobs — slowly, the way I wish someone had walked me through them.
Last week I wrote about USER_MODE — making your Apex safe by being honest about who it runs as. This week is the other half of that thread: being honest about when and where it runs.
Why “later” is a feature, not a workaround
Normal Apex — the code in your triggers and controllers — is synchronous. That means it runs while the user waits, inside a single transaction: one unit of work that either fully succeeds or fully rolls back. And every transaction lives inside governor limits — the platform’s hard caps on things like queries and CPU time, there to keep one org’s code from starving everyone else on the shared servers.
Synchronous code must finish everything inside that one transaction and its limits. That is fine for “update this field.” It is terrible for “call a slow external API” or “recalculate a million records.”
Asynchronous Apex moves the work out of the user’s request. The user’s save finishes quickly; the heavy work runs afterwards, in its own separate transaction. That separation is the whole point.
Async is not a trick for dodging limits. It is a design decision: the user’s moment stays fast, and the heavy work gets its own room, with its own fresh limits.
@future: the grandfather
The oldest async tool is the @future annotation: you mark a static method, call it, and it runs “sometime soon” in the background. Fire and forget.
It still works, but it is the most limited option. A future method can only accept primitive arguments — simple values like Ids and Strings, not objects or lists of records — and one future method cannot start another. No rich state, no chaining. I mention it mainly so you recognize it in older codebases. For new work, reach for the next tool.
Queueable: one unit of background work
Queueable is what I use for almost every “do this one thing later” job. You write a class that implements the Queueable interface, and you hand it to the platform with System.enqueueJob:
public class ContactEnrichmentJob implements Queueable, Database.AllowsCallouts {
private List<Contact> contacts;
public ContactEnrichmentJob(List<Contact> contacts) {
this.contacts = contacts;
}
public void execute(QueueableContext ctx) {
// call the external API, update the contacts
}
}
// somewhere after insert:
System.enqueueJob(new ContactEnrichmentJob(newContacts));
Three things make Queueable a real upgrade over @future:
- Rich state. The class holds whatever it needs — full objects, lists of records — passed in through the constructor. No squeezing everything into primitive arguments.
- Chaining. From inside
execute, a Queueable can enqueue the next job. Step one finishes, step two begins, each in its own transaction. - Callouts. Add
Database.AllowsCalloutsto theimplementsline, and the job may talk to external systems — which is exactly what my trigger could not do.
And crucially: the job runs in its own transaction with fresh governor limits. Whatever the user’s save consumed, your background job starts clean.
This is precisely how the Urla Shoes enrichment works: the trigger stays tiny — it just enqueues the job — and the Queueable does the callout on its own time.
Batch Apex: when the data is the problem
Sometimes “later” is not enough; the volume is the problem. You cannot process millions of rows in one transaction no matter when it runs. For that, there is Batch Apex.
You implement the Database.Batchable interface, which gives your class a three-part shape:
start— you tell the platform which records you want.execute— the platform feeds you those records in chunks (the default chunk is 200 records), and each chunk runs in its own transaction with its own limits.finish— one final call when every chunk is done, good for wrap-up work like sending a summary.
That chunking is the magic. A million records is impossible in one transaction, but five thousand small transactions of 200 records each is routine. You write the logic for one chunk; the platform handles the marathon.
Schedulable: work on the clock
The last shape answers a different question — not “how do I do this later?” but “how do I do this every night at 2am?”
A class that implements the Schedulable interface can be put on a timer with System.schedule and a cron expression — a compact text format for describing recurring times (this one means every day at 2am):
System.schedule('Nightly lead routing', '0 0 2 * * ?', new NightlyLeadRouting());
Here is a pattern I lean on in production, and one I would teach any beginner early: the Schedulable schedules, and a Queueable does. The scheduled class does almost nothing itself — it simply enqueues a Queueable, and the Queueable holds the real logic. Scheduling and doing stay separate, so the same logic can also run on demand, and each piece can be tested on its own.
Two of my Urla Shoes jobs follow exactly this shape: a scheduled lead-routing job, and a retry scheduler that re-attempts failed syncs and writes an audit log of what it tried. The clock triggers; the queue works.
How do you test code that runs “later”?
This worried me at first: if the work happens sometime in the background, how can a unit test ever assert on the result?
The platform gives you a lovely trick. Wrap the action in Test.startTest() and Test.stopTest(). Any async work enqueued between those two lines is executed synchronously the moment stopTest runs. After that line, the “later” has already happened, and you can assert on the results like any normal test.
For the enrichment Queueable, I combined this with callout mocks — stand-in fake responses, since real callouts are not allowed in tests — and covered four scenarios: a successful response, an empty one, an HTTP 500 error, and a bulk insert. Async code deserves the same testing discipline as anything else; stopTest is what makes that possible.
The rule of thumb I teach: the user is waiting — stay synchronous. One unit of background work, maybe with a callout — Queueable. Millions of rows — Batch. On the clock — Schedulable, which enqueues a Queueable.
Your next step
Pick one small thing in your own org or sandbox that currently makes a user wait — or that fails because of a callout in a trigger — and move it into a Queueable. Keep the trigger down to one line: enqueue the job. Then write the test, with Test.startTest() and Test.stopTest(), and watch your “later” become assertable “now”.
One honest caveat before you go: exactly how many async jobs you can enqueue and run per day depends on your edition and contract, so verify in your org before you design around it. The shapes, though, are universal — and once you can tell the four apart, a lot of Apex architecture conversations suddenly get much easier to follow.