SfSkills Domains Docs GitHub

Home / apex / apex-event-bus-subscriber

apex-event-bus-subscriber

Apex Platform Event subscriber runtime semantics — checkpoint-and-resume via `EventBus.TriggerContext.setResumeCheckpoint`, `EventBus.RetryableException` semantics, the 2,000-event-per-trigger default batch, and `PlatformEventSubscriberConfig` for batch-size and running-user tuning. Covers the checkpoint-vs-RetryableException decision, the most common subscriber bug. NOT for publishing from Apex — use apex/platform-events-apex. NOT for external subscribers — use integration/platform-events-integration.

Open the skill folder on GitHub


Apex Event Bus Subscriber

Subscribing to Platform Events from Apex looks deceptively like writing a DML trigger. It isn't. The runtime semantics — batch size, error handling, position tracking, retry budget — are all different from before insert / after insert triggers, and getting them wrong is the most common production bug in Platform Event subscribers.

This skill is the runtime-semantics layer. The basic trigger Foo on MyEvent__e (after insert) { ... } syntax is plain Apex; go to the Apex Developer Guide. Publishing events (EventBus.publish) lives in integration/platform-event-publish-patterns. Defining the event SObject itself lives in admin / metadata. This skill assumes you already have a trigger and you need to make it production-grade.


Before Starting


Core Concepts

The trigger contract

trigger MyEventSubscriber on MyEvent__e (after insert) {
    EventBus.TriggerContext ctx = EventBus.TriggerContext.currentContext();
    for (MyEvent__e e : Trigger.new) {
        try {
            handleEvent(e);
            // Mark this event processed. If we throw later, we resume AFTER this one.
            ctx.setResumeCheckpoint(e.ReplayId);
        } catch (TransientException ex) {
            throw new EventBus.RetryableException(
                'Transient failure on ' + e.ReplayId + '; re-fire batch', ex
            );
        } catch (PermanentException ex) {
            // Log + skip; the rest of the batch should proceed.
            ApplicationLogger.error('Bad event ' + e.ReplayId, ex);
            ctx.setResumeCheckpoint(e.ReplayId);
        }
    }
}

Three things are happening here that don't happen in a DML trigger:

  1. EventBus.TriggerContext.currentContext() — gives you the per-batch state object, including getEventsRemaining() (count) and setResumeCheckpoint(replayId) (position marker).
  2. setResumeCheckpoint(e.ReplayId) — after each successfully-processed event, set the checkpoint. On uncaught exception, the trigger re-fires from the event AFTER this checkpoint (skipping already-processed events).
  3. EventBus.RetryableException — explicit retry signal. Throws the whole batch back to be re-fired, up to 9 times (10 attempts total). After 10 attempts the trigger gives up and the events are lost.

Three retry strategies and when to use each

StrategyMechanismBehavior on failure
Checkpoint + uncaught exceptionsetResumeCheckpoint after each success; let other exceptions propagateTrigger re-fires from event AFTER the checkpoint (already-processed events are skipped). Up to 9 retries on the unprocessed slice.
Explicit RetryableExceptionthrow new EventBus.RetryableException(...)Whole batch re-fires (including already-processed events). Idempotency required. Up to 9 retries on the whole batch.
Drop on errorcatch + log + setResumeCheckpointMove past the bad event, do not re-fire. Right for permanent / poison-pill events.

The most common bug is using RetryableException without setResumeCheckpoint first. Result: every uncaught failure re-fires the whole 2,000-event batch, processed events are reprocessed, and governor budget is multiplied by 10.

PlatformEventSubscriberConfig for tuning

The default 2,000 batch is too large for many real workloads. Tune via metadata:

<!-- MyEventSubscriber.platformEventSubscriberConfig-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<PlatformEventSubscriberConfig xmlns="http://soap.sforce.com/2006/04/metadata">
    <batchSize>200</batchSize>
    <masterLabel>MyEventSubscriber</masterLabel>
    <platformEventConsumer>MyEventSubscriber</platformEventConsumer>
    <user>integration.user@example.com</user>
</PlatformEventSubscriberConfig>

Two knobs that matter:


Common Patterns

Pattern A — Checkpointed forward processing

When to use. Mostly-independent events; failure on one shouldn't block others; transient infra issues should be retried automatically.

trigger OrderEventSubscriber on OrderEvent__e (after insert) {
    EventBus.TriggerContext ctx = EventBus.TriggerContext.currentContext();
    for (OrderEvent__e e : Trigger.new) {
        try {
            new OrderProcessor().handle(e);
            ctx.setResumeCheckpoint(e.ReplayId);
        } catch (DmlException dml) {
            // Permanent: bad event payload. Log and skip.
            ApplicationLogger.error('Bad OrderEvent', dml);
            ctx.setResumeCheckpoint(e.ReplayId);
        } catch (CalloutException callout) {
            // Transient: external system unreachable. Bail and let the
            // platform retry from this checkpoint.
            throw callout;  // uncaught — checkpoint advances are preserved
        }
    }
}

