If your Queueable fails halfway through and you have no finalizer, you have a silent data loss bug. The job is gone, the records are half-updated, and the only trace is a debug log nobody reads.
The Finalizer interface, GA since Winter ‘22 and significantly improved in 2025-2026, is the cleanest async recovery pattern in Apex. Here is the production template.
What finalizers actually give you
A Finalizer runs after the parent Queueable, regardless of success or failure. It receives a FinalizerContext exposing:
getAsyncApexJobId()— the parent job id.getResult()— SUCCESS or UNHANDLED_EXCEPTION.getException()— the unhandled exception, if any.
You can enqueue new jobs, send platform events, or update records from within the finalizer. The finalizer runs in its own transaction with its own governor limits. This is the single most important property — the parent’s exception did NOT poison your error handler.
The minimum-viable finalizer
public class SafeQueueable implements Queueable, Database.AllowsCallouts, Finalizer {
private final String idempotencyKey;
private final List<Id> recordIds;
public SafeQueueable(String idempotencyKey, List<Id> recordIds) {
this.idempotencyKey = idempotencyKey;
this.recordIds = recordIds;
}
public void execute(QueueableContext qc) {
// Attach finalizer FIRST. If we crash before this line,
// there is no recovery. Don't put anything above it.
System.attachFinalizer(this);
// Now do the actual work
processRecords();
}
public void execute(FinalizerContext fc) {
if (fc.getResult() == ParentJobResult.SUCCESS) {
AsyncJobLog__c.create(idempotencyKey, 'SUCCESS', null);
return;
}
Exception e = fc.getException();
AsyncJobLog__c.create(idempotencyKey, 'FAILED',
e.getTypeName() + ': ' + e.getMessage());
// Retry once with smaller batch if it's a CPU or limit exception
if (e instanceof System.LimitException && recordIds.size() > 10) {
List<Id> half = new List<Id>(recordIds);
half = half.subList(0, half.size() / 2);
System.enqueueJob(new SafeQueueable(idempotencyKey + ':r', half));
}
}
private void processRecords() {
// The actual work
}
}
System.attachFinalizer(this) registers the same class as its own finalizer. Two-argument constructor preserves the state.
Idempotency: the half people skip
The finalizer above logs an AsyncJobLog__c keyed by the idempotency key. The processing code MUST check this key on entry and skip work that’s already done.
private void processRecords() {
AsyncJobLog__c existing = AsyncJobLog__c.findByKey(idempotencyKey);
if (existing != null && existing.Status__c == 'SUCCESS') {
// Idempotent short-circuit
return;
}
// Group work by record so partial completion is recoverable
List<RecordWorkLog__c> processed = [
SELECT RecordId__c FROM RecordWorkLog__c
WHERE IdempotencyKey__c = :idempotencyKey
];
Set<Id> done = new Set<Id>();
for (RecordWorkLog__c log : processed) done.add(log.RecordId__c);
for (Id rid : recordIds) {
if (done.contains(rid)) continue;
processOne(rid);
insert new RecordWorkLog__c(
IdempotencyKey__c = idempotencyKey,
RecordId__c = rid
);
}
}
On retry the finalizer re-enqueues. The retry skips records already logged in RecordWorkLog__c. No double-processing, no missed records.
Chaining with finalizers vs System.enqueueJob in execute()
Pre-finalizer code commonly did this:
public void execute(QueueableContext qc) {
doWork();
if (hasMore) {
System.enqueueJob(new NextChunk());
}
}
If doWork() throws, the chain dies. With finalizers:
public void execute(FinalizerContext fc) {
if (fc.getResult() == ParentJobResult.SUCCESS && hasMore) {
System.enqueueJob(new NextChunk(idempotencyKey, remainingIds));
} else if (fc.getResult() == ParentJobResult.UNHANDLED_EXCEPTION) {
// Retry the SAME chunk, not the next one
System.enqueueJob(new SafeQueueable(idempotencyKey, recordIds));
}
}
The chain survives any single failure.
Callouts and finalizers
Finalizers can make callouts. The pattern people miss: if the parent Queueable made a callout that succeeded but local DML failed, you need the finalizer to issue a compensating callout.
public void execute(FinalizerContext fc) {
if (fc.getResult() == ParentJobResult.UNHANDLED_EXCEPTION
&& this.externalCallSucceeded) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Vendor/compensate');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, String>{
'idempotencyKey' => idempotencyKey,
'reason' => fc.getException().getMessage()
}));
new Http().send(req);
}
}
The external system must support compensation. If it doesn’t, don’t make the callout from the parent until local state is committed.
Monitoring
Watch AsyncApexJob for jobs with Status = 'Failed' and NumberOfErrors > 0 and JobItemsProcessed = 0 (failed without any progress). Those are the leak indicators.
SELECT ApexClass.Name, JobItemsProcessed, NumberOfErrors,
ExtendedStatus, CreatedDate
FROM AsyncApexJob
WHERE JobType = 'Queueable'
AND Status = 'Failed'
AND CreatedDate = LAST_N_DAYS:7
ORDER BY CreatedDate DESC
Surface this in a dashboard. If Failed count is non-zero you have outstanding retries.
The Flow connection
Record-triggered flows that call invocable Apex which enqueues a Queueable should pass through the idempotency key from the flow. See the flow fault path recovery patterns for how to plumb it from the flow layer.
UX note
If your Queueable backs a user action (“Sync to ERP” button), the button should immediately show a “Sync queued” toast with a job tracking id. The user-facing record should display sync status (Queued / Running / Synced / Failed) sourced from the AsyncJobLog__c. Trust requires visibility.
Bottom line
- Always attach a finalizer to any production Queueable, no exceptions.
- Pass an idempotency key. Log per-record progress. The retry path should skip done records.
- Use the finalizer for chain continuation; never
System.enqueueJobinsideexecute()directly. - For callout + DML jobs, the finalizer is where compensating actions live.
- Monitor
AsyncApexJobfor partial failures weekly; treat any non-zero count as an incident.