Scheduled Scripts are the workhorses of NetSuite automation. They run in the background on a defined schedule, processing records, syncing data, and performing maintenance tasks without user interaction. If you need to process thousands of records overnight, send batched emails, or sync data with an external system on a recurring basis, Scheduled Scripts are the right tool.
What Are Scheduled Scripts?
A Scheduled Script is a server-side SuiteScript that runs asynchronously on a time-based schedule or via an on-demand trigger. Unlike User Event Scripts that fire on record actions, Scheduled Scripts operate independently with a governance limit of 10,000 units.
Common use cases include:
- Nightly data synchronization with external systems
- Periodic record cleanup and archival
- Batch email notifications and digests
- Automated report generation
- Data integrity checks and corrections
Scheduled Scripts vs. Map/Reduce Scripts
| Feature | Scheduled Script | Map/Reduce Script |
|---|---|---|
| Governance limit | 10,000 units | 10,000 units per stage |
| Parallelism | Single-threaded | Multi-threaded (up to 5 queues) |
| Restart on failure | Manual rescheduling | Automatic per-key retry |
| Best for | Sequential processing, simple batches | Large datasets, parallelizable work |
| Complexity | Lower | Higher |
Choose a Scheduled Script when your processing is straightforward and sequential. Move to Map/Reduce when you need parallel execution, automatic retry, or are processing millions of records.
Basic Structure
Every Scheduled Script has a single entry point: the execute function.
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/search', 'N/record', 'N/log'], (search, record, log) => {
const execute = (context) => {
log.audit('Script Start', `Trigger type: ${context.type}`);
// Your batch processing logic goes here
log.audit('Script Complete', 'Processing finished successfully');
};
return { execute };
});The context.type tells you how the script was triggered: SCHEDULED (by deployment schedule), ON_DEMAND (via N/task), USER_INTERFACE (manually from deployment page), ABORTED, or SKIPPED.
Script Parameters for Configurable Behavior
Hardcoding values makes scripts inflexible. Script parameters let administrators change behavior without modifying code:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/runtime', 'N/search', 'N/log'], (runtime, search, log) => {
const execute = (context) => {
const script = runtime.getCurrentScript();
const savedSearchId = script.getParameter({ name: 'custscript_search_id' });
const batchSize = script.getParameter({ name: 'custscript_batch_size' }) || 500;
const notifyEmail = script.getParameter({ name: 'custscript_notify_email' });
if (!savedSearchId) {
log.error('Configuration Error', 'Saved Search ID parameter is required');
return;
}
const results = search.load({ id: savedSearchId });
// Process results...
};
return { execute };
});Add parameters under the Script record's Parameters tab. This lets you reuse the same script across multiple deployments, each configured differently.
Governance Management
Every SuiteScript API call consumes governance units. Scheduled Scripts have a 10,000-unit budget per execution. Common costs: record.load() = 10 units, record.save() = 20 units, search.create() = 5 units, email.send() = 20 units, http.request() = 10 units.
Always monitor governance within loops:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/runtime', 'N/search', 'N/record', 'N/log'], (runtime, search, record, log) => {
const GOVERNANCE_THRESHOLD = 200;
const execute = (context) => {
const script = runtime.getCurrentScript();
let processedCount = 0;
const orderSearch = search.create({
type: search.Type.SALES_ORDER,
filters: [
['status', 'anyof', 'SalesOrd:B'],
['datecreated', 'onorbefore', 'daysago7']
],
columns: ['entity', 'total', 'tranid']
});
orderSearch.run().each((result) => {
if (script.getRemainingUsage() < GOVERNANCE_THRESHOLD) {
log.audit('Governance Limit', `Stopping at ${processedCount} records`);
return false;
}
try {
const orderRecord = record.load({ type: record.Type.SALES_ORDER, id: result.id });
orderRecord.setValue({ fieldId: 'custbody_reviewed', value: true });
orderRecord.save();
processedCount++;
} catch (e) {
log.error(`Error processing order ${result.id}`, e.message);
}
return true;
});
log.audit('Execution Summary', `Processed ${processedCount} orders`);
};
return { execute };
});Rescheduling Patterns
When you have more records than one execution can handle, use N/task to reschedule the script and continue from where you left off:
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
*/
define(['N/runtime', 'N/search', 'N/record', 'N/task', 'N/log'],
(runtime, search, record, task, log) => {
const GOVERNANCE_THRESHOLD = 500;
const execute = (context) => {
const script = runtime.getCurrentScript();
const lastProcessedId = script.getParameter({ name: 'custscript_last_id' }) || 0;
const invoiceSearch = search.create({
type: search.Type.INVOICE,
filters: [
['mainline', 'is', 'T'],
['status', 'anyof', 'CustInvc:A'],
['internalidnumber', 'greaterthan', lastProcessedId]
],
columns: [
search.createColumn({ name: 'internalid', sort: search.Sort.ASC }),
'entity', 'total'
]
});
let processedCount = 0;
let currentId = lastProcessedId;
let hasMoreRecords = false;
invoiceSearch.run().each((result) => {
if (script.getRemainingUsage() < GOVERNANCE_THRESHOLD) {
hasMoreRecords = true;
return false;
}
currentId = result.id;
try {
record.submitFields({
type: record.Type.INVOICE, id: result.id,
values: { custbody_batch_processed: true }
});
processedCount++;
} catch (e) {
log.error(`Error on Invoice ${result.id}`, e.message);
}
return true;
});
log.audit('Batch Complete', `Processed ${processedCount}. Last ID: ${currentId}`);
if (hasMoreRecords) {
try {
const scriptTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: runtime.getCurrentScript().id,
deploymentId: runtime.getCurrentScript().deploymentId,
params: { custscript_last_id: currentId }
});
scriptTask.submit();
log.audit('Rescheduled', `Continuing from ID: ${currentId}`);
} catch (e) {
log.error('Reschedule Failed', e.message);
}
}
};
return { execute };
});Key rescheduling tips: sort search results by internal ID for consistent ordering, use a separate "Not Scheduled" deployment for on-demand runs, and always wrap the reschedule call in try-catch since the queue might be full.
Scheduling Options
Configure recurring schedules through the deployment record:
- Every 15 / 30 / 60 minutes - Near-real-time sync needs
- Daily - Specify time of day (e.g., 2:00 AM)
- Weekly - Choose day and time
- Monthly / Yearly - Choose day of month and time
Create multiple deployments of the same script for different schedules or configurations, such as customdeploy_sync_daily_us (Daily at 2 AM, Region = US) and customdeploy_sync_daily_eu (Daily at 10 PM, Region = EU).
Practical Examples
Nightly Order Status Sync
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @NModuleScope SameAccount
*/
define(['N/runtime', 'N/search', 'N/https', 'N/log'],
(runtime, search, https, log) => {
const execute = (context) => {
const script = runtime.getCurrentScript();
const apiKey = script.getParameter({ name: 'custscript_api_key' });
const orderSearch = search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
['lastmodifieddate', 'onorafter', 'yesterday'],
['status', 'anyof', ['SalesOrd:B', 'SalesOrd:D', 'SalesOrd:F']]
],
columns: ['tranid', 'entity', 'status', 'total', 'shipaddress']
});
const ordersToSync = [];
orderSearch.run().each((result) => {
if (script.getRemainingUsage() < 300) return false;
ordersToSync.push({
netsuite_id: result.id,
order_number: result.getValue('tranid'),
status: result.getText('status'),
total: result.getValue('total')
});
return true;
});
// Send in batches of 50
let synced = 0;
for (let i = 0; i < ordersToSync.length; i += 50) {
if (script.getRemainingUsage() < 300) break;
const batch = ordersToSync.slice(i, i + 50);
try {
const response = https.post({
url: 'https://fulfillment.example.com/api/v2/orders',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
body: JSON.stringify({ orders: batch })
});
if (response.code === 200) synced += batch.length;
else log.error('API Error', `Status: ${response.code}`);
} catch (e) {
log.error('Sync Error', e.message);
}
}
log.audit('Sync Complete', { total: ordersToSync.length, synced });
};
return { execute };
});Weekly Data Cleanup and Archival
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @NModuleScope SameAccount
*/
define(['N/runtime', 'N/search', 'N/record', 'N/task', 'N/log'],
(runtime, search, record, task, log) => {
const GOVERNANCE_THRESHOLD = 400;
const execute = (context) => {
const script = runtime.getCurrentScript();
const phase = script.getParameter({ name: 'custscript_cleanup_phase' }) || 'archive';
if (phase === 'archive') {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90);
const cutoffStr = `${cutoff.getMonth() + 1}/${cutoff.getDate()}/${cutoff.getFullYear()}`;
let count = 0;
search.create({
type: 'customrecord_activity_log',
filters: [['created', 'before', cutoffStr], ['isinactive', 'is', 'F']],
columns: [search.createColumn({ name: 'internalid', sort: search.Sort.ASC })]
}).run().each((result) => {
if (script.getRemainingUsage() < GOVERNANCE_THRESHOLD) return false;
try {
record.submitFields({
type: 'customrecord_activity_log', id: result.id,
values: { isinactive: true }
});
count++;
} catch (e) { log.error(`Archive Error ${result.id}`, e.message); }
return true;
});
log.audit('Archive Complete', `Archived ${count} records`);
// Chain to purge phase
const nextTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: script.id, deploymentId: script.deploymentId,
params: { custscript_cleanup_phase: 'purge' }
});
nextTask.submit();
} else if (phase === 'purge') {
let count = 0;
search.create({
type: 'customrecord_temp_staging',
filters: [['created', 'before', 'daysago7'], ['custrecord_staging_status', 'is', 'Complete']],
columns: [search.createColumn({ name: 'internalid', sort: search.Sort.ASC })]
}).run().each((result) => {
if (script.getRemainingUsage() < GOVERNANCE_THRESHOLD) return false;
try { record.delete({ type: 'customrecord_temp_staging', id: result.id }); count++; }
catch (e) { log.error(`Delete Error ${result.id}`, e.message); }
return true;
});
log.audit('Purge Complete', `Deleted ${count} temp records`);
}
};
return { execute };
});Daily Email Notification Digest
/**
* @NApiVersion 2.1
* @NScriptType ScheduledScript
* @NModuleScope SameAccount
*/
define(['N/runtime', 'N/search', 'N/record', 'N/email', 'N/log'],
(runtime, search, record, email, log) => {
const execute = (context) => {
const script = runtime.getCurrentScript();
const senderId = script.getParameter({ name: 'custscript_sender_id' });
// Group unsent notifications by recipient
const recipientMap = {};
search.create({
type: 'customrecord_notification_queue',
filters: [['custrecord_notif_sent', 'is', 'F'], ['created', 'within', 'yesterday', 'today']],
columns: ['custrecord_notif_recipient', 'custrecord_notif_subject',
'custrecord_notif_message', 'custrecord_notif_priority']
}).run().each((result) => {
if (script.getRemainingUsage() < 500) return false;
const rid = result.getValue('custrecord_notif_recipient');
if (!recipientMap[rid]) recipientMap[rid] = [];
recipientMap[rid].push({
id: result.id,
subject: result.getValue('custrecord_notif_subject'),
message: result.getValue('custrecord_notif_message'),
priority: result.getText('custrecord_notif_priority')
});
return true;
});
// Send digest to each recipient
let sent = 0;
for (const [recipientId, notifications] of Object.entries(recipientMap)) {
if (script.getRemainingUsage() < 300) break;
try {
let body = '<h2>Your Daily Digest</h2><ul>';
notifications.forEach((n) => { body += `<li><strong>[${n.priority}]</strong> ${n.subject}: ${n.message}</li>`; });
body += '</ul>';
email.send({
author: senderId, recipients: parseInt(recipientId),
subject: `Daily Digest - ${notifications.length} items`, body
});
notifications.forEach((n) => {
record.submitFields({ type: 'customrecord_notification_queue', id: n.id, values: { custrecord_notif_sent: true } });
});
sent++;
} catch (e) { log.error(`Email Error ${recipientId}`, e.message); }
}
log.audit('Digest Complete', { recipients: sent, total: Object.keys(recipientMap).length });
};
return { execute };
});Using N/task to Chain Scripts
Beyond rescheduling, N/task lets you trigger Map/Reduce scripts, CSV imports, and other scheduled scripts:
// Trigger a Map/Reduce script
const mrTask = task.create({
taskType: task.TaskType.MAP_REDUCE,
scriptId: 'customscript_process_results',
deploymentId: 'customdeploy_process_results',
params: { custscript_mr_search_id: savedSearchId }
});
const taskId = mrTask.submit();
// Check task status
const status = task.checkStatus({ taskId });
log.debug('Status', { status: status.status, pct: status.getPercentageCompleted() });Error Handling and Notification on Failure
Wrap your main logic in try-catch-finally, track statistics, and send alerts for critical failures:
const execute = (context) => {
const script = runtime.getCurrentScript();
const adminId = script.getParameter({ name: 'custscript_admin_id' });
const stats = { processed: 0, errors: 0, errorDetails: [], startTime: new Date() };
try {
processRecords(script, stats);
} catch (e) {
log.error('Fatal Error', { message: e.message, stack: e.stack });
stats.errorDetails.push({ type: 'FATAL', message: e.message });
} finally {
stats.duration = (new Date() - stats.startTime) / 1000;
log.audit('Summary', stats);
if (stats.errors > 0 && adminId) {
const errorList = stats.errorDetails.map((e) => `- ${e.recordId || 'N/A'}: ${e.message}`).join('\n');
email.send({
author: adminId, recipients: adminId,
subject: `[ALERT] Scheduled Script - ${stats.errors} failures`,
body: `Processed: ${stats.processed}\nErrors: ${stats.errors}\nDuration: ${stats.duration}s\n\n${errorList}`
});
}
}
};Monitoring and Logging Best Practices
log.debug()- Development detail. Disable in production by setting deployment log level to Audit.log.audit()- Milestones: script start, completion, record counts. Always active.log.error()- Errors that need attention but do not stop execution.- Structured logging - Pass objects instead of strings for searchable logs:
log.audit('Execution Summary', {
recordsProcessed: 150, duration: 45, errors: 3,
scriptId: runtime.getCurrentScript().id
});Check execution history at Customization > Scripting > Script Deployments > [Your Deployment] > Execution Log. For scripts that run frequently, consider writing summary stats to a custom record so you can build saved searches and dashboards around execution history.
Deployment Configuration Tips
| Setting | Recommendation |
|---|---|
| Status | Released for production, Testing during development |
| Log Level | Audit for production, Debug during development |
| Execute As Role | Minimum necessary permissions |
| Queue | Assign to a specific processor queue to avoid contention |
NetSuite processes scheduled scripts through queues (typically 5 slots per account). Schedule heavy scripts during off-peak hours and stagger start times by 15-30 minutes to prevent queue congestion.
Build your scripts to be idempotent: running the same script twice on the same data should produce the same result. This protects you from partial execution issues, since any records already processed before a failure remain processed with no automatic rollback.
Test in this order: manual trigger via the deployment page, small dataset with limited parameters, sandbox validation, then production release.
Next Steps
Now that you know how to build Scheduled Scripts, explore these related topics:
- Map/Reduce Scripts for parallel processing of large datasets
- User Event Scripts for record-triggered automation
- RESTlets for on-demand API triggers
Need automated processes in your NetSuite environment? Contact our development team to discuss your automation needs.