The first time governor limits stop being an abstraction is usually a 2 a.m. page. For us it was a financial-services client running an order pipeline that handled millions of transactions a month, and the symptom was maddeningly intermittent: the same order-update trigger that ran fine all week would throw Apex CPU time limit exceeded for a handful of users on a busy afternoon, then go quiet again. Nothing in the code had changed. What had changed was load, and the realization that the platform doesn't grade you on your code in isolation — it grades you on everything that fires inside the transaction.
That distinction is the whole game. The CPU clock counts your trigger, plus the secondary triggers it cascades into, plus the auto-launched flows, plus the process automation, plus whatever an email service tacks on at the end. We had a single order update that fanned out into eight downstream triggers through field updates, and on a good day that chain landed around nine seconds of CPU against a ten-second synchronous ceiling. On a day when the underlying node was a little slower, it tipped over. The code was "correct." The budget was the problem.
Once you start thinking in budgets, the entire design conversation shifts. You stop asking "does this work?" and start asking "how much of the transaction's finite resource does this consume, and what happens when I run out?" The answer to that second question is where the circuit breaker pattern earns its place.
Limits are a budget, so measure the spend
Every Apex transaction gets a fixed allowance — 100 SOQL queries, 50,000 rows retrieved, 150 DML statements, 10,000 DML rows, 100 callouts, 6 MB of heap, 10 seconds of CPU synchronously. Async contexts roughly double a few of these (200 SOQL queries, 12 MB heap, 60 seconds of CPU), which is exactly why the standard fix for "I'm hitting limits" is so often "move it to Batch or Queueable." But doubling a ceiling is not a strategy; knowing where you stand against it is.
The Limits class is the instrument panel, and the trick is to read it at the right granularity. Every consumable resource exposes a getX() / getLimitX() pair, and the cheapest way to find your expensive code is to snapshot those numbers at meaningful boundaries inside a handler:
public class LimitMonitor {
public static void snapshot(String tag) {
System.debug(LoggingLevel.INFO,
tag +
' | CPU ' + Limits.getCpuTime() + '/' + Limits.getLimitCpuTime() +
' | SOQL ' + Limits.getQueries() + '/' + Limits.getLimitQueries() +
' | DML ' + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements() +
' | Heap ' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize());
}
}
Call LimitMonitor.snapshot('after validateCreditLimit') at a few key points and the spike announces itself — you see CPU jump 4,000 ms across one method and you know where to look. One caveat that bites people: reading Limits.getCpuTime() inside a tight 10,000-iteration loop itself burns measurable CPU. Snapshot at checkpoints, not on every pass.
The spikes you don't see coming
The limits that actually take production down are rarely the ones you'd guess. Three patterns account for most of our incidents, and all three share a trait: the cost is invisible at the line you wrote.
The first is query rows accumulating across a for-each SOQL loop. Writing for (List<Account> chunk : [SELECT Id FROM Account]) feels safe because the platform streams 200 records per iteration. But getQueryRows() sums every record the cursor touches, so an org with 60,000 accounts blows straight through the 50,000-row limit even though no single query is large. The fix is a WHERE clause that bounds the scan — filter to the records you actually need (WHERE Active__c = true) rather than letting the cursor walk the whole table.
The second is DML rows multiplying through cascade. Updating 100 parent records is nothing. But if those parents sit in a master-detail with triggered children, one account update can fan into 50 contact updates and 200 opportunity updates, and the 10,000-row DML ceiling arrives faster than anyone estimated. When a write cascades that hard, the cascade belongs in an async handler, not inline.
The third is heap inflated by serialization. JSON.serialize(largeObjectGraph) builds intermediate strings that run two to three times the size of the source objects, so a list of a few thousand records with formula fields can balloon past the synchronous 6 MB heap. The disciplined alternative is to stream with JSONGenerator, writing fields directly to the output buffer instead of materializing the whole structure:
JSONGenerator gen = JSON.createGenerator(false);
gen.writeStartArray();
for (Account a : accs) {
gen.writeStartObject();
gen.writeStringField('Id', a.Id);
gen.writeStringField('Name', a.Name);
gen.writeEndObject();
}
gen.writeEndArray();
String body = gen.getAsString();
The same heap lesson applies to greedy selects. SELECT FIELDS(ALL) FROM Order__c LIMIT 50000 on a wide object is a heap bomb — 50,000 records at a couple of kilobytes each is well over a hundred megabytes. Select only the fields you use, and when the volume is genuinely large, stream it: query a thousand records, process them, null out the reference so the heap can be reclaimed, then query the next batch.
The circuit breaker: check before you act
Defensive coding with try-catch is a recovery mechanism — it runs after something has already crashed. A circuit breaker is preventive: it inspects how much budget remains before it commits to a unit of work, and if the transaction is running hot it defers the non-essential parts to async rather than letting the user's save fail. The mental model is borrowed from distributed systems, but here the "downstream service" you're protecting is the transaction itself.
The implementation is deliberately boring. A small utility computes percentage consumption and exposes "is it safe?" predicates at empirically chosen thresholds:
public class CircuitBreaker {
private static final Decimal CPU_THRESHOLD = 70; // 70% of 10s sync
private static final Decimal DML_THRESHOLD = 80; // 80% of 150 statements
public static Boolean isCpuSafe() {
return ((Decimal) Limits.getCpuTime() / Limits.getLimitCpuTime()) * 100 < CPU_THRESHOLD;
}
public static Boolean isDmlSafe() {
return ((Decimal) Limits.getDmlStatements() / Limits.getLimitDmlStatements()) * 100 < DML_THRESHOLD;
}
}
The threshold choice is the one place people get burned, and it's worth being deliberate. Set it at 95% and by the time the check fails you have maybe 500 ms left — which evaporates the moment the deferral logic itself needs 600 ms to enqueue a job, and you throw anyway. The 70–80% band leaves enough headroom for the breaker to do its own work. It is a safety margin, not a precision instrument.
In the order handler, the breaker turns a flat list of actions into a prioritized one. Credit validation and outstanding-balance updates are critical and always run inline. Loyalty calculation and audit logging are deferred when CPU or DML is already high, and customer notifications — which involve callouts — are always pushed to async because you never make HTTP calls synchronously from a trigger:
public static void afterUpdate(List<Order__c> news, Map<Id, Order__c> oldMap) {
validateCreditLimit(news, oldMap); // critical, always
updateAccountOutstanding(news, oldMap); // critical, always
if (CircuitBreaker.isCpuSafe()) {
calcLoyaltyPoints(news, oldMap);
} else {
deferAsync(news, 'calcLoyalty');
}
if (CircuitBreaker.isDmlSafe()) {
createAuditLogs(news);
} else {
deferAsync(news, 'auditLog');
}
notifyCustomersAsync(news, oldMap); // callouts, always async
}
Where deferral goes wrong
The pattern is powerful enough to be dangerous, and the failure modes are all about what you defer and how reliably the async path completes.
The cardinal sin is gating critical logic behind the breaker. If updateOrderStatus only runs when CPU is safe, then on a hot transaction the order silently stays in its old state — no error, no retry, just a record stranded in the wrong status that nobody notices until reconciliation. Only ever defer work that is genuinely non-essential to the integrity of the record: audit trails, analytics, notifications, loyalty math. Anything that defines the record's correctness must either run or throw so the user can retry.
Deferral also demands idempotency, because the async job will sometimes run twice. A trigger fires and enqueues a loyalty recalculation; the user clicks save again, the trigger fires again, a second job enqueues, and now loyalty points are credited twice. The defense is a guard the job checks before it acts — a Loyalty_Calculated__c flag, or row locking inside the execute — so a duplicate invocation is a no-op rather than a double-count.
Then there's the fallback when even async is exhausted. Queueable jobs have their own limits, so the deferral routine has to handle the case where it can't enqueue anything at all:
private static void deferAsync(List<Order__c> orders, String taskType) {
if (Limits.getQueueableJobs() >= Limits.getLimitQueueableJobs() - 1) {
insert new Deferred_Task__c(
Task_Type__c = taskType,
Payload__c = JSON.serialize(extractIds(orders)),
Status__c = 'Pending'); // scheduled job drains it later
} else {
System.enqueueJob(new DeferredTaskQueueable(taskType, extractIds(orders)));
}
}
That custom-object queue is itself a liability if you forget about it. If the scheduled drainer fails quietly, the table grows without bound, so it needs a TTL, a cleanup job, and an alert when the backlog crosses a threshold. A deferral mechanism with no backpressure is just a slower way to lose data.
Finally, test the unsafe branch on purpose. Test context often has more generous limits than production, so the breaker reports "safe" in every test and your deferral path never executes under coverage — it passes in the org and fails in production. Make the breaker injectable with a @TestVisible override so a test can force the unsafe branch and assert that the work actually deferred:
@TestVisible static Boolean forceUnsafe = false;
public static Boolean isCpuSafe() {
return forceUnsafe ? false : cpuUsagePercent() < CPU_THRESHOLD;
}
The deeper principle is that governor limits stop being a constraint to fight the moment you treat them as a resource to manage. You measure the spend, you protect the critical path, you gracefully defer the rest, and you make the deferral as trustworthy as the inline work it replaced. Code that respects its own budget doesn't crash at the limit — it slows down, sheds the non-essential load, and keeps the user's save succeeding. That is the difference between an integration that survives Black Friday and one that pages you at 2 a.m.
Working on advanced Apex — enterprise patterns, integrations, or large-data design? Sapota's Salesforce team builds and reviews production Apex on real engagements. Get in touch ->
See our full platform services for the stack we cover.








