record.save() on an Invoice costs 20 units, but on a custom record only 4 units. [src3]This card covers the SuiteScript 2.x governance model as implemented in Oracle NetSuite (release 2026.1). Governance limits are a server-side resource management system that assigns a unit cost to every SuiteScript API call and enforces a maximum budget per script invocation. If a script exceeds its allowed units, NetSuite terminates execution immediately with an SSS_USAGE_LIMIT_EXCEEDED error. [src1]
SuiteScript 2.1 and 2.0 share identical governance costs and limits. SuiteScript 2.1 adds modern JavaScript syntax (ES6+: let, const, arrow functions, async/await, Promises) but does not change any governance behavior. [src6]
| Property | Value |
|---|---|
| Vendor | Oracle |
| System | Oracle NetSuite — SuiteScript 2.x Runtime |
| API Surface | SuiteScript 2.x (server-side JavaScript) |
| Current Version | SuiteScript 2.1 (release 2026.1) |
| Editions Covered | All NetSuite editions (SuiteCloud Developer license required) |
| Deployment | Cloud |
| API Docs | SuiteScript Governance and Limits |
| Status | GA — enforced on all accounts |
SuiteScript governance applies to all server-side script types. Each type has a different unit budget reflecting its expected workload. [src2]
| Script Type | Max Units | Time Limit | Use Case | Parallel? | Auto-Yield? |
|---|---|---|---|---|---|
| Client Script | 1,000 | User-controlled | Field validation, UI automation | No | No |
| User Event Script | 1,000 | 10 min | Record-level beforeLoad/beforeSubmit/afterSubmit | No | No |
| Suitelet | 1,000 | 10 min | Custom UI pages, internal tools | No | No |
| Portlet Script | 1,000 | 10 min | Dashboard portlets | No | No |
| Workflow Action Script | 1,000 | 10 min | Custom workflow actions | No | No |
| Mass Update Script | 1,000 | Per record | Bulk record updates | No | No |
| RESTlet | 5,000 | 5 min | External API endpoints | No | No |
| Scheduled Script | 10,000 | 60 min | Background processing, batch jobs | No | Manual |
| Map/Reduce Script | Per-phase | Per-phase | Large-scale data processing | Yes | Automatic |
| Bundle Installation Script | 10,000 | — | SuiteApp installation logic | No | No |
| SDF Installation Script | 10,000 | — | SuiteCloud project deployment | No | No |
| Custom Plug-in | 10,000 | — | Extensibility points | No | No |
Costs vary by record type: transaction records cost the most, custom records the least. [src3]
| API Method | Transaction Records | Standard Non-Transaction | Custom Records | Notes |
|---|---|---|---|---|
record.create() | 10 | 5 | 2 | Creates in-memory record object |
record.load() | 10 | 5 | 2 | Loads existing record from DB |
record.copy() | 10 | 5 | 2 | Copies existing record |
record.transform() | 10 | 5 | 2 | Transforms record type (e.g., SO to IF) |
record.save() | 20 | 10 | 4 | Commits record to database |
record.delete() | 20 | 10 | 4 | Deletes record from database |
record.submitFields() | 10 | 5 | 2 | Inline field update without full record load |
record.attach() / detach() | 10 | 10 | 10 | Attach/detach records |
| API Method | Units | Notes |
|---|---|---|
search.create() | 0 | Creating a search object is free |
search.load() | 5 | Loading a saved search |
search.save() | 5 | Saving a search definition |
search.lookupFields() | 1 | Cheapest way to read field values |
ResultSet.each() | 10 | Running search results iteration |
ResultSet.getRange() | 10 | Getting a page of results |
Search.runPaged() | 5 | Paginated search execution |
PagedData.fetch() | 5 | Fetching a page from paged results |
query.runSuiteQL() | 10 | Running a SuiteQL query |
| API Method | Units | Notes |
|---|---|---|
http.get() / post() / put() / delete() | 10 | Each external HTTP call |
https.get() / post() / put() / delete() | 10 | Each secure HTTP call |
https.requestRestlet() | 10 | RESTlet-to-RESTlet call |
https.requestSuiteTalkRest() | 10 | SuiteTalk REST call from SuiteScript |
sftp.Connection.download() | 100 | SFTP file download |
sftp.Connection.upload() | 100 | SFTP file upload |
| API Method | Units | Notes |
|---|---|---|
email.send() | 20 | Sending an email |
file.save() | 20 | Saving file to File Cabinet |
file.load() | 10 | Loading file from File Cabinet |
workflow.initiate() | 20 | Starting a workflow |
task.CsvImportTask.submit() | 100 | Submitting a CSV import |
llm.generateText() | 100 | AI text generation (N/llm module) |
llm.embed() | 50 | AI embedding generation |
documentCapture.documentToText() | 100 | OCR document processing |
| Phase | Hard Limit (Units) | Hard Limit (Time) | Hard Limit (Instructions) | What Happens on Exceed |
|---|---|---|---|---|
| getInputData | 10,000 | 60 min | 1 billion | Ends invocation, skips to summarize |
| map | 1,000 | 5 min | 100 million | Ends current invocation; pending jobs cancel |
| reduce | 5,000 | 15 min | 100 million | Ends current invocation |
| summarize | 10,000 | 60 min | 1 billion | Script stops executing |
[src4]
| Soft Limit | Default | Configurable? | Applies To | Behavior |
|---|---|---|---|---|
| Units per job | 10,000 | No | map, reduce | Job yields and reschedules with same priority |
| Yield After Minutes | 60 min | Yes (3-60 min) | map, reduce | Job yields after time limit; configurable on deployment record |
| Limit Type | Value | Error Code |
|---|---|---|
| Total persisted data | 200 MB | PERSISTED_DATA_LIMIT_FOR_MAPREDUCE_SCRIPT_EXCEEDED |
| Max key length | 3,000 characters | KEY_LENGTH_IS_OVER_3000_BYTES |
| Max value size | 10 MB per entry | VALUE_LENGTH_IS_OVER_10_MB |
Governance limits apply regardless of how the script is triggered. [src1]
| Script Trigger | Auth Context | Governance Budget |
|---|---|---|
| User action (record save) | Session-based (logged-in user) | User Event: 1,000 units |
| RESTlet call via TBA | Token-Based Authentication | RESTlet: 5,000 units |
| RESTlet call via OAuth 2.0 | OAuth 2.0 M2M | RESTlet: 5,000 units |
| Scheduled job | System/administrator context | Scheduled: 10,000 units |
| Map/Reduce job | System/administrator context | Per-phase limits |
| Workflow action | Workflow executor role | Workflow Action: 1,000 units |
SSS_USAGE_LIMIT_EXCEEDED and terminates execution immediately. There is no grace period and no way to catch this exception. [src1]record.save() costs 20 units on a transaction record but only 4 on a custom record. [src3]START — Need to process records with SuiteScript
+-- How many records?
| +-- < 50 records, triggered by user save
| | +-- Use User Event Script (1,000 units)
| | +-- Prefer search.lookupFields (1 unit) over record.load (5-10 units)
| +-- 50-5,000 records, on a schedule
| | +-- Use Scheduled Script (10,000 units)
| | +-- Implement yield logic: check getRemainingUsage() and reschedule
| +-- > 5,000 records, parallelizable
| | +-- Use Map/Reduce Script (auto-governed)
| | +-- Set Concurrency Limit on deployment record (default: 2)
| +-- External API integration
| +-- Low volume (< 100 calls)?
| | +-- RESTlet (5,000 units) for inbound API endpoint
| +-- High volume (> 100 calls)?
| +-- Map/Reduce or Scheduled Script for higher budgets
+-- Need custom UI?
| +-- Suitelet (1,000 units) — delegate heavy ops via task.submit()
+-- Error tolerance?
+-- Must complete all records: Map/Reduce (automatic retry)
+-- Best-effort: User Event afterSubmit (fire-and-forget)
| Pattern | Operations | Unit Cost | Fits In |
|---|---|---|---|
| Simple field validation | 5x search.lookupFields | 5 | Client (1,000), User Event (1,000) |
| Load + modify + save 1 transaction record | record.load + record.save | 30 | User Event (1,000) |
| Load + modify + save 1 custom record | record.load + record.save | 6 | User Event (1,000) |
| Search + update 10 transaction records | 1x ResultSet.each + 10x record.submitFields | 110 | User Event (1,000) |
| Search + update 100 custom records | 1x ResultSet.each + 100x record.submitFields | 210 | User Event (1,000) |
| HTTP POST to external API | https.post | 10 | User Event (1,000, max ~100 calls) |
| Nightly sync: 200 transaction records | 200x (record.load + record.save) | 6,000 | Scheduled (10,000) |
| Nightly sync: 500 transaction records | 500x (record.load + record.save) | 15,000 | Map/Reduce only |
| Bulk CSV import | task.CsvImportTask.submit | 100 | Scheduled (10,000) |
| SFTP download + process file | sftp.download + file.load | 110 | Scheduled (10,000) |
| Send 10 emails | 10x email.send | 200 | Scheduled (10,000) |
| AI text generation | llm.generateText | 100 | Scheduled (10,000) |
Before executing expensive operations, always check remaining units. [src1, src5]
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/runtime', 'N/log'], (runtime, log) => {
const execute = (context) => {
const script = runtime.getCurrentScript();
log.debug('Remaining units', script.getRemainingUsage());
};
return { execute };
});
Verify: Check Execution Log for Remaining units: 10000 (for Scheduled Script).
Scheduled Scripts do NOT auto-yield. Implement manual yield logic to handle large datasets. [src5]
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/runtime', 'N/search', 'N/record', 'N/task', 'N/log'],
(runtime, search, record, task, log) => {
const GOVERNANCE_THRESHOLD = 200;
const execute = (context) => {
const script = runtime.getCurrentScript();
let lastProcessedId = script.getParameter({ name: 'custscript_last_id' }) || 0;
const mySearch = search.create({
type: search.Type.SALES_ORDER,
filters: [
['internalidnumber', 'greaterthan', lastProcessedId],
'AND', ['mainline', 'is', 'T']
],
columns: ['internalid', 'entity', 'total']
});
let processedCount = 0;
mySearch.run().each((result) => {
if (script.getRemainingUsage() < GOVERNANCE_THRESHOLD) {
log.audit('Yielding', `Processed ${processedCount}, last ID: ${result.id}`);
const rescheduleTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: runtime.getCurrentScript().id,
deploymentId: runtime.getCurrentScript().deploymentId,
params: { custscript_last_id: result.id }
});
rescheduleTask.submit();
return false;
}
const rec = record.load({ type: record.Type.SALES_ORDER, id: result.id });
rec.setValue({ fieldId: 'memo', value: 'Processed by batch' });
rec.save();
lastProcessedId = result.id;
processedCount++;
return true;
});
log.audit('Complete', `Processed ${processedCount} records total`);
};
return { execute };
});
Verify: Check Execution Log for yield messages and rescheduled task IDs.
Map/Reduce scripts automatically yield and reschedule when governance limits are approached. [src4]
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
*/
define(['N/search', 'N/record', 'N/log', 'N/runtime'],
(search, record, log, runtime) => {
const getInputData = () => {
return search.create({
type: search.Type.SALES_ORDER,
filters: [['mainline', 'is', 'T'], 'AND', ['status', 'anyof', 'SalesOrd:B']],
columns: ['internalid', 'entity', 'total']
});
};
const map = (context) => {
const searchResult = JSON.parse(context.value);
context.write({
key: searchResult.id,
value: { entity: searchResult.values.entity, total: searchResult.values.total }
});
};
const reduce = (context) => {
const soId = context.key;
const rec = record.load({ type: record.Type.SALES_ORDER, id: soId });
rec.setValue({ fieldId: 'memo', value: 'Processed by Map/Reduce' });
rec.save();
};
const summarize = (context) => {
context.reduceSummary.errors.iterator().each((key, error) => {
log.error('Reduce Error', `Key: ${key}, Error: ${error}`);
return true;
});
log.audit('Usage', `Remaining: ${runtime.getCurrentScript().getRemainingUsage()}`);
};
return { getInputData, map, reduce, summarize };
});
Verify: Navigate to Scripting > Script Status > Map/Reduce to monitor job progress.
search.lookupFields() costs only 1 governance unit vs record.load() at 5-10 units. [src3, src6]
// GOOD: 1 governance unit
const customerData = search.lookupFields({
type: search.Type.CUSTOMER,
id: customerId,
columns: ['companyname', 'email', 'creditlimit']
});
// BAD: 5 governance units (for non-transaction record)
// const customerRec = record.load({ type: record.Type.CUSTOMER, id: customerId });
Verify: Compare getRemainingUsage() before and after each approach.
// Input: afterSubmit context — triggered when a Purchase Order is saved
// Output: Updates related Vendor Bill records (up to governance limit)
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/search', 'N/record', 'N/runtime', 'N/log'], (search, record, runtime, log) => {
const afterSubmit = (context) => {
if (context.type !== context.UserEventType.CREATE) return;
const script = runtime.getCurrentScript();
const vendorId = context.newRecord.getValue({ fieldId: 'entity' });
// search.lookupFields = 1 unit (cheap)
const vendorData = search.lookupFields({
type: search.Type.VENDOR, id: vendorId,
columns: ['companyname', 'terms']
});
// ResultSet.getRange = 10 units
const openBills = search.create({
type: search.Type.VENDOR_BILL,
filters: [['entity', 'is', vendorId], 'AND', ['mainline', 'is', 'T']],
columns: ['internalid', 'total', 'duedate']
}).run().getRange({ start: 0, end: 20 });
for (const bill of openBills) {
if (script.getRemainingUsage() < 50) {
log.audit('Governance limit approaching',
`Remaining: ${script.getRemainingUsage()} units`);
break;
}
// record.submitFields on transaction = 10 units each
record.submitFields({
type: record.Type.VENDOR_BILL, id: bill.id,
values: { memo: `Linked to PO ${context.newRecord.id}` }
});
}
log.debug('Governance', `Units remaining: ${script.getRemainingUsage()}`);
};
return { afterSubmit };
});
// Input: All customers with overdue invoices (>30 days)
// Output: Email notifications sent, grouped by customer
/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
*/
define(['N/search', 'N/email', 'N/log', 'N/runtime'],
(search, email, log, runtime) => {
const getInputData = () => {
return search.create({
type: search.Type.INVOICE,
filters: [
['mainline', 'is', 'T'], 'AND',
['status', 'anyof', 'CustInvc:A'], 'AND',
['daysoverdue', 'greaterthan', 30]
],
columns: ['entity', 'tranid', 'total', 'duedate']
});
};
const map = (context) => {
// 1,000 unit budget — keep lightweight
const result = JSON.parse(context.value);
context.write({
key: result.values.entity.value,
value: { tranId: result.values.tranid, total: result.values.total }
});
};
const reduce = (context) => {
// 5,000 unit budget — email.send = 20 units
const customerId = context.key;
const invoices = context.values.map(v => JSON.parse(v));
email.send({
author: -5,
recipients: [customerId],
subject: `${invoices.length} overdue invoice(s) require attention`,
body: `You have ${invoices.length} overdue invoices.`
});
};
const summarize = (context) => {
context.reduceSummary.errors.iterator().each((key, error) => {
log.error('Reduce error', `Customer ${key}: ${error}`);
return true;
});
};
return { getInputData, map, reduce, summarize };
});
| Record Category | Examples | create/load/copy/transform | save/delete | submitFields |
|---|---|---|---|---|
| Transaction | Invoice, Sales Order, Purchase Order, Cash Refund, Journal Entry | 10 units | 20 units | 10 units |
| Standard Non-Transaction | Customer, Vendor, Employee, Item, Contact | 5 units | 10 units | 5 units |
| Custom Record | Any custom record type | 2 units | 4 units | 2 units |
[src3]
search.lookupFields() at 1 unit is always cheaper than record.load() at 2-10 units. Use lookupFields whenever you need fewer than ~10 fields and don't need to modify the record. [src3, src6]N/cache module's Cache.get() costs only 1 unit. Cache frequently-accessed configuration values to avoid repeated config.load() calls at 10 units each. [src3]| Error Code | Meaning | Cause | Resolution |
|---|---|---|---|
SSS_USAGE_LIMIT_EXCEEDED | Governance units exhausted | Script consumed more units than its type allows | Refactor to reduce consumption; use higher-budget script type; use Map/Reduce |
PERSISTED_DATA_LIMIT_FOR_MAPREDUCE_SCRIPT_EXCEEDED | Map/Reduce 200 MB data cap | Too much data written between phases | Pass only IDs between phases; reload records in reduce |
KEY_LENGTH_IS_OVER_3000_BYTES | Map/Reduce key too long | Key string exceeds 3,000 characters | Use shorter keys (IDs only); pass data in values |
VALUE_LENGTH_IS_OVER_10_MB | Map/Reduce value too large | Single value exceeds 10 MB | Split data across multiple key-value pairs |
SSS_REQUEST_LIMIT_EXCEEDED | Concurrent request limit hit | Too many simultaneous SuiteScript executions | Reduce Map/Reduce concurrency; stagger scheduled scripts |
SSS_TIME_LIMIT_EXCEEDED | Script execution time exceeded | Script ran longer than its time limit | Optimize loops; reduce record count per invocation |
Move external API calls to a Scheduled Script triggered via task.create().submit() (20 units). [src5, src6]Emit only record IDs as values in map; reload the record in reduce. [src4]Check getRemainingUsage() in each loop iteration and reschedule via task.create() when below threshold. [src5]Use log.debug() and the Execution Log for production-scale debugging. [src2]Use a Map/Reduce script for multi-file SFTP operations. [src3]record.save() can trigger User Event scripts on the saved record. Those scripts have their OWN governance budget. But if they save OTHER records, those cascade further. Fix: Audit the full trigger chain; use script parameters as flags to prevent recursive processing. [src1, src4]// BAD — record.load costs 5-10 units; loading 50 customers = 250-500 units
for (const custId of customerIds) {
const custRec = record.load({ type: record.Type.CUSTOMER, id: custId });
const name = custRec.getValue({ fieldId: 'companyname' });
}
// GOOD — search.lookupFields costs 1 unit; 50 customers = 50 units (5-10x cheaper)
for (const custId of customerIds) {
const fields = search.lookupFields({
type: search.Type.CUSTOMER, id: custId,
columns: ['companyname', 'email']
});
}
// BAD — blocks the user's save operation; 10 units per call; timeout risk
const beforeSubmit = (context) => {
const response = https.post({
url: 'https://external-api.example.com/validate',
body: JSON.stringify(context.newRecord.toJSON()),
headers: { 'Content-Type': 'application/json' }
}); // 10 units + blocks until response
if (response.code !== 200) throw 'Validation failed';
};
// GOOD — delegate to Scheduled Script (20 units to submit)
const afterSubmit = (context) => {
if (context.type === context.UserEventType.CREATE) {
const myTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: 'customscript_external_sync',
params: { custscript_record_id: context.newRecord.id }
});
myTask.submit(); // 20 units, async execution
}
};
[src5]
// BAD — costs 30 units per transaction record (10 load + 20 save)
const rec = record.load({ type: record.Type.SALES_ORDER, id: soId });
rec.setValue({ fieldId: 'memo', value: 'Updated' });
rec.save();
// GOOD — costs 10 units for transaction record (vs 30)
record.submitFields({
type: record.Type.SALES_ORDER, id: soId,
values: { memo: 'Updated' }
});
record.load() calls cost the same. A custom record load costs 2 units, but a transaction record load costs 10 units — 5x more. Fix: Always calculate governance budgets based on the actual record types being processed. [src3]Check getRemainingUsage() in every loop iteration; reschedule via task.create().submit() when below a threshold (e.g., 200 units). [src5]Pass only record IDs between phases; reload records in the reduce phase. [src4]Consolidate User Event scripts into a single script; or delegate heavy operations to Scheduled Script. [src1, src6]config.load() costs 10 units each call. Fix: Cache configuration values using the N/cache module (Cache.get costs 1-2 units) with appropriate TTL. [src3]Always test with production-volume datasets; calculate governance consumption mathematically before deployment. [src6, src7]// === Add to any SuiteScript for governance monitoring ===
// Check remaining governance units for current script
const script = runtime.getCurrentScript();
log.debug('Governance Status', {
remainingUsage: script.getRemainingUsage(),
scriptId: script.id,
deploymentId: script.deploymentId
});
// Log governance usage at key checkpoints
const startUnits = script.getRemainingUsage();
// ... perform operations ...
const endUnits = script.getRemainingUsage();
log.audit('Operation cost', `Used ${startUnits - endUnits} governance units`);
// Monitor Map/Reduce job status programmatically
const mrStatus = task.checkStatus({ taskId: 'MAPREDUCETASK_12345' });
log.debug('M/R Status', {
status: mrStatus.status, // PENDING, PROCESSING, COMPLETE, FAILED
stage: mrStatus.stage, // GET_INPUT, MAP, REDUCE, SUMMARIZE
percentComplete: mrStatus.getPercentageCompleted()
});
# Check Map/Reduce job status via UI
# Navigate to: Customization > Scripting > Script Status > Map/Reduce
# Check Scheduled Script queue
# Navigate to: Customization > Scripting > Script Status > Scheduled
# View Execution Log for governance debugging
# Navigate to: Customization > Scripting > Script Execution Log
# Filter by: Script ID, Log Level, Date Range
# Look for: SSS_USAGE_LIMIT_EXCEEDED errors
| SuiteScript Version | NetSuite Release | Status | Key Changes | Notes |
|---|---|---|---|---|
| SuiteScript 2.1 | 2020.1+ | Current (recommended) | ES6+ syntax (let/const, arrow functions, async/await) | Same governance as 2.0 |
| SuiteScript 2.0 | 2015.2+ | Supported | Module-based architecture, AMD define() | Original 2.x version |
| SuiteScript 1.0 | Legacy | Maintenance-only | nlapiYieldScript, nlapiSetRecoveryPoint | Yield functions not in 2.x |
| Release | Change | Impact |
|---|---|---|
| 2026.1 | N/llm module added (generateText: 100 units, embed: 50 units) | AI operations consume significant governance budget |
| 2025.2 | N/documentCapture module (100 units per operation) | OCR/document processing is governance-expensive |
| 2024.1 | Map/Reduce 200 MB hard limit on persisted data | Previously softer enforcement; now hard cap |
| 2023.2 | Custom Tool Scripts introduced (1,000 units) | New script type for SuiteCloud custom tools |
Oracle NetSuite maintains backward compatibility for SuiteScript APIs across releases. SuiteScript 1.0 is in maintenance-only mode — new scripts should use 2.1. Governance unit costs for existing APIs have remained stable since SuiteScript 2.0 was introduced in 2015. [src1, src3]
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Planning which script type to use for a new customization | Sizing SuiteTalk REST/SOAP API throughput | NetSuite SuiteTalk API rate limits card |
Debugging an SSS_USAGE_LIMIT_EXCEEDED error | Configuring Token-Based Authentication | NetSuite authentication guide |
| Estimating if a script will fit within its governance budget | Understanding NetSuite licensing or editions | NetSuite edition comparison |
| Choosing between User Event, Scheduled, or Map/Reduce | Planning CSV import file size limits | NetSuite CSV import reference |
| Optimizing an existing script's governance consumption | Configuring concurrent user limits for SuiteTalk | NetSuite concurrency governance card |
getRemainingUsage() method returns remaining units for the current script invocation, not for the account or the day. There is no API to check SuiteCloud Processor queue availability. [src1]