Tested in Summer ’26 on September 10, 2026. Salesforce changes often. If something here no longer matches your org, tell us and we will retest it.

In one sentence: asynchronous Apex doubles your SOQL queries to 200, raises CPU time from 10,000 ms to 60,000 ms, and doubles heap to 12 MB. It changes nothing about DML statements, DML rows, query rows, or callouts. Those four are identical in both contexts, which means going async fixes transactions that are heavy and does nothing for transactions whose cost grows with record count.

The full comparison

From the per-transaction limits in the Apex Developer Guide. The rows where the columns match are the important ones.

Limit Synchronous Asynchronous Change
SOQL queries issued 100 200 2×
CPU time 10,000 ms 60,000 ms 6×
Heap size 6 MB 12 MB 2×
Records retrieved by SOQL 50,000 50,000 none
DML statements issued 150 150 none
Records processed by DML 10,000 10,000 none
Records from Database.getQueryLocator 10,000 10,000 none
Total callouts 100 100 none
Cumulative callout timeout 120 s 120 s none
SOSL queries issued 20 20 none
Maximum execution time 10 minutes 10 minutes none
Methods with future annotation 50 0 worse
Jobs added with System.enqueueJob 50 1 worse
Callout request/response size 6 MB 12 MB 2×

Note the two rows that get worse. From inside an asynchronous transaction you cannot chain a future method (calling one from a future or batch context throws at runtime), and you can enqueue only one Queueable job rather than 50. Worth knowing that the reported allowance and the actual restriction disagree: measured in a Summer ’26 org, Limits.getLimitFutureCalls() returns 50 from both a future method and a Queueable, while Limits.getLimitQueueableJobs() correctly drops to 1. Do not read the future number as permission. That asymmetry exists to stop async work fanning out without bound, and it is the reason a chain of jobs is the shape async work takes rather than a tree.

What the six-times CPU increase is really for

CPU time is the limit that most often drives the decision to go async, and it is worth understanding what it measures before you count on the extra 50,000 ms.

The Apex Developer Guide states that CPU time is calculated for all execution on the Salesforce application servers in one transaction, including “any processes that are called from this code, such as package code and workflows.” It excludes database execution time for SOQL, SOSL, and DML, and excludes time spent waiting on HTTP callouts.

So the 60,000 ms is 60,000 ms of computation, not 60 seconds of wall clock. If your transaction is slow because it makes twelve sequential callouts, moving it async buys you nothing on CPU (that time was never counted), but it does get the work off the user’s save. If your transaction is slow because it loops over 200 records building Maps of Maps, async buys you six times the room, and that is genuine.

The corollary: measure with Limits.getCpuTime() before deciding. A transaction taking eight seconds of wall clock might be using 900 ms of CPU, in which case CPU is not your problem and the async ceiling is irrelevant to you.

Async raises ceilings. It does not change how your code scales

This is the part I would put on a poster. Governor limits are constants. If your code issues one SOQL query per record, then 100 records fails synchronously and 200 records fails asynchronously. You bought a factor of two and the problem is linear in a number you do not control.

// Fails at 101 records sync, at 201 records async.
// Async is not the fix.
for (Account a : accounts) {
    List<Contact> cs = [SELECT Id FROM Contact WHERE AccountId = :a.Id];
}

The four limits that do not move in async are the ones that punish poor scaling hardest. 10,000 DML rows is the same number in a Queueable as in a trigger, so a cascade that touches 12,000 records fails identically in both. 100 callouts is the same, so one callout per record fails at 101 either way.

Order of operations, then: bulkify first, and only then decide whether async is needed for the remaining cost. Async applied to unbulkified code buys you a few weeks before the same ticket comes back with a bigger record count attached.

Which flavour to use, and what each costs

Four mechanisms. They are not interchangeable and the choice has real consequences.

Queueable Batch Apex Future Schedulable
Limits reset per chunk no yes no no
Returns a job Id yes yes no yes
Accepts non-primitive params yes yes no yes
Can chain a follow-up job yes yes no yes
Callouts allowed with Database.AllowsCallouts with Database.AllowsCallouts with callout=true via a called class
Max records addressable one transaction’s worth 50 million via QueryLocator one transaction’s worth one transaction’s worth
Concurrency ceiling 50 enqueued per sync transaction 5 queued or active 50 per transaction 100 scheduled classes

Queueable is my default for new work, and Salesforce agrees — the asynchronous Apex overview says outright that “Salesforce recommends that you use Queueable Apex instead of Apex future methods.” You get a job Id you can query on AsyncApexJob, you can pass sObjects and custom types in the constructor, and you can enqueue with a delay of 0 to 10 minutes. Cost: chaining is limited to one child job per parent, and in a Developer or Trial org the maximum stack depth for chained jobs is 5, so you can chain four times. That limit does not apply in production, but it does mean a chain you test in a scratch org can behave differently from the one you ship.

