SuiteScript Governance: Why Your Script Works in Sandbox and Dies in Production
SSS_USAGE_LIMIT_EXCEEDED is the most misleading error NetSuite produces. It reads like a rate limit, which suggests waiting and retrying. It is not a rate limit. It is a budget, the budget is spent per execution, and no amount of waiting refunds it.
The reason the error shows up in production and not in your sandbox testing is almost never the code. It is that you tested against eleven records and production has four hundred.
What a unit actually is
Every SuiteScript API call deducts a fixed number of usage units from an allowance granted at the start of the execution. When the allowance hits zero, NetSuite terminates the script mid-flight. Not throttled, not queued. Stopped.
Two properties matter and both are counterintuitive.
Units are per execution, not per script. A User Event script gets 1,000 units each time it fires. Firing on a thousand records means a thousand separate 1,000-unit budgets. This is why a script can process fifty thousand records over a day without complaint and then die during a CSV import.
Cost depends on the record category, not just the operation. This is the part that is genuinely surprising and the source of most bad estimates:
| Operation | Transaction | Custom record | Other standard |
|---|---|---|---|
record.load() |
10 | 2 | 5 |
record.create() |
10 | 2 | 5 |
record.save() |
20 | 4 | 10 |
record.submitFields() |
10 | 2 | 5 |
record.delete() |
20 | 4 | 10 |
Reading and writing fields on a record already in memory (getValue, setValue, getText) is free. log.debug() is free. The cost is entirely in the round trips.
So a load-modify-save cycle on a sales order is 30 units. On a custom record it is 6. The same code is five times more expensive against transactions, which is where most of the interesting work is.
The arithmetic that decides everything
Take a common requirement: when a sales order is approved, update a field on every related fulfilment.
// User Event: 1,000 units total
const results = search.create({ /* fulfilments for this order */ }).run().getRange({start: 0, end: 1000});
results.forEach(r => {
const rec = record.load({type: 'itemfulfillment', id: r.id}); // 10
rec.setValue({fieldId: 'custbody_flag', value: true}); // 0
rec.save(); // 20
});
Search create plus run plus getRange costs 20. Each fulfilment costs 30. The budget is 1,000. That is 32 fulfilments before termination.
In your sandbox, orders have three fulfilments and it works perfectly. In production, one customer does partial shipments and has forty. The script dies, the field is set on the first thirty-two, and because the earlier saves already committed, you now have half-updated data and no transaction to roll back.
That last part is the real hazard. Governance failure is not a clean abort. Every write that happened before the limit stands.
Four substitutions that buy back the most
Before restructuring anything, check whether the calls themselves can be cheaper. These are ordered by how much they typically return.
Replace load + save with submitFields. If you are only setting body fields and do not need sourcing, recalculation or a full record in memory, record.submitFields() costs 10 on a transaction against 30 for the cycle. The example above goes from 32 records to 98 for a one-line change.
record.submitFields({type: 'itemfulfillment', id: r.id, values: {custbody_flag: true}});
The caveat is real: submitFields does not source dependent fields, does not run sublist logic, and by default does not trigger sourcing or field-change events. For a flag, that is what you want. For anything where NetSuite needs to recalculate, it is not.
Replace record.load with search.lookupFields when you only need to read. lookupFields costs 1 unit. Loading a transaction to read two fields costs 10. That is a ten-fold difference for the single most common thing scripts do.
const f = search.lookupFields({type: 'salesorder', id: soId, columns: ['entity', 'trandate']});
Replace a search loop with one SuiteQL query. query.run() costs 10 units and returns a joined result set. Getting the same joined data through N/search costs 5 to create, 5 to run, and 10 for every getRange or each call, and if the join is not available in the search model you end up running a second search per row, which is the expensive way to lose.
const rows = query.runSuiteQL({
query: `SELECT t.id, t.tranid, tl.item, tl.quantity
FROM transaction t JOIN transactionline tl ON tl.transaction = t.id
WHERE t.type = 'SalesOrd' AND t.trandate >= ?`,
params: ['2026-01-01']
}).asMappedResults();
One call, ten units, arbitrary joins.
Stop iterating with each() on large result sets. resultSet.each() and getRange() cost 10 units per call and getRange returns a maximum of 1,000 rows. Ten thousand results is ten calls and 100 units before you have processed anything. runPaged() with a page size of 1,000 is the same data at a more predictable cost, and it gives you a page count up front so you can decide whether to proceed at all.
Choosing the script type is choosing the budget
| Script type | Units per execution |
|---|---|
| Client, User Event, Suitelet, Portlet, Workflow Action, Mass Update | 1,000 |
| RESTlet | 5,000 |
| Scheduled Script | 10,000 |
| Map/Reduce | No total limit |
Map/Reduce is the one people misread. Oracle's documentation says there are no limits on the deployment as a whole, and that is true, but it does not mean unlimited units. Each stage invocation is governed separately: getInputData and summarize get 10,000 units each, each reduce invocation gets 5,000, and each map invocation gets 1,000.
That is a different mental model, and it is the point of the whole script type. Your budget is per key, not per job. A map function that loads a transaction, changes it and saves it spends 30 of its 1,000 units and then the next key starts fresh. You can process a million records, as long as no single record needs more than 1,000 units of work.
The corollary is that Map/Reduce does not rescue a script whose problem is one expensive record. If a single key needs to touch two hundred transactions, you are back to the same wall, and the fix is to change what the key is.
The rough decision rule: a handful of records on save is a User Event; a few thousand on a schedule is a Scheduled Script; anything unbounded or parallelisable is Map/Reduce. If you are writing a User Event that loops, you are usually one growth spurt away from an incident.
Defensive patterns
Check your remaining budget rather than assuming it.
const remaining = runtime.getCurrentScript().getRemainingUsage();
if (remaining < 100) {
// hand off rather than die mid-write
task.create({taskType: task.TaskType.SCHEDULED_SCRIPT, scriptId: 'customscript_x',
params: {custscript_resume_from: lastProcessedId}}).submit(); // 20 units
return;
}
Yielding costs units too (task.submit() for a scheduled script is 20, and for a CSV import task a hefty 100), so leave headroom for the exit path. A rescheduling call that itself runs out of budget is a particularly annoying way to fail.
Guard User Events against bulk contexts. A User Event fires during CSV import, during web services calls, and during other scripts unless you check:
if (runtime.executionContext === runtime.ContextType.CSV_IMPORT) return;
A large share of "it worked for months and then broke" incidents are a User Event that was fine on manual entry and met a five thousand row import.
Make writes idempotent and record progress. Since a governance kill leaves earlier writes committed, the recovery path matters more than the failure path. Write a marker field, filter on it, and make a rerun safe. This is unglamorous and it is the difference between a rerun and a data cleanup project.
Reading the autopsy
When a script does die, the useful information is in the deployment's execution log rather than the error message. Customization > Scripting > Script Deployments, open the deployment, and the log shows entries by execution with the record that triggered them.
Two things to look for. First, whether the failures correlate with specific records rather than being spread evenly. That tells you it is one expensive record, not a general budget shortfall, and it changes the fix completely. Second, whether the volume of executions changed before the failures started. A script that has run daily for a year and starts failing usually met new data, not new code.
Instrument before you need it. runtime.getCurrentScript().getRemainingUsage() logged once at the end of a successful run costs nothing and turns "it fails sometimes" into a number you can reason about. Log it against the record ID so the correlation is visible in the log itself.
The unglamorous conclusion
Governance is not a difficult concept. It is arithmetic, and everything you need is a table of unit costs and a count of how many times your loop runs. The reason it keeps catching people is that the count is invisible at development time, the cost table is not in front of you while you write, and the failure appears months later on someone else's data.
Reading script logs to work out which of those it was is exactly the kind of narrow, tedious, pattern-matching work we built MokuBot to do. It reads the execution logs in your account, correlates the failures against the triggering records, and tells you whether you are looking at one pathological record or a script that has quietly outgrown its script type. It also does the boring part of the fix, which is usually swapping a load/save pair for submitFields in nine places.
None of that is beyond you. It is just an hour you did not want to spend, roughly once a quarter, forever.