The trigger re-fires on the unprocessed slice if a CalloutException fires. Permanent failures are logged + skipped. Transient failures get 9 retries on shrinking slices.

Pattern B — All-or-nothing with explicit retry

When to use. Events must be processed as a transactional batch (e.g. accounting balance updates). Idempotent or guarded by an external dedup key.

trigger LedgerEventSubscriber on LedgerEvent__e (after insert) {
    try {
        Ledger.applyBatch(Trigger.new);  // all-or-nothing
    } catch (TransientException ex) {
        throw new EventBus.RetryableException('Re-fire ledger batch', ex);
    }
}

No checkpoint — the batch is the unit of work. RetryableException fires the whole batch up to 9 more times. After 10 total attempts the events are lost; an idempotency key on each event lets you reprocess from a dead-letter store.

Pattern C — Tuned batch size for heavy work

When to use. Trigger body does substantial CPU work (rich text parsing, regex on large payloads) and a 2,000-event batch blows the CPU governor.

Step 1 — measure. Run a representative batch and confirm the CPU budget hit.

Step 2 — tune via PlatformEventSubscriberConfig (see XML above). A common starting point is 200 (matching DML-trigger intuition); optimize from there.

Step 3 — re-measure. Don't tune below what one event's worth of work would handle; below ~50 events per batch, per-batch overhead dominates.


Decision Guidance

SituationApproachReason
Default trigger written, no checkpoint, no try/catchAdd setResumeCheckpoint(e.ReplayId) after each successWithout it, an uncaught exception re-fires from the START of the batch every retry — repeated processing of already-handled events
Transient failure mode (callout timeout, lock contention)Either let exception propagate (with checkpoint) OR throw RetryableException (no checkpoint)Both retry; with-checkpoint retries the unprocessed slice, RetryableException retries the whole batch
Permanent failure (bad payload, validation rejection)catch + log + setResumeCheckpointDrop the bad event; don't burn the 9-retry budget on something that will never succeed
Trigger body does heavy CPU workTune batchSize down via PlatformEventSubscriberConfigDefault 2,000 is 10× DML; CPU governor will blow on rich payloads
Trigger needs broader permissions than the publishing userSet <user> in PlatformEventSubscriberConfigDefault running user is often wrong; integration user is the typical right answer
All-or-nothing semantics with idempotencyNo checkpoint + RetryableException on transientWhole batch is the unit of work
Events must NEVER be lostCheckpoint + dead-letter on the 10th attempt failureAfter 10 attempts the platform stops retrying; a custom dead-letter table catches the residue
You see "EventBus.subscribe()" in someone's codeStop and rewriteThat API does not exist in Apex; the only subscriber is a trigger

  1. Pick the retry strategy first. Checkpoint + propagate, RetryableException + no-checkpoint, or drop-on-error. Different strategies for transient vs permanent failures inside the same trigger are fine and common.
  2. Always call setResumeCheckpoint(e.ReplayId) after each successful event unless you've explicitly chosen Pattern B (all-or-nothing).
  3. Catch the right exception types. Don't catch Exception — catch DmlException, CalloutException, QueryException etc. and decide per type whether it's transient (re-throw) or permanent (log + checkpoint).
  4. Test with Test.getEventBus().deliver(). Triggers are async; in a test class, publish the event, then call Test.getEventBus().deliver() to drive the trigger synchronously.
  5. Tune via PlatformEventSubscriberConfig only after measurement. Default batch size is fine for most workloads; only tune when you have profile evidence.

Review Checklist


Salesforce-Specific Gotchas

  1. Default batch size is 2,000, not 200. Plan governor budgets accordingly. (See references/gotchas.md § 1.)
  2. EventBus.subscribe(...) does not exist. Only triggers are supported subscribers. (See references/gotchas.md § 2.)
  3. RetryableException without setResumeCheckpoint reprocesses already-processed events. Idempotency required. (See references/gotchas.md § 3.)
  4. 9 retries (10 total) then the events are lost. Plan a dead-letter strategy for must-not-lose flows. (See references/gotchas.md § 4.)
  5. The trigger's running user defaults to the publisher's context, not always what you want. Set explicitly via PlatformEventSubscriberConfig. (See references/gotchas.md § 5.)
  6. Test.getEventBus().deliver() is required to drive triggers in tests — publishing alone doesn't fire the trigger synchronously. (See references/gotchas.md § 6.)

Output Artifacts

ArtifactDescription
Production-grade subscriber triggerWith per-event checkpointing, typed exception handling, retry-strategy decision documented inline
PlatformEventSubscriberConfig XML (when needed)Batch size + running user tuning
Test classUses Test.getEventBus().deliver(); covers success path, transient retry, permanent-skip, and 10-attempt give-up
Dead-letter handler (if must-not-lose)Stores residue from the 10th failed retry for human review or async retry

Package files