Batch Apex is the only mechanism that genuinely escapes per-transaction limits, because the limits reset. The documentation states that “Apex governor limits are reset for each execution of execute” and that “each execution of a batch Apex job is considered a discrete transaction.” A Database.QueryLocator in the start method can return up to 50 million records, bypassing the 50,000-row query limit entirely. Cost: batch is slow to start, only 5 jobs can be queued or active at once (with up to 100 more held in the flex queue), you get one transaction for start and one for finish with their own limits, and debugging a job that fails on chunk 400 of 900 is genuinely tedious. Default chunk size is 200 records. The scope parameter of Database.executeBatch maxes out at 2,000 for a QueryLocator. Reduce the scope when chunks blow CPU or heap — that is the dial, and people forget it exists.

Future methods are the cheapest to write and the most constrained. Parameters must be primitives, arrays of primitives, or collections of primitives, and the return type must be void, so you pass record Ids and requery inside. You get no job Id, no chaining, and no ability to call another future method from an async context. I would not write a new one. The only reason to reach for @future(callout=true) today is that it is two lines in an existing codebase full of them, and consistency has value.

Schedulable is not a peer of the other three — it is a trigger for work rather than a way of doing it. A scheduled class runs in its own transaction with async limits, and the usual pattern is an execute method that does nothing but call Database.executeBatch. Up to 100 Apex classes can be scheduled concurrently, 5 in a Developer Edition org.

The limits async does not have that sync does not either

Async introduces its own budget, org-level rather than per-transaction. Three numbers to know.

Asynchronous Apex method executions are capped per 24 hours at 250,000 or user licences × 200, whichever is greater — shared across future methods, Queueable executions, batch chunks, and scheduled Apex. A batch job over a million records at a chunk size of 200 spends 5,000 of them in one run, which is worth the arithmetic before you set a small scope on a large job.

Concurrent long-running transactions are limited by ratio: 100 licences to 1 concurrent transaction, minimum 10, maximum 50. This one bites integrations hardest and produces failures that look random, because they depend on what else is running.

Batch concurrency is 5 queued or active jobs, with up to 100 more held in the flex queue. Fire six batch jobs from one trigger and the sixth waits.

The cost you have to name out loud

Every async decision trades consistency for headroom, and that trade belongs in the design conversation rather than a code comment.

Once work moves out of the save, the record commits and becomes visible before the async work runs. There is a window (normally seconds, longer when the queue is busy) during which the record is saved and the downstream effect has not happened. If the job throws, the record stays saved and wrong, and nothing rolls back.

So async work needs two things a synchronous transaction gets for free: a field recording whether the work succeeded, and retry handling with a bound on retries. A Queueable with an empty catch block is worse than the governor exception you were escaping, because the exception was at least loud.

If the requirement cannot tolerate that window, async is the wrong tool and the answer is to make the transaction cheaper — fewer queries, fewer loops, less automation on the object. Harder, and sometimes correct.

Frequently asked

Do Queueable, future, and batch all get the same async limits?

The per-transaction async limits are the same for all of them: 200 SOQL queries, 60,000 ms of CPU, 12 MB of heap. What differs is structure. Batch is the only one where those limits reset per chunk, which is why it is the only one that scales past a single transaction’s worth of work.

Does the finish method of a batch job share limits with execute?

No. start, each execute, and finish are separate transactions, each with a fresh async budget.

Can I raise the async limits for my org?

Not the per-transaction ones. They are fixed. The daily asynchronous execution allowance scales with licence count, and the concurrent long-running transaction ceiling scales with licences up to a maximum of 50, so those grow as the org grows. Neither is set by a support request.

Why does my Queueable fail to enqueue another job?

Because you are already in an asynchronous transaction, where the limit is one enqueued job rather than 50. Chaining works (one child per parent), but fanning out into several jobs does not.

Do async transactions run as a different user?

They run as the user who initiated them, but without the UI context. Anything depending on UserInfo-derived state set earlier in the request, or on data your synchronous code held in static variables, is gone. Statics do not survive the transaction boundary, which is a common source of “it worked before I made it async”.

Is a platform event subscriber synchronous or asynchronous?

An Apex trigger on a platform event runs asynchronously in its own transaction with async limits, and it processes events in batches of up to 2,000 rather than the usual 200. The static Apex limits list a trigger batch size of 2,000 for platform events and Change Data Capture. Code written for 200 records that assumes the batch size can be surprised by that.


Reference: Apex Governor Limits, Asynchronous Apex, Queueable Apex, Using Batch Apex, and Future Methods, Salesforce Apex Developer Guide.