Using Queueable Apex with Transaction Finalizers: A Minimal Architecture for Guaranteed Post‑Processing
When an async Apex job fails, the caller often needs a reliable way to react. Salesforce's Transaction Finalizer attaches a post-execution handler to a Queueable job. This note covers requirements, minimal design, trust boundaries, operational checks, failure modes, and redesign triggers.
27 Jul 2026, 02:19 UTC

Problem: Guaranteed Follow‑Up After an Async Job Fails
Async Apex jobs (Queueable, Batch, @future) run in a separate transaction. If an exception occurs or a governor limit is hit, the caller has no built‑in way to react. Salesforce's System.Finalizer interface lets you attach a post‑execution handler that receives a ParentJobResult describing the job's outcome, enabling a retry, alert, or audit trail even when the original job fails.
Requirements
- Work must run asynchronously and may involve non‑primitive state or callouts.
- Caller needs a guaranteed callback after the job completes, regardless of success.
- Retry logic must be bounded to avoid exceeding the org's async Apex limits.
- Design must respect sharing, CRUD, and FLS when user‑controlled data crosses into the async context.
Minimal Suitable Design
Only two Apex classes are required:
MyQueueable– implementsQueueableand optionallyDatabase.AllowsCallouts.MyFinalizer– implementsSystem.Finalizerand contains the retry or logging logic.
Below is a concrete example that retries up to three times on an UNHANDLED_EXCEPTION and falls back to logging when retries are exhausted. Replace the endpoint and object names with your own.
public class MyQueueable implements Queueable, Database.AllowsCallouts {
private final Id recordId;
private final Integer retryCount;
public MyQueueable(Id recordId, Integer retryCount) {
this.recordId = recordId;
this.retryCount = retryCount;
}
public void execute(QueueableContext ctx) {
// Attach the finalizer before any DML or callout
System.attachFinalizer(new MyFinalizer(recordId, retryCount));
// Example work: callout then DML
HttpRequest req = new HttpRequest();
req.setEndpoint('https://example.com/api');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('API error');
}
Account acc = [SELECT Id, External_Id__c FROM Account WHERE Id = :recordId LIMIT 1];
acc.External_Id__c = 'Processed';
update acc;
}
}
public class MyFinalizer implements System.Finalizer {
private final Id recordId;
private final Integer retryCount;
public MyFinalizer(Id recordId, Integer retryCount) {
this.recordId = recordId;
this.retryCount = retryCount;
}
public void finalize(ParentJobResult result) {
if (result.getStatus() == ParentJobResult.Status.UNHANDLED_EXCEPTION) {
if (retryCount < 3) {
System.enqueueJob(new MyQueueable(recordId, retryCount + 1));
} else {
Finalizer_Log__c log = new Finalizer_Log__c(
Record_Id__c = recordId,
Attempts__c = retryCount,
Error_Message__c = result.getErrorMessage());
insert log;
}
}
}
}
Key Points in the Code
- Attach the finalizer early in
executeso it is registered before any exception can occur. - Because async jobs run in system context by default, declare classes
with sharingorinherited sharingif record‑level sharing must be enforced. - Perform callouts before any DML in the same transaction to avoid uncommitted‑work errors.
- Retries are capped at three to avoid unbounded loops against the daily async Apex limit (250,000 or 200× licenses, whichever is greater, per 24 hours). Confirm current limits for your org's release before committing to a retry count.
- Only re‑enqueue on retryable failures. Data‑validation errors should go straight to logging, since retrying them will never succeed.
Trust and Data Boundaries
- Sharing: Queueable jobs run in system mode by default. Add
with sharingto enforce the enqueuer's record‑level sharing. - CRUD/FLS: The enqueuing user's permissions do not automatically constrain the async transaction. Validate CRUD/FLS explicitly where user‑controlled data crosses into the job, including in the finalizer's logging DML.
- Finalizer limits: A finalizer executes even after uncatchable limit exceptions, but it runs with its own limits. Do not assume it can perform unlimited DML or callouts.
Operational Checks
| Check | How to Verify | Why It Matters |
|---|---|---|
| AsyncApexJob status | Query SELECT Id, Status, JobType, CreatedDate FROM AsyncApexJob WHERE JobType = 'Queueable' | Detect jobs stuck in Queued or Processing states. |
| Finalizer logs | Query Finalizer_Log__c for recent failures. | Audit retry attempts and error messages. |
| Async Apex usage | Setup > System Overview, or Limits.getAsyncCalls() | Ensure headroom before reaching the 24‑h limit. |
| Retry count | Check Attempts__c on log records. | Verify that retries are capped. |
Common Failure Modes
- Unhandled exceptions: Any exception not caught in
executetriggers the finalizer withUNHANDLED_EXCEPTION. - Limit exceptions: Some limits cannot be caught in the job but are visible to the finalizer, which can still log or retry within its own limits.
- Enqueue limits: A transaction can enqueue up to 50 Queueable jobs, and an executing job can chain only one child job. Breaching these throws before the finalizer can help.
- Daily async exhaustion: Once the org's 24‑h limit is reached, new jobs are rejected. Design the finalizer to degrade to logging rather than enqueue when limits are near.
When to Redesign
- High record volume: If a queueable chain processes thousands of records, switch to Batch Apex for chunking and per‑batch governor limits.
- Loose coupling needed: When producers and consumers should be decoupled beyond a single transaction, consider Platform Events or Change Data Capture.
- Complex retry logic: If retries need exponential back‑off or depend on external systems, a dedicated retry mechanism may be more maintainable.
Practical Verification Steps
- Deploy the classes to a scratch org or sandbox:
sfdx force:source:push --targetusername myOrg - Enqueue a job:
System.enqueueJob(new MyQueueable('001XXXXXXXXXXXX', 0));(run in Execute Anonymous; replace the Id with a real record). - Force an unhandled exception by pointing the callout at a failing endpoint or throwing deliberately in the job.
- Check the outcome: query
AsyncApexJobfor the latest Queueable job and queryFinalizer_Log__cfor logged failures. - Verify the retry count via the
Attempts__cfield, and check async usage withLimits.getAsyncCalls()or Setup > System Overview.
Note that Test.startTest/stopTest forces synchronous execution, so finalizer retry paths need dedicated unit tests rather than relying on chained async behavior across transactions.
Conclusion
Queueable Apex paired with a Transaction Finalizer provides a lightweight, reliable pattern for guaranteed post‑processing of async jobs. Keep the design minimal, cap retries, enforce sharing and FLS explicitly, and monitor AsyncApexJob plus your log object. Move to Batch Apex or platform events when volume or decoupling requirements outgrow a single queueable chain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.