SfSkills Domains Docs GitHub

Home / apex / apex-limits-monitoring

apex-limits-monitoring

Use this skill when writing Apex that must check governor limits at runtime before executing expensive operations — guard clauses, early-exit patterns, Queueable re-queue on limit approach, and batch scope sizing.

Open the skill folder on GitHub


Apex Limits Monitoring

Activate this skill when writing Apex that must stay within Salesforce governor limits at runtime, or when building the layer that watches limit consumption over time. It covers the Limits class API, guard-clause patterns, early-exit and re-queue strategies, batch scope calculation, and the three places limit data can be observed: inside the transaction, after it, and across the org.


Before Starting


Questions to Ask Before Configuring

Ask before writing the first guard. A monitor built without these answers reports numbers nobody acts on, and a guard built without them turns a loud failure into a quiet wrong answer.

AskWhy it mattersWhat a good answer adds
"When this code runs out of headroom, is a partial result acceptable, or must the whole transaction fail?"Decides whether the guard returns early, throws an application exception, or re-queues. Returning an empty collection where the caller expects completeness converts a visible LimitException into silent data lossThe degrade contract: what the caller receives, and how it can tell the result was truncated
"Which context does this code actually run in — trigger, Batch execute, Queueable, Scheduled, or a Finalizer?"Every one of these has a different ceiling, and two of them surprise people: scheduled Apex gets synchronous limits, and a Finalizer gets synchronous limits with exactly three async exceptionsThe right ceiling source (always getLimitX()), and whether re-queue is even available here
"What is the per-record cost of the loop body, in SOQL, DML rows, and callouts?"Scope size and the guard threshold are both derived from this number; guessing it produces a batch that fails on chunk one or wastes 60% of its allocationA scope formula in the class header that a reviewer can re-derive when the loop changes
"Does a managed package run inside this transaction?"Certified managed packages get their own 150 DML and 100 SOQL allocations, so Limits.getQueries() in your namespace cannot see theirs, and the cumulative ceiling is 11× the per-namespace oneA decision to read LIMIT_USAGE_FOR_NS per namespace rather than trusting one number
"Which limits does the business actually care about, and what does 'too high' mean for each?"Alerting on everything OrgLimits.getAll() returns is alerting on nothing. Percentage thresholds must come from observed peaks, not from 80 being a round numberThe Limit_Threshold__mdt record set, and a defensible warning/critical pair per limit
"Who receives a threshold alert, and what are they expected to do that day?"An alert with no owner and no runbook becomes noise within two weeks, after which the monitor is worse than nothing because it looks like coverageA named owner per limit and the action that clears the alert
"How will we know a limit regression landed — before production, not after?"ApexTestResultLimits records consumption per test method, but only between Test.startTest() and Test.stopTest(), only for async runs, and only in the default namespaceA CI gate with a committed baseline, and tests shaped so the object actually gets populated

What a proper limits-monitoring layer adds over scattering Limits.getQueries() calls: the ceiling is read from the context instead of hardcoded, the degrade behaviour is a contract rather than an accident, a consumption regression fails the build instead of the customer's Tuesday, and when a transaction does blow a limit the exact meter is recoverable from outside the dead transaction.


Core Concepts

Three Monitoring Horizons

This skill owns all three. Each answers a different question and none substitutes for another.

HorizonQuestion it answersMechanismWhere it lives
In-transaction"Can I afford the next operation?"Limits.getX() vs Limits.getLimitX() → early-exit, degrade, or re-queueLimitGuard in references/code-examples.md
Post-transaction"Did consumption change since last release? Which meter blew?"ApexTestResultLimits in CI; CUMULATIVE_LIMIT_USAGE in debug logs; a Finalizer; EventLogFile ApexUnexpectedExceptionCI pipeline + Event Monitoring
Org-wide"Are we going to run out of API calls / storage / async executions this week?"OrgLimits.getAll() or REST /limits, polled into a time series with thresholdsOrgLimitsPoller + Limit_Snapshot__c

The Limits Class — getX() / getLimitX() Pairs

"There are two versions of every method: the first returns the amount of the resource that has been used while the second version contains the word limit and returns the total amount of the resource that is available" (Apex Reference Guide, Limits Class, apexrefguide L220166–220170).

Pairs worth guarding, all from the class's own method list (apexrefguide L220185–220390):

