Most Salesforce Developers understand Queueable Apex and Platform Events as separate tools. Fewer combine them intentionally to create asynchronous architectures that are decoupled, scalable, and easier to evolve.
That gap matters because many async implementations begin with good intentions and eventually become difficult to maintain. A pattern like Trigger → Queueable → Queueable → Queueable may work at first, but it often becomes brittle when retries, branching logic, observability, and new downstream requirements enter the picture.
This article explores how to treat Queueables as workers and Platform Events as the orchestration layer between business events and execution. The goal is not just to move work off the main transaction, but to design an async pipeline that is resilient, extensible, and enterprise-ready.
The Async Illusion
Queueables are powerful – they let developers move expensive logic off the user transaction, support callouts, accept complex payloads, and chain follow-up jobs when needed.
That power creates an illusion: because Queueables can be chained, they are often used as if they were the orchestration mechanism itself. In reality, chaining is only a sequential handoff from one job to the next. It is not a true coordination model for workflows that need branching, selective retries, multiple subscribers, or independent evolution over time.
This is where many implementations begin to struggle. A single chain may start with one clean requirement, but after a few releases it turns into a hard-coded path where every new step increases coupling.
A common anti-pattern looks like this: Trigger → Queueable → Queueable → Queueable.
At first, it feels simple. Over time, it becomes the place where scale, reliability, and maintainability begin to erode.
A Quick Refresher on Salesforce Async Tools
Salesforce gives us multiple async tools, and each one has a different strength. Understanding where Queueables and Platform Events fit makes it easier to use them together deliberately.
| Tool | Best Used For | Limitation |
|---|---|---|
| Future Methods | Lightweight fire-and-forget operations. | Limited flexibility, no rich orchestration model, Salesforce signaling their future deprecation. |
| Queueable Apex | Background jobs, callouts, complex objects, controlled execution, large volumes with use of Cursor feature. | Chaining is still linear. |
| Batch Apex | Large-volume record processing. | Heavyweight for event-driven workflow coordination. |
| Scheduled Apex | Time-based execution. | Not ideal for reactive async pipelines. |
| Platform Events | Decoupled publish-subscribe communication. | Requires strong event design, monitoring, and idempotency. |
A useful mental model is this:
- Queueables are workers.
- Platform Events are the message bus.
Once these responsibilities are separated, the architecture becomes cleaner and more flexible.
Why Linear Chaining Does Not Scale
Consider a business flow like this:
- An order is created.
- Credit is validated.
- Tax is calculated.
- An invoice is generated.
- An ERP system is notified.
- A confirmation email is sent.
A traditional Queueable chain can represent that sequence, but it introduces several design problems:
- The flow becomes tightly coupled.
- One failure can break the entire chain.
- Retrying a single step becomes harder than retrying the full process.
- Adding a new branch means editing existing orchestration logic.
- Observability is scattered across jobs instead of centered around business events.
The issue is not that Queueables are weak. The issue is that Queueables are being asked to solve a coordination problem when they are better suited for execution.
Event-Driven Orchestration
A more scalable approach is to let business events drive the flow.
Instead of chaining job to job, the transaction publishes a Platform Event that represents something meaningful in the domain. A subscriber reacts to that event, translates it into work, and enqueues a Queueable to perform the heavy lifting. When needed, that worker can publish another event to indicate completion, failure, or the start of the next stage.
The sequence now looks more like this:
- A trigger, Flow, or service publishes an event.
- A Platform Event subscriber receives the event.
- The subscriber enqueues one or more Queueable workers.
- Each Queueable performs focused work.
- The worker logs results or emits follow-up events.
This shift changes the architecture in important ways.
First, the publisher no longer needs to know who is listening. That makes the originating transaction lighter and less coupled to downstream systems.
Second, subscribers can evolve independently. A new process can react to the same event without rewriting existing triggers or flows.
Third, failure handling becomes more intentional. Instead of treating the entire pipeline as one chained unit, each step can be observed, retried, or rerouted based on its own outcome.
Architecture Pattern: Orchestrated Async Pipeline
A practical implementation usually includes the following parts:
1. Domain Trigger or Flow
This is the entry point. It detects that something important happened in the business process, such as a record being created, updated, or reaching a meaningful state.
The goal here is to keep the entry point lightweight. It should capture intent and publish an event, not perform heavy processing directly.
2. Event Publisher Layer
A publisher layer centralizes event creation and publishing logic. This prevents every trigger or Flow from building event payloads differently and creates a cleaner contract for downstream consumers.
This also improves reuse. If Accounts, Opportunities, and Cases all need to trigger a shared async process, they can publish the same event through a common service.
3. Platform Event Definition
The event itself is the contract. It should contain the minimum fields required to identify the business context and allow downstream processors to act.
Typical fields might include:
- Record Id
- Event type or action
- Correlation Id
- Retry count
- Target system or template identifier
- Processing metadata for logging or routing
A good event schema is stable, focused, and designed for versioning over time.
4. Event Subscriber Trigger
This subscriber reacts to the event and converts it into executable work. In many designs, the subscriber should remain thin.
Its responsibilities usually include:
- Validating the incoming event payload
- Grouping or chunking work where needed
- Enqueuing Queueable workers
- Capturing metadata for monitoring
The subscriber should not become the new monolith. Heavy logic still belongs in workers or services.
5. Queueable Worker Class
This is where actual processing happens. The worker might perform a callout, update records, invoke external services, or transform data.
Queueables remain the ideal execution unit because they provide control, support callouts, and let you pass structured payloads into the job. They are best used as stateless workers that do one thing well.
6. Result Handling and Retry Logic
Once a worker finishes, the system needs a consistent way to handle success and failure. That may include logging results, publishing a failure event, scheduling a retry, or escalating to support.
This is where Transaction Finalizers, custom retry metadata, and follow-up events become especially valuable.
Practical Example: External Email Integration
A concrete example makes the pattern easier to understand.
Imagine multiple business processes need to send messages through an external email system. A new Account may trigger a welcome email. An escalated Case may trigger a support notification. A Closed Won Opportunity may trigger a customer communication.
A common but fragile implementation would place HTTP callout logic directly in each trigger or Flow path. That leads to duplicated logic, bloated automation, and multiple integration points that all need to be maintained separately.
A better pattern looks like this:
- The Account trigger publishes an Email Request Platform Event.
- An Opportunity flow publishes the same Email Request Platform Event.
- A Platform Event subscriber listens for Email Request events.
- The subscriber enqueues a Queueable worker.
- The Queueable performs the HTTP callout to the external email service.
- A finalizer handles retries or logs failures to an Email Log object.
This gives the org a single integration pathway for outbound email requests even though multiple entry points can initiate the process.
That design scales more naturally because publishers stay lightweight, the event becomes reusable, and the integration logic is centralized in one worker layer.
Code Walkthrough
Here is a simplified example of what this can look like.
Account Trigger Publishes the Event
trigger AccountTrigger on Account (after insert) {
List<Email_Request__e> events = new List<Email_Request__e>();
for (Account acc : Trigger.new) {
Email_Request__e evt = new Email_Request__e();
evt.Record_Id__c = acc.Id;
evt.Template_Id__c = 'NEW_ACCOUNT_WELCOME';
evt.To_Email__c = acc.PersonEmail;
events.add(evt);
}
if (!events.isEmpty()) {
EventBus.publish(events);
}
}
Notice that the trigger does not make a callout or contain retry logic. It simply emits the intent that an email should be sent.
Platform Event Trigger Enqueues a Worker
trigger EmailRequestTrigger on Email_Request__e (after insert) {
```
List<EmailRequestWrapper> requests = new List<EmailRequestWrapper>();
```
for (Email_Request__e evt : Trigger.new) {
requests.add(new EmailRequestWrapper(
evt.Record_Id__c,
evt.Template_Id__c,
evt.To_Email__c
));
}
if (!requests.isEmpty()) {
System.enqueueJob(new EmailRequestQueueable(requests, 0));
}
}
The subscriber remains intentionally lightweight. It transforms event data into work items and hands them to a Queueable.
Queueable Worker Performs the Callout
public class EmailRequestQueueable implements Queueable, Database.AllowsCallouts {
private List<EmailRequestWrapper> requests;
private Integer retryCount;
public EmailRequestQueueable(List<EmailRequestWrapper> requests, Integer retryCount) {
this.requests = requests;
this.retryCount = retryCount;
}
public void execute(QueueableContext context) {
```
List<EmailRequestWrapper> failedRequests = new List<EmailRequestWrapper>();
```
for (EmailRequestWrapper reqWrap : requests) {
try {
HttpRequest req = new HttpRequest();
req.setMethod('POST');
req.setEndpoint('callout:External_Email_Service/send');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(reqWrap));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException('Failed callout for ' + reqWrap.toEmail);
}
} catch (Exception ex) {
failedRequests.add(reqWrap);
}
}
System.attachFinalizer(new EmailRequestFinalizer(failedRequests, retryCount));
if (!failedRequests.isEmpty()) {
throw new CalloutException('One or more requests failed in this job.');
}
}
}
The Queueable is now doing what it should do best: focused, isolated execution.
Finalizer Handles Retry and Logging
public class EmailRequestFinalizer implements System.Finalizer {
private List<EmailRequestWrapper> failedRequests;
private Integer retryCount;
private static final Integer MAX_RETRIES = 3;
public EmailRequestFinalizer(List<EmailRequestWrapper> failedRequests, Integer retryCount) {
this.failedRequests = failedRequests;
this.retryCount = retryCount;
}
public void execute(System.FinalizerContext context) {
if (failedRequests.isEmpty()) {
return;
}
if (retryCount < MAX_RETRIES) {
System.enqueueJob(new EmailRequestQueueable(failedRequests, retryCount + 1));
} else {
List<Email_Log__c> logs = new List<Email_Log__c>();
for (EmailRequestWrapper reqWrap : failedRequests) {
logs.add(new Email_Log__c(
Record_Id__c = reqWrap.recordId,
To_Email__c = reqWrap.toEmail,
Status__c = 'Failed',
Retry_Count__c = retryCount
));
}
insert logs;
}
}
}
This gives you a predictable place to apply post-processing behavior rather than embedding retry logic across multiple layers.
Advanced Patterns That Make This Enterprise-Ready
The base pattern is useful on its own, but enterprise implementations usually need more than simple publish and process behavior.
Publish Reliability with Apex Publish Callback
Publishing an event is usually straightforward, but at scale you should still think about what happens if the publish itself fails. Apex Publish Callback gives you a way to confirm success or react to failure.
This is especially important when the event is the critical handoff point in the architecture. If the event is not accepted, downstream work never begins.
Use callbacks when you need to:
- Log publish success or failure
- Retry failed publishing attempts
- Trigger compensating actions
- Preserve business traceability from the original transaction
Built-In Retry and Replay for Event Consumers
Platform Events already provide features that improve resilience.
Built-in subscriber retry helps when failures are transient. Replay IDs help consumers recover missed events or reprocess messages after downtime. These features are not a replacement for good application design, but they give you a stronger operational foundation than direct point-to-point coupling.
Parallel Subscribers for Throughput
When event volume grows, a single Apex subscriber can become a bottleneck. This is where PlatformEventSubscriberConfig becomes valuable.
Parallel subscribers let you partition event processing across multiple instances of the same subscriber. Combined with careful tuning of batch size and partition strategy, this can significantly improve throughput.
This design should be used carefully. More parallelism is not automatically better. Batch size, partition count, and downstream processing limits all need to be tuned together.
Idempotency
Event-driven systems must assume duplicates can happen. Retries, replays, and distributed execution all make duplicate processing a realistic scenario.
That is why idempotency is essential. A subscriber or worker should be able to receive the same instruction more than once without producing bad side effects.
Common approaches include:
- Storing a correlation or message key.
- Checking whether the target operation has already completed.
- Recording processing status in a log or state object.
- Designing external integrations to reject duplicate requests safely.
Observability
If an async architecture cannot be observed, it cannot be trusted.
At minimum, a production-ready implementation should provide visibility into:
- What event was published.
- Which subscriber processed it.
- Which Queueable executed the work.
- Whether the operation succeeded or failed.
- Whether it was retried.
- Which business record or correlation Id the work belonged to.
Monitoring may include standard Apex Jobs, event logs, custom logging objects, and operational dashboards. A centralized log object can be especially useful for support teams and admins who need traceability without reading debug logs.
Admin-Friendly Design
Although this pattern is highly relevant for developers and architects, it also benefits admins when designed thoughtfully.
One of the biggest advantages is that different entry points can publish the same event contract. That means a flow can participate in the same orchestration model as Apex without duplicating external integration logic.
For example, a record-triggered flow on Opportunity can publish the same Email Request event used by an Account trigger. The downstream worker remains the same, so the integration pathway is centralized even though the entry points differ.
This leads to a cleaner collaboration model:
- Admins own declarative entry points where appropriate.
- Developers own the event contract and worker logic.
- Architects preserve consistency across the system.
That is a much healthier design than embedding fragile callout logic in multiple automation layers.
When Not to Use This Pattern
It is important to stay balanced. Not every async requirement needs an event-driven orchestration model.
You may not need this pattern when:
- The logic is a simple two-step async operation.
- The process is low volume and internal only.
- There is no realistic need for multiple consumers.
- The overhead of events, logging, and retry handling would outweigh the benefit.
In those cases, a single Queueable or even another simpler async option may be sufficient.
The goal is not to introduce Platform Events everywhere. The goal is to use them where decoupling and scale create meaningful architectural value.
Final Thoughts
A mature async architecture requires a shift in mindset.
Instead of designing the system as a line of chained jobs, design it around domain events and focused workers. Let Platform Events express what happened. Let Queueables perform the execution. Let finalizers, logs, and replay strategies provide resilience around the edges.
When you make that shift, the benefits are significant:
- Better decoupling between publishers and processors.
- Easier feature expansion as new subscribers are added.
- More intentional retry and failure handling.
- Stronger observability.
- Cleaner separation of concerns.
Queueables are excellent workers. Platform Events are excellent messengers. When they are combined intentionally, they create an asynchronous architecture that is far more scalable than either tool used in isolation.







