Hey folks, Abhi here!
In my last post on batch chaining, I flagged the recursion trap — a job chaining into itself over and over until it eats your async limits — and then kind of waved my hand at the fix with a quick “just schedule it instead.” That paragraph deserved a lot more than one line, so this post is the deep dive.
The real question is: when a chain genuinely needs to go deep, what do you actually do to keep it safe? You’ve got two clean answers — cap the depth or switch to Queueable — and picking the wrong one bites you at 2am during a migration. Let’s break down both with real code.
🔁 First, What’s the Recursion Trap Again?
Chaining means one job kicks off the next when it finishes. Batch does it from finish(), Queueable does it from execute(). The trap shows up when a job re-enqueues itself — say, to keep reprocessing records until none are left:
apex
global void finish(Database.BatchableContext bc) { // Danger: no exit condition Database.executeBatch(new RetryFailedRecordsBatch(), 200);}
If the exit condition never trips — a record that always fails, a query that always returns rows — you’ve built an infinite chain. It burns through your daily async limits and eventually falls over. The fix isn’t “never self-chain,” because sometimes you genuinely need to. The fix is controlling how deep it’s allowed to go.
🎯 Option 1 — Cap the Depth
The simplest guard: pass a counter through the constructor and stop chaining once it hits a ceiling.
apex
global class RetryFailedRecordsBatch implements Database.Batchable<SObject> { private Integer depth; private static final Integer MAX_DEPTH = 5; global RetryFailedRecordsBatch(Integer depth) { this.depth = depth; } global Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator( 'SELECT Id FROM Integration_Log__c WHERE Status__c = \'Failed\'' ); } global void execute(Database.BatchableContext bc, List<SObject> scope) { // retry logic here } global void finish(Database.BatchableContext bc) { // Only chain again if there's work left AND we're under the cap Integer remaining = [ SELECT COUNT() FROM Integration_Log__c WHERE Status__c = 'Failed' ]; if (remaining > 0 && depth < MAX_DEPTH) { Database.executeBatch(new RetryFailedRecordsBatch(depth + 1), 200); } }}
Kick it off with Database.executeBatch(new RetryFailedRecordsBatch(0), 200);. After 5 links it stops, no matter what. Notice there are two exit conditions, not one — the counter and the “is there work left” check. Never rely on the work-check alone, because a permanently-failing record makes it lie to you forever.
When to use this: every link genuinely needs QueryLocator-scale volume (thousands to millions of records per run). If each step truly needs Batch Apex’s muscle, stay in Batch and just cap the chain.
⚡ Option 2 — Switch to Queueable
Here’s the thing — most “deep chain” scenarios aren’t actually processing millions per link. They’re doing smaller, step-by-step work that just happens to repeat a lot. That’s Queueable territory, and it chains more cleanly.
apex
public class RetryFailedRecordsQueueable implements Queueable { private Integer depth; private static final Integer MAX_DEPTH = 5; public RetryFailedRecordsQueueable(Integer depth) { this.depth = depth; } public void execute(QueueableContext ctx) { List<Integration_Log__c> failed = [ SELECT Id FROM Integration_Log__c WHERE Status__c = 'Failed' LIMIT 200 ]; // retry logic here if (!failed.isEmpty() && depth < MAX_DEPTH) { System.enqueueJob(new RetryFailedRecordsQueueable(depth + 1)); } }}
A few reasons this is often the better call for deep chains:
It doesn’t consume the 5 concurrent Batch slots. Batch jobs compete for a hard limit of 5 active-or-queued jobs org-wide, with up to 100 more held in the Flex Queue. Queueable doesn’t draw from those Batch slots, so a Queueable chain won’t get stuck behind your scheduled cleanups and integrations. (Fair warning: it does still count against the shared org-wide limit for total concurrent async executions — Queueable isn’t unlimited, it just isn’t fighting for the Batch slots specifically.)
Chaining is native and cleaner. You submit the next job right from execute() with System.enqueueJob() — no finish() gymnastics.
Mind the depth rules. In production orgs, Salesforce enforces no limit on chain depth — which is exactly why you must cap it yourself. In Developer Edition and trial orgs, the max stack depth is 5 (the initial job plus four chained links), so test with that in mind.
One child per parent. A running Queueable job can enqueue only one chained job. That’s actually a feature here — it keeps your chain linear and predictable instead of fanning out uncontrollably.
🛡️ The Piece Both Options Miss: Visibility
Capping depth stops the runaway. But it doesn’t tell you why a chain died. If link 3 blows a governor limit on an uncatchable error, the job just… stops. The run often looks like it’s still going when it’s actually dead — the silent-stall problem, and it’s brutal to debug mid-migration.
This is where the Finalizer interface earns its keep with Queueable. Per Salesforce’s docs, a Finalizer runs after your Queueable job completes — success or failure, including uncatchable exceptions — because it executes in its own separate transaction context. That’s the key property: even a limit blowout that kills your job still triggers the Finalizer.
Best practice is a dedicated Finalizer class (not folding it into the Queueable), which is how Salesforce’s own examples structure it:
apex
public class RetryFinalizer implements Finalizer { private Integer depth; private static final Integer MAX_DEPTH = 5; public RetryFinalizer(Integer depth) { this.depth = depth; } public void execute(FinalizerContext ctx) { if (ctx.getResult() == ParentJobResult.SUCCESS) { // Chain the next link only on clean success + under cap if (depth < MAX_DEPTH) { System.enqueueJob(new RetryFailedRecordsQueueable(depth + 1)); } } else { // Job died — log the reason + tracking id instead of stalling silently insert new Async_Run_Log__c( Status__c = 'Failed', Reason__c = ctx.getException()?.getMessage(), Async_Job_Id__c = ctx.getAsyncApexJobId() ); } }}
And in the Queueable, attach it at the top of execute():
apex
public void execute(QueueableContext ctx) { System.attachFinalizer(new RetryFinalizer(depth)); // retry logic here}
Now a crashed chain gets marked Failed with a reason and a tracking id, instead of sitting on “Running” forever. You still design the step order yourself — the Finalizer just makes sure a silent stall can’t hide. Note that FinalizerContext officially gives you getResult(), getException(), and getAsyncApexJobId() for exactly this.
One gotcha: the Finalizer runs in a fresh context, so the original Queueable instance no longer exists — don’t try to call back into it. Pass what you need (like the depth counter) into the Finalizer’s constructor.
Batch Apex has no native equivalent to this, which is another point in Queueable’s favor for anything chain-heavy.
📊 So, Which Do I Reach For?
Here’s my actual decision rule:
| Situation | Approach |
|---|---|
| Every link needs QueryLocator-scale volume | Cap the depth, stay in Batch |
| Smaller, repeated step-by-step work | Switch to Queueable |
| Need failure visibility per link | Queueable + Finalizer |
| Need to pass complex objects between links | Queueable |
| Genuinely periodic, not really recursive | Don’t chain — schedule it |
The honest summary: I only cap-and-stay-in-Batch when the volume forces me to. The moment the per-link volume drops into “a few hundred records” territory, Queueable wins — native chaining, no fight for the Batch slots, and Finalizers to kill the silent stall.
🧠 Key Takeaways
- The recursion trap is about missing exit conditions, not chaining itself.
- Always guard with two conditions: a depth counter and a “is there work left” check.
- Cap depth and stay in Batch only when every link needs Batch-scale volume.
- For smaller repeated work, Queueable chains cleaner and doesn’t consume the 5 Batch slots.
- In production there’s no enforced chain-depth limit — which is exactly why you must set one.
- Use a Finalizer so a dead chain gets logged as Failed instead of stalling silently on “Running.”
That’s the recursion-handling deep dive I owed you after the last post. If there’s a corner of this you want pushed further, drop it in the comments.
Catch you in the next one!
— Abhi
Leave a comment