ConcernUsedCeiling
SOQL queriesLimits.getQueries()Limits.getLimitQueries()
SOQL rows returnedLimits.getQueryRows()Limits.getLimitQueryRows()
getQueryLocator rowsLimits.getQueryLocatorRows()Limits.getLimitQueryLocatorRows()
DML statementsLimits.getDMLStatements()Limits.getLimitDMLStatements()
DML rowsLimits.getDMLRows()Limits.getLimitDMLRows()
CPU time (ms)Limits.getCpuTime()Limits.getLimitCpuTime()
Heap size (bytes)Limits.getHeapSize()Limits.getLimitHeapSize()
CalloutsLimits.getCallouts()Limits.getLimitCallouts()
SOSL queriesLimits.getSoslQueries()Limits.getLimitSoslQueries()
Relationship subqueriesLimits.getAggregateQueries()Limits.getLimitAggregateQueries()
@future callsLimits.getFutureCalls()Limits.getLimitFutureCalls()
Queueable jobs enqueuedLimits.getQueueableJobs()Limits.getLimitQueueableJobs()
sendEmail invocationsLimits.getEmailInvocations()Limits.getLimitEmailInvocations()
Publish-immediately eventsLimits.getPublishImmediateDML()Limits.getLimitPublishImmediateDML()
Mobile push Apex callsLimits.getMobilePushApexCalls()Limits.getLimitMobilePushApexCalls()
Apex cursors (24 h)Limits.getApexCursors()Limits.getLimitApexCursors()

Three entries on that list are not usable in a monitor:

Wording matters when you write the log line: getHeapSize() "returns the approximate amount of memory (in bytes) that has been used for the heap" (apexrefguide L220711–220712). getCpuTime() carries no such qualifier — it "returns the CPU time (in milliseconds) that has been used in the current transaction" (apexrefguide L220544–220545). Do not label a heap reading exact, and do not label a CPU reading approximate.

Sync vs Async Ceilings — and Where "Async" Is a Lie

From the Per-Transaction Apex Limits table (apexdev L19540–19580):

LimitSynchronousAsynchronous
SOQL queries100200
SOQL rows returned50,00050,000
DML statements150150
DML rows10,00010,000
Callouts100100
System.enqueueJob calls501
@future methods per invocation500 in batch/future, 50 in queueable
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
EventBus.publish (publish immediately)150150

Three notes on that table the platform prints right beside it:

Guard clauses must therefore call Limits.getLimitX() rather than a constant. The runtime already knows which column applies; a hardcoded number does not.

What getAggregateQueries() Actually Counts

Not COUNT(), SUM() or GROUP BY. The Per-Transaction Apex Limits footnote is explicit: "In a SOQL query with parent-child relationship subqueries, each parent-child relationship counts as an extra query. These types of queries have a limit of three times the number for top-level queries. The limit for subqueries corresponds to the value that Limits.getLimitAggregateQueries() returns" (apexdev L19612–19616). So the meter tracks relationship subqueries, its ceiling is 3× the SOQL ceiling (300 sync, 600 async — derived, not printed), and an aggregate-function query still consumes an ordinary slot on getQueries(). Guard the subquery count in code that issues nested SELECT (SELECT …) shapes, not in code that issues COUNT().

System.LimitException Takes finally With It

"Some special types of built-in exceptions can't be caught… One such exception is the limit exception (System.LimitException)… When exceptions are uncatchable, catch blocks, as well as finally blocks if any, aren't executed" (apexdev L39720–39728). The second half is the part that costs people a logging strategy: a finally { LogService.flush(); } does not run either, so the transaction cannot record its own death. Post-mortem must come from outside — BatchApexErrorEvent, a Finalizer, or the ApexUnexpectedException event log. See references/code-examples.md for the three surfaces and what each yields.

Org-Wide: OrgLimits and REST /limits Disagree on Purpose

OrgLimits.getAll() returns List<System.OrgLimit>; getMap() keys the same by name (apexrefguide L226226–226256). Each instance exposes getName(), getLimit() (maximum) and getValue() (usage) (apexrefguide L226101–226166). The REST resource returns Max and Remaining instead (api_rest L2000–2002). One reports consumed, the other reports left; a poller that treats them as interchangeable inverts every reading. Both lag: "Limit values are updated asynchronously, in near-real-time" (apexrefguide L226215), and "tabulated limits returned by the API are accurate within five minutes of resource consumption" (api_rest L7792–7793).


Common Patterns

Guard Clause Before an Expensive Operation

When to use: any service-layer method that issues SOQL, DML, or heap-intensive work inside a loop or reachable from several code paths.

