Queueable Apex vs batch Apex comes down to one question: how many records are you touching, and do you need the result right now? Batch Apex exists to chew through millions of records in chunks without blowing a single governor limit. Queueable Apex exists for smaller, more precise jobs that need object-oriented structure and the ability to chain cleanly. Pick based on volume and timing, not personal preference, and you'll stop fighting limits you created yourself.
Batch Apex splits a job into scopes, usually 200 records at a time, and runs each scope as its own transaction with its own governor limits. That's the entire point. A nightly job that needs to recalculate rollups on 3 million opportunity records cannot run in one transaction. Batch Apex gives it a start method that defines the record set, an execute method that fires per chunk, and a finish method for cleanup or chaining.
Queueable Apex runs as a single transaction, just like a future method, but it's a real class implementing the Queueable interface instead of an annotated static method. That single-transaction design is a constraint, not a flaw. It means Queueable is built for jobs small enough to fit inside one set of limits, but complex enough that you want parameters, member variables, and non-primitive types passed in through a constructor.
Future methods predate both. They take only primitive arguments, return nothing, can't be chained, and give you zero visibility into execution order. Salesforce hasn't deprecated them, but there's no good reason to write a new one in 2024. Queueable does everything future does and adds object support, chaining, and a job ID you can actually track in AsyncApexJob.
Reach for batch Apex when the record count is unpredictable or large, meaning anything north of a few thousand rows that can't be handled in one transaction's limits. Mass data cleanup, nightly recalculation of custom rollup fields, data archiving jobs that touch every record in an object, and mass updates tied to a scheduled job all belong here.
Batch Apex also gives you Database.Stateful, an interface that lets you preserve variable state across transaction boundaries. Without it, every execute call starts fresh, which is usually what you want, but a running total across the whole job needs Stateful. That's a tool future and basic Queueable jobs don't offer natively.
The tradeoff is overhead. Batch jobs have a minimum five-second gap between transactions when queued behind other async jobs, and the org-wide limit is five active or queued batch jobs at once. If you're kicking off batch jobs from triggers at scale, you'll hit that ceiling fast, and the job will fail outright instead of queuing patiently.
Queueable fits the job that's too complex for a future method but too small to justify splitting into scopes. A callout that needs to fire after a record save, followed by a database update based on the response, is a textbook Queueable use case. You get the finally-executed context of async processing without the ceremony of start/execute/finish.
It also wins when you need to pass real objects into the job. Future methods choke on anything beyond primitives and collections of primitives. Queueable takes a constructor, so you can hand it a List of SObjects, a wrapper class, or a map built from trigger context, and it carries that state into the async transaction cleanly.
Chaining is where Queueable pulls ahead decisively. You can call System.enqueueJob from inside the execute method of another Queueable job, up to a depth limit, and build a sequence of dependent steps. Try that with future methods and you'll hit the wall immediately, since future methods can't call other future methods.
Both tools share the Apex async limit of 50 queued or executing jobs per rolling 24-hour period in most orgs, combining batch, queueable, future, and scheduled jobs together. That ceiling catches teams off guard when multiple automations fire async work independently and nobody's counting the total.
Queueable chaining depth is capped at one in most editions when started from a trigger, and five when started from synchronous Apex outside a trigger context. That's a hard rule, not a suggestion, and hitting it throws a runtime exception that's painful to debug after the fact.
Batch Apex caps you at five jobs in a Pending or Processing state per org at once, and raises an easier-to-hit wall if your start method's query returns more rows than the org's query cursor limit allows for non-selective filters. The table below lays out the limits side by side.
| Limit | Batch Apex | Queueable Apex |
|---|---|---|
| Max concurrent/queued jobs | 5 per org | Shares 50/24hr async pool |
| Transaction model | One per scope (chunked) | Single transaction |
| Chaining | Via finish method, unlimited sequential jobs | Depth-limited (1 from trigger, 5 otherwise) |
| Accepts non-primitive params | Yes, via constructor | Yes, via constructor |
| Best record volume | Thousands to millions | Single record to a few hundred |
Teams default to batch Apex because it feels like the serious, enterprise-grade option, then run it on data sets of 50 records out of habit. That's wasted overhead. A Queueable job with no scope management runs faster and avoids the five-job concurrency ceiling that batch jobs share across the whole org, including jobs other teams scheduled without your knowledge.
The opposite mistake shows up just as often: cramming a six-figure record update into a Queueable job because it was already written for a smaller case, then watching it hit the per-transaction SOQL row limit and fail silently in a log nobody checks. Queueable doesn't chunk your data for you. If the job queries more rows than one transaction allows, you need batch Apex, full stop.
My take: default to Queueable for anything triggered by user action on a handful of records, and default to batch for anything triggered by a schedule or touching an object with no practical ceiling on row count. That one rule resolves 90% of the decision without a meeting.
Batch Apex pairs naturally with the Schedulable interface, letting you run Database.executeBatch from a cron-based schedule and hand off the entire job lifecycle to the platform. Queueable can be scheduled too, through a Schedulable wrapper that calls System.enqueueJob, though it's a less common pattern since Queueable usually fires in response to an event rather than a clock.
Both show up in the AsyncApexJob object, which is the only reliable way to monitor status, track failures, and debug why a job silently stopped progressing. Query it by JobType and Status rather than trusting Setup's Apex Jobs page alone, since the UI paginates and hides older records faster than you'd expect.
Transaction logs and debug logs behave differently for each, too. Batch job logs split by scope, so a failure in chunk 400 out of 1,000 won't show up in the log you captured for chunk 1. Queueable logs capture the full single transaction in one place, which makes debugging noticeably faster when the job is small enough to fit the pattern in the first place.
Yes, you can call Database.executeBatch from within the execute method of a Queueable class. This is a common pattern for kicking off a large cleanup job after a smaller, immediate piece of logic finishes. Keep in mind the batch job still counts against the org's five concurrent batch job limit and the shared 24-hour async job ceiling.
Salesforce limits how many Queueable jobs you can chain in sequence, and that limit is stricter when the chain originates inside a trigger context, typically capped at one additional job. Outside of a trigger, synchronous Apex can chain up to five Queueable jobs deep. Hitting this limit means you need to restructure the logic or move the entry point out of the trigger.
Rarely. Future methods only accept primitive data types and offer no chaining, no job tracking object reference, and no way to pass structured objects. The one scenario where future still makes sense is quick, simple fire-and-forget callouts where the extra class structure of Queueable adds no real value. For anything more complex, Queueable is strictly better.
There is no hard cap on total records a batch job can process across its full run, since it processes data in scopes, commonly 200 records per transaction unless you set a custom scope size. The practical limit comes from the start method's query, which must stay selective enough to avoid timing out or exceeding non-selective query thresholds on very large objects.
Queueable jobs and batch jobs share the same org-wide pool of 50 queued or active asynchronous Apex jobs within a rolling 24-hour window. They do not share the separate five-concurrent-job ceiling that applies specifically to batch Apex. That means you can run several Queueable jobs while batch jobs are also active, as long as the combined 24-hour count stays under the shared limit.