Choosing a Bulk-Safe Trigger Pattern in Apex: Static Helpers vs. Queue-Based Processing
A decision guide for bulk-safe Apex triggers: when a static helper with list-based DML is enough, when to escalate to Queueable, and how to verify limit consumption at 200-record volume.
14 Jul 2026, 18:59 UTC

The decision you're actually making
Every Apex trigger eventually faces the same question: how do you keep it alive when it fires on 200 records instead of 2? A trigger that works in a sandbox test with a single record can throw System.LimitException: Too many SOQL queries: 101 the first time a data load or integration pushes a few hundred rows through it. The fix is bulkification — writing the trigger so it operates on collections rather than individual records — but there are two mainstream ways to structure that, and they have different costs.
This guide compares the two supported patterns, gives you a decision table, and shows a concrete implementation of the simpler one plus a way to verify it holds up at volume.
Constraints that shape the choice
Apex runs in a multi-tenant environment, so Salesforce enforces per-transaction governor limits. The ones that matter most for trigger design:
- SOQL queries: 100 per synchronous transaction.
- DML statements: 150 per transaction.
- CPU time: 10 seconds synchronous (the research brief's figure of 6M refers to milliseconds in some documented contexts — verify against the current "Execution Governors and Limits" page for your edition).
- Heap size: 6 MB synchronous, 12 MB asynchronous.
These limits are edition- and context-dependent and change over platform releases, so treat the numbers above as a baseline and confirm them for your target org before committing to a design.
Comparing the two supported patterns
| Factor | Static helper class with list-based DML | Queue-based processing (Queueable / Batch) |
|---|---|---|
| Best for | Predictable volumes, logic that must finish before the transaction commits | Variable or high volumes, work that can tolerate slight delay |
| Governor headroom | Shares the triggering transaction's limits | Gets a fresh, higher asynchronous limit set per job |
| Data consistency | Atomic with the original DML — rolls back together | Runs after commit; failures don't roll back the original records |
| Code complexity | Low: one trigger, one handler class | Higher: job class, chaining logic, failure monitoring |
| Debugging | Single debug log for the whole transaction | Separate logs per job; harder to trace causality |
| Failure surface | Limit exceptions under unexpected volume | Job failures can go unnoticed without monitoring (e.g., no built-in retry) |
Trade-offs in plain terms
The static helper pattern keeps everything synchronous. If an Account update must also update related Contacts before the user sees a result, this is the only correct choice — asynchronous jobs run after the transaction commits, so the user could see stale child data. The risk is that you're borrowing against the same 100-query budget the rest of the transaction uses, including any other triggers, flows, or managed packages on the same object.
Queue-based processing buys you headroom. A Queueable job gets the asynchronous governor limits (double the SOQL and DML allowances in most editions) and you can chain jobs to process arbitrarily large sets. The costs are real, though: the work is no longer atomic with the triggering change, you need a monitoring story for failed jobs, and chaining depth is limited (one chained job per Queueable execution in most contexts). Don't reach for it just because it sounds scalable — reach for it when you've measured that synchronous processing won't fit, or when the work (callouts, non-essential rollups) genuinely doesn't need to block the user.
Concrete implementation: the static helper pattern
The trigger itself should contain no logic — it delegates immediately. This keeps the trigger order-insensitive and testable.
trigger AccountTrigger on Account (after update) {
AccountTriggerHandler.handleOwnerChange(Trigger.new, Trigger.oldMap);
}The handler collects IDs first, runs exactly one SOQL query and one DML statement regardless of record count:
public class AccountTriggerHandler {
public static void handleOwnerChange(List<Account> newAccounts,
Map<Id, Account> oldMap) {
Set<Id> changedOwnerAccountIds = new Set<Id>();
for (Account acc : newAccounts) {
if (acc.OwnerId != oldMap.get(acc.Id).OwnerId) {
changedOwnerAccountIds.add(acc.Id);
}
}
if (changedOwnerAccountIds.isEmpty()) {
return;
}
// One query for all affected children — never query inside the loop above.
List<Contact> contactsToUpdate = [
SELECT Id, AccountId, OwnerId
FROM Contact
WHERE AccountId IN :changedOwnerAccountIds
];
Map<Id, Account> accountsById = new Map<Id, Account>(newAccounts);
for (Contact c : contactsToUpdate) {
c.OwnerId = accountsById.get(c.AccountId).OwnerId;
}
// One DML statement for the whole collection.
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate;
}
}
}Run this in a sandbox or scratch org with system administrator or "Author Apex" permission. Two details matter: the SOQL query sits outside every loop, and the update call takes a list, so 200 records cost 1 query and 1 DML statement, not 200 of each.
Validating that it actually holds up
Don't trust the pattern — measure it. Write a test that inserts 200 Accounts, updates all of their owners in a single DML statement, and asserts the Contact owners changed. Then check the limit consumption directly in the test:
@IsTest
static void bulkOwnerChangeStaysWithinLimits() {
// setup: create 200 accounts each with contacts, then...
Test.startTest();
update accounts; // single DML on the list
Test.stopTest();
System.assert(Limits.getQueries() <= 5,
'Query count should stay flat regardless of record count: ' + Limits.getQueries());
System.assert(Limits.getDmlStatements() <= 3,
'DML statements should stay flat: ' + Limits.getDmlStatements());
}The key signal is that Limits.getQueries() stays roughly constant whether you process 2 records or 200. If it scales with record count, something is querying inside a loop. You can also run the update from the Developer Console's Execute Anonymous window and open the resulting log's "Limits" or "Executed Units" section to see actual consumption.
Limitations and when to escalate
This pattern does not protect you from other automation on the same object. A managed package trigger or a record-triggered flow on Account consumes from the same limit pool, so a handler that uses 5 queries in isolation can still push the transaction over 100 in a busy org. Test in a sandbox that mirrors production's installed packages.
Escalate to Queueable when: the follow-up work involves callouts (not allowed in triggers), the child record counts are unbounded (an Account with 50,000 Contacts will blow the 10,000-record DML rows limit in one statement), or the work is genuinely deferrable. If you go that route, plan for failure monitoring from day one — an asynchronous job that throws leaves no trace in the user's session.
Finally, re-check the current governor limit documentation for your edition before finalizing any thresholds in tests; Salesforce adjusts these values between releases, and hard-coded assertions like the ones above should reflect the documented numbers for your org.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.