How it works: compare consumption to a percentage of getLimitX(), not to a constant. LimitGuard.nearSoql(80) in references/code-examples.md is the reusable form; LimitGuard.hasRoomFor('soql', 3) is the exact form to prefer when the per-call cost is known.

Why not the alternative: a try/catch (System.LimitException) compiles and never runs.

if (LimitGuard.nearSoql(80)) {
    LogService.warn('ContactService.enrich', 'degraded: ' + LimitGuard.snapshot());
    return partial;                       // and the caller can tell it is partial
}

Queueable Re-Queue on Limit Approach

When to use: long-running Queueable jobs over variable-size datasets.

How it works: check headroom after each slice; when it drops, persist a cursor and enqueue the remainder. Bound the chain: "no limit is enforced on the depth of chained jobs… For Developer Edition and Trial organizations, the maximum stack depth for chained jobs is 5" (apexdev L16182–16185). Use AsyncOptions.MaximumQueueableStackDepth with System.enqueueJob(queueable, asyncOptions) and read AsyncInfo.getCurrentQueueableStackDepth() inside execute to stop deliberately rather than by accident (apexdev L16044–16057). Each execution counts once against the shared 24-hour asynchronous ceiling (apexdev L17689–17694), so an unbounded chain is an org-wide problem, not a local one.

Batch Scope Size From Per-Record Cost

When to use: designing a Batch class where per-record limit cost is estimable.

How it works: divide the applicable ceiling by the per-record cost, apply a safety factor, and write the derivation into the class header so the next change re-derives it. Governor limits reset for each execute invocation (apexdev L17712–17713), which is what makes the arithmetic per-chunk rather than per-job. scope caps at 2,000 when start returns a QueryLocator, and the optimal value is a factor of 2000 (apexdev L17704–17708). Worked example in references/examples.md.

Checkpoint Logging and the Snapshot Shape

When to use: high-volume service methods and batch execute bodies.

apex/debug-and-logging already ships LogService.limitsSnapshot(). Do not write a second one. LimitGuard.snapshot() in this skill emits the same name=used/ceiling shape and adds the meters LogService omits, so one parser reads both strings.

Threshold Polling Into a Time Series

When to use: the org needs to see a limit climbing, not just its current value.

OrgLimitsPoller reads OrgLimits.getAll(), classifies each reading against Limit_Threshold__mdt, and writes one Limit_Snapshot__c row per limit per run. Which limits are worth monitoring, what the alert routing should be, and who owns each alert are architect/org-limits-monitoring questions; the Apex that implements the answer is here.


Decision Guidance

SituationRecommended ApproachReason
Service method called from a trigger, may issue SOQLLimitGuard.hasRoomFor('soql', n) before the query, with an explicit degrade contractTrigger context has no retry; the exact-cost form beats a percentage guess
Queueable over a variable-size datasetHeadroom check inside the loop, cursor re-queue, bounded with AsyncOptions.MaximumQueueableStackDepthChain depth is otherwise unbounded and consumes the org's shared async allocation
Batch execute with uncertain per-record SOQL costDerive scope from the binding constraint; document the formula in the class headerLimits reset per execute, so scope is the only lever that matters
Heap-intensive transformationLimitGuard.nearHeap(pct) inside the build loop, not only at method entrygetHeapSize() is an approximate reading of a number that grows as you allocate
Need to know which meter is closest to its ceilingLimitGuard.worst() in the log lineOne name in the alert beats twelve numbers nobody reads
Consumption regression must not reach productionApexTestResultLimits diff in CI against a committed baselineThe only per-test-method consumption record the platform keeps
A transaction died and you need to know which limitEventLogFile ApexUnexpectedException → EXCEPTION_CATEGORYNames the meter (LimitException: CpuTime) from outside the dead transaction
Org-level allocation trending toward exhaustionOrgLimitsPoller → Limit_Snapshot__c → threshold alertOrgLimits gives a current value; the object gives a slope
Choosing sync vs async in the first placestandards/decision-trees/async-selection.mdThis skill covers defensive coding, not job-type selection
Deciding which org limits matter and who is pagedarchitect/org-limits-monitoringThat skill owns the monitoring architecture; this one owns the Apex

  1. Establish the context and its ceilings. Determine whether the target code is a trigger, Batch execute, Queueable, Scheduled job, or Finalizer, then check it against the sync/async table above — scheduled Apex and Finalizers both run under synchronous limits with exceptions. Replace every hardcoded ceiling with the matching Limits.getLimitX() call.
  2. Map the per-record cost of each loop. For every loop and fan-out method, count SOQL statements, relationship subqueries, DML statements, DML rows, and callouts per iteration. This number feeds both the guard threshold and the batch scope formula; record it in a class-header comment.
  3. Deploy LimitGuard and replace the ad-hoc subtractions. Take the class from references/code-examples.md, then convert each getLimitX() - getX() < n expression at a call site into LimitGuard.hasRoomFor(meter, n) or LimitGuard.nearX(pct). Write the degrade contract into the method's doc comment: what the caller receives and how it detects truncation.
  4. Wire the degrade path into LogService. Every guard that trips must emit one line carrying LimitGuard.snapshot() and LimitGuard.worst().name. Do not add a finally block for this — a limit breach skips finally entirely, so the log has to be written by the guard, before the operation.
  5. Stand up the org-wide poller. Deploy Limit_Snapshot__c, Limit_Threshold__mdt and OrgLimitsPoller from references/code-examples.md in the deploy order given there, populate one threshold record per limit the org actually cares about, and schedule the job last from anonymous Apex.
  6. Run scripts/check_apex_limits_monitoring.py --manifest-dir <source root>. It flags the seven defects this skill's gotchas describe — hardcoded ceilings, catch (System.LimitException), per-iteration getCpuTime() polling, a poller with no dispatch, a snapshot object with no DateTime column, threshold literals duplicated across classes, and guard tests that assert without consuming anything. Add --strict in CI to promote WARN to failure.
  7. Close the loop in CI. Run sf apex run test asynchronously, query ApexTestResultLimits for the run, and diff Cpu, Soql, QueryRows, Dml and DmlRows per method against the committed baseline. Verify the tests actually populate the object — no Test.startTest()/Test.stopTest() block means no row, and no row reads as zero consumption.

Review Checklist


Salesforce-Specific Gotchas

Eleven non-obvious platform behaviours, each with What happens / When it occurs / How to avoid and a guide citation, are in references/gotchas.md. The four that most often invalidate a monitoring design:

  1. A limit breach skips finally, not just catch — so in-transaction logging cannot record it.
  2. getAggregateQueries() counts relationship subqueries, not aggregate functions.
  3. ApexTestResultLimits is populated only between startTest/stopTest, only for async runs, only in the default namespace — and it has no heap column.
  4. Certified managed packages get their own limit allocations, so your Limits.getQueries() reading is per-namespace, not per-transaction.

Output Artifacts

ArtifactDescription
LimitGuard.cls + -meta.xmlPercentage and exact-cost guards over nine meters, plus snapshot() matching LogService's shape
OrgLimitsPoller.clsThin Schedulable writing Limit_Snapshot__c rows from OrgLimits.getAll() with CMDT-driven classification
Limit_Snapshot__c + fieldsThe limit time series: name, consumed, maximum, percent, severity, captured-at, run id
Limit_Threshold__mdt + one recordDeployable warning/critical percentages per limit name
LimitGuardTest.clsThreshold assertions against measured consumption; poller row shape through an injection seam
package.xml, deploy order, verification SOQLOrdered deployment and the CronTrigger / Limit_Snapshot__c queries that prove it ran
CI limits gateApexTestResultLimits query plus the three conditions that must hold for it to return rows
Guard-clause and re-queue snippetsPaste-ready patterns for service classes and Queueable jobs

Reference Files

FileRead it when
references/code-examples.mdYou are building it: LimitGuard, OrgLimitsPoller, Limit_Snapshot__c, Limit_Threshold__mdt, the test class, package.xml, deploy order, verification SOQL, the CI gate, and the three post-mortem surfaces
references/gotchas.mdA guard did not fire, a monitor reported nothing, or a limit blew where you thought you had headroom — eleven platform behaviours with guide citations
references/llm-anti-patterns.mdReviewing generated limit-handling code; eight failure shapes with detection hints
references/examples.mdYou need the worked guard clause, the batch scope derivation, checkpoint logging, or the CI regression baseline
references/well-architected.mdJustifying guard granularity, alert thresholds, or the cost of the monitoring layer to a reviewer
templates/apex-limits-monitoring-template.mdRunning a limits review over someone else's Apex
scripts/check_apex_limits_monitoring.pyBefore deploying — seven rules over the .cls and .object-meta.xml files this skill produces

Package files