What It Is

A Scheduled Job is a server-side script stored in the sysauto_script table that executes automatically based on a defined schedule—hourly, daily, weekly, or via custom cron expressions. Unlike Business Rules or Script Includes that respond to user actions or API calls, Scheduled Jobs run independently in the background to handle batch operations, data cleanup, report generation, and system maintenance tasks. They solve the fundamental problem of automating repetitive administrative work that would otherwise require manual intervention or external systems to trigger. ServiceNow's job scheduler handles the timing, queuing, and execution context, ensuring your scripts run reliably even during system maintenance windows or high-load periods.

Architecturally, Scheduled Jobs execute entirely on the server side within the ServiceNow application nodes, never touching the browser or client-side JavaScript context. They run with elevated system privileges in their own execution thread, separate from user sessions, which means they can access restricted tables and perform operations that regular users cannot. The script context provides access to server-side APIs like GlideRecord, GlideSystem, and GlideDateTime, but not client-side constructs like g_form or DOM manipulation methods. This server-only execution model makes them ideal for data processing workflows that need consistent performance regardless of user activity.

Under the hood, ServiceNow's scheduler daemon continuously monitors the sysauto_script table for jobs whose next run time has arrived. When a job is triggered, the platform creates a new execution context, loads your script, and runs it within a transaction boundary that can be committed or rolled back based on success or failure. The system automatically handles logging to syslog, tracks execution statistics in sysauto_script_trace, and manages retry logic for failed executions. This infrastructure ensures your jobs run reliably even during instance upgrades, cluster failovers, or temporary resource constraints.

Without Scheduled Jobs, you cannot perform automated bulk operations, implement time-based business logic, or maintain data integrity across large datasets without manual intervention or expensive external integrations. Critical use cases like SLA escalations, license compliance reporting, stale data cleanup, and integration synchronization all depend on scheduled execution. Any enterprise ServiceNow implementation handling significant data volumes or complex business processes will inevitably require scheduled automation to remain performant and compliant.

Administrators typically use Scheduled Jobs for data maintenance, report generation, and user provisioning workflows, while developers leverage them for integration processing, cache warming, and complex business rule logic that's too expensive to run synchronously. Platform architects design scheduled job hierarchies to handle enterprise-scale automation, often coordinating multiple jobs to process different data segments or handle dependencies between systems. Scheduled Jobs relate closely to Business Rules (which can trigger them via GlideRecord.autoSysFields()), Flow Designer (which offers a more visual alternative for some use cases), and Integration Hub (which often relies on scheduled jobs for data synchronization). Unlike Business Rules that must complete quickly to avoid user experience issues, Scheduled Jobs can run for minutes or hours processing large datasets without impacting interactive performance.

How It Works Under the Hood

ServiceNow's scheduler service runs continuously on each application node, polling the sysauto_script table every few seconds to identify jobs ready for execution. When a job's scheduled time arrives, the scheduler claims it by updating the run_start field and spawning a new execution thread. This claiming mechanism prevents duplicate execution in clustered environments where multiple nodes might see the same job simultaneously. The scheduler maintains a configurable pool of execution threads, queuing jobs when all threads are busy processing other scripts.

Each job executes within its own JavaScript context that includes server-side APIs but isolates variables and functions from other running jobs. The platform automatically provides logging infrastructure, database transaction management, and error handling that captures exceptions and updates job status fields. What many developers don't realize is that the scheduler can pause or terminate long-running jobs during system maintenance, then resume them afterward—your scripts should be designed to handle partial completion gracefully. The execution context also includes access to the current job record via undocumented APIs, allowing scripts to update their own progress or schedule the next run dynamically.

The Execution Lifecycle

  1. Scheduler daemon checks sysauto_script table for jobs where next_action <= now() and active = true
  2. System claims the job by setting run_start timestamp and state = 'running' to prevent duplicate execution
  3. New execution thread spawns with server-side JavaScript context, loading all available APIs and the job's script content
  4. Script executes with automatic transaction boundary, logging all gs.log() output to syslog and performance metrics to sysauto_script_trace
  5. On completion, system updates run_end, calculates next_action based on schedule, and sets state = 'ready' for next execution
  6. Failed executions retry based on max_auto_run setting, with exponential backoff delay between attempts
Free Newsletter

Enjoying this? Get one deep-dive per week.

Join 1,000+ ServiceNow pros — scripts, GlideRecord patterns, Flow Designer techniques, and career moves. Free.

No spam · Unsubscribe anytime

The Core Pattern

Scheduled Job — Daily User Cleanup.js
// Main execution logic - runs in server context with sys_user privileges
try {
    var userGR = new GlideRecord('sys_user');
    // Target inactive users not updated in 90 days
    userGR.addEncodedQuery('active=false^sys_updated_on<javascript:gs.daysAgo(90)');
    userGR.query();
    
    var processedCount = 0;
    var maxBatchSize = 1000; // Prevent timeout on large datasets
    
    while (userGR.next() && processedCount < maxBatchSize) {
        // Call reusable business logic in Script Include
        var userManager = new UserMaintenanceUtils();
        userManager.deactivateUser(userGR.getUniqueValue());
        processedCount++;
    }
    
    // Always log results for monitoring and troubleshooting
    gs.info('User cleanup processed ' + processedCount + ' records');
    
} catch (error) {
    // Scheduled jobs should never fail silently
    gs.error('User cleanup failed: ' + error.toString());
    throw error; // Re-throw to mark job as failed
}
Script Include — UserMaintenanceUtils.js
var UserMaintenanceUtils = Class.create();
UserMaintenanceUtils.prototype = {
    initialize: function() {
        // Constructor for any setup needed
    },
    
    deactivateUser: function(userSysId) {
        // Centralized business logic callable from jobs or other scripts
        var userGR = new GlideRecord('sys_user');
        if (!userGR.get(userSysId)) {
            gs.warn('Cannot deactivate user - not found: ' + userSysId);
            return false;
        }
        
        // Archive user assignments before deactivation
        this._archiveUserAssignments(userSysId);
        
        // Update user record with deactivation metadata
        userGR.setValue('locked_out', true);
        userGR.setValue('u_deactivation_reason', 'Automated cleanup');
        userGR.setValue('u_deactivated_date', new GlideDateTime());
        
        return userGR.update(); // Returns sys_id if successful
    },
    
    _archiveUserAssignments: function(userSysId) {
        // Private method to handle related record cleanup
        // Implementation would move assignments to archive table
    },
    
    type: 'UserMaintenanceUtils'
};

Real-World Scenarios

SLA Breach Notification Escalation

Your organization needs to escalate high-priority incidents to management when SLA breach is imminent, but the built-in SLA engine doesn't provide sufficient customization for complex escalation rules. This job runs every 15 minutes to identify at-risk incidents and trigger appropriate notifications based on business priority and assignment group.

Scheduled Job — SLA Breach Escalation.js
// Find incidents approaching SLA breach within next 2 hours
var incidentGR = new GlideRecord('incident');
incidentGR.addEncodedQuery('priority=1^state!=6^state!=7'); // P1, not resolved/closed
incidentGR.addQuery('sys_created_on', '>', gs.hoursAgo(22)); // Within 22 hours of 24hr SLA
incidentGR.query();

var escalationUtil = new SLAEscalationUtils();
var escalatedCount = 0;

while (incidentGR.next()) {
    var remainingTime = escalationUtil.calculateSLARemaining(incidentGR);
    
    // Escalate if less than 2 hours remaining and not already escalated
    if (remainingTime <= 120 && !incidentGR.getValue('u_escalated')) {
        var escalationLevel = escalationUtil.determineEscalationLevel(
            incidentGR.getValue('assignment_group'),
            remainingTime
        );
        
        escalationUtil.sendEscalationNotification(incidentGR, escalationLevel);
        escalatedCount++;
    }
}

gs.info('SLA escalation processed ' + escalatedCount + ' incidents');

Watch for timezone issues when calculating SLA remaining time—always use GlideDateTime for accurate comparisons. Consider adding a flag field like u_escalated to prevent duplicate notifications if the job runs more frequently than incidents are resolved. Test your escalation logic thoroughly in sub-production—incorrect queries can flood executives with false alerts.

CMDB Health Data Cleanup

Discovery and integrations create thousands of Configuration Items daily, but duplicate and stale records accumulate over time, degrading CMDB accuracy and performance. This weekly job identifies potential duplicates based on name similarity and discovery source, then either merges or marks them for manual review.

Scheduled Job — CMDB Duplicate Cleanup.js
// Process CI duplicates in batches to avoid performance impact
var ciGR = new GlideRecord('cmdb_ci_computer');
ciGR.addQuery('install_status', '!=', '7'); // Not retired
ciGR.addQuery('u_duplicate_check_date', '<', gs.daysAgo(7)); // Not checked recently
ciGR.orderBy('name');
ciGR.setLimit(500); // Process in manageable chunks
ciGR.query();

var cmdbUtil = new CMDBHealthUtils();
var duplicatesFound = 0;
var mergedRecords = 0;

while (ciGR.next()) {
    // Look for potential duplicates by name similarity
    var potentialDupes = cmdbUtil.findSimilarCIs(
        ciGR.getValue('name'),
        ciGR.getValue('discovery_source')
    );
    
    if (potentialDupes.length > 0) {
        duplicatesFound++;
        var mergeResult = cmdbUtil.processDuplicateCI(ciGR, potentialDupes);
        if (mergeResult.merged) mergedRecords++;
    }
    
    // Mark as processed regardless of outcome
    ciGR.setValue('u_duplicate_check_date', new GlideDateTime());
    ciGR.update();
}

gs.info('CMDB cleanup: ' + duplicatesFound + ' duplicates found, ' + mergedRecords + ' merged');

Always use setLimit() when processing large CI tables to prevent job timeouts and memory issues. Add tracking fields like u_duplicate_check_date so subsequent runs can pick up where previous ones left off. Be extremely cautious with automated CI merging—incorrect matches can destroy valuable relationship data and impact service mapping.

Integration Queue Processing

Your ServiceNow instance receives employee data from HR systems via file drops, but the volume is too large for real-time processing and requires complex validation logic. This job processes queued import records in controlled batches, handling failures gracefully and maintaining audit trails for compliance.

Scheduled Job — HR Data Integration Processor.js
// Process HR integration queue with retry logic and error handling
var queueGR = new GlideRecord('u_hr_import_queue');
queueGR.addQuery('state', 'pending');
queueGR.addQuery('retry_count', '<', '3'); // Max 3 retry attempts
queueGR.orderBy('sys_created_on'); // FIFO processing
queueGR.setLimit(200); // Conservative batch size for complex processing
queueGR.query();

var hrProcessor = new HRIntegrationProcessor();
var processedCount = 0;
var errorCount = 0;

while (queueGR.next()) {
    try {
        var result = hrProcessor.processEmployeeRecord({
            employeeId: queueGR.getValue('employee_id'),
            firstName: queueGR.getValue('first_name'),
            lastName: queueGR.getValue('last_name'),
            email: queueGR.getValue('email'),
            department: queueGR.getValue('department')
        });
        
        // Mark as completed with reference to created/updated user
        queueGR.setValue('state', 'completed');
        queueGR.setValue('processed_user', result.userSysId);
        queueGR.update();
        processedCount++;
        
    } catch (error) {
        // Increment retry count and log error details
        hrProcessor.handleProcessingError(queueGR, error.toString());
        errorCount++;
    }
}

gs.info('HR processing: ' + processedCount + ' completed, ' + errorCount + ' errors');

Design your queue table with proper state management (pending, processing, completed, failed) and retry counters to handle job interruptions gracefully. Always process integration queues in date order to maintain data consistency when records have dependencies. Consider adding a separate error handling job that processes failed records with exponential backoff to avoid overwhelming external systems.

The Classic Mistake

⚠️

Running queries without proper limits in scheduled jobs, causing database timeouts and platform instability.

Anti-pattern — Do Not Use This.js
// Daily cleanup job - looks innocent but will kill your instance
var gr = new GlideRecord('incident');
gr.addQuery('state', 7); // Closed
gr.addQuery('sys_created_on', '<', gs.daysAgoStart(365));
gr.query();

var deletedCount = 0;
while (gr.next()) {
    // Process attachments, related records, etc.
    var attachGr = new GlideRecord('sys_attachment');
    attachGr.addQuery('table_name', 'incident');
    attachGr.addQuery('table_sys_id', gr.sys_id);
    attachGr.query();
    attachGr.deleteMultiple();
    
    gr.deleteRecord();
    deletedCount++;
}

This code will process every closed incident from the past year in a single transaction, potentially hundreds of thousands of records. ServiceNow's scheduled job execution has a default timeout of 3600 seconds, but the database will start throwing deadlock exceptions long before that when multiple tables are locked simultaneously. You'll see "Database operation timed out" errors in System Log > All, and the job will fail midway through, leaving your data in an inconsistent state. The platform's transaction manager can't handle bulk operations of this magnitude without proper batching and commit points.

The Fix.js
// Batch processing with proper limits and progress tracking
var BATCH_SIZE = 100;
var MAX_RUNTIME_MS = 300000; // 5 minutes
var startTime = new Date().getTime();

var gr = new GlideRecord('incident');
gr.addQuery('state', 7);
gr.addQuery('sys_created_on', '<', gs.daysAgoStart(365));
gr.orderBy('sys_created_on'); // Consistent ordering for batching
gr.setLimit(BATCH_SIZE);
gr.query();

var processedCount = 0;
while (gr.next() && (new Date().getTime() - startTime) < MAX_RUNTIME_MS) {
    // Process one record at a time with error handling
    try {
        var attachGr = new GlideRecord('sys_attachment');
        attachGr.addQuery('table_name', 'incident');
        attachGr.addQuery('table_sys_id', gr.sys_id);
        attachGr.deleteMultiple();
        
        gr.deleteRecord();
        processedCount++;
        
        // Commit every 50 records to prevent long transactions
        if (processedCount % 50 === 0) {
            gs.log('Processed ' + processedCount + ' incidents', 'ScheduledCleanup');
        }
    } catch (e) {
        gs.error('Failed to process incident ' + gr.number + ': ' + e.message, 'ScheduledCleanup');
    }
}

gs.log('Batch complete. Processed: ' + processedCount + ' records', 'ScheduledCleanup');
💡

Never query more than 1000 records in a scheduled job without explicit limits and runtime checks. Always include ordering, batching, and progress logging.

Performance Rules

  1. Set explicit setLimit() on every GlideRecord query. Over 1000 records per job execution causes database timeouts and memory exhaustion on mid-size instances.
  2. Use GlideAggregate instead of getRowCount() for counting operations over 10,000 records. getRowCount() loads every matching record into memory, causing heap exhaustion.
  3. Add runtime checks every 50-100 iterations using new Date().getTime() and break loops before 5-minute execution limit. Jobs exceeding timeout are killed mid-transaction, corrupting data.
  4. Always include orderBy() on indexed fields for batch processing. Without consistent ordering, you'll process the same records multiple times as database pages shift during execution.
  5. Avoid deleteMultiple() on tables with complex relationships or business rules. Delete records individually in loops to ensure audit trails and prevent cascade failures that take down dependent services.
  6. Use gs.sleep() between intensive operations to prevent database connection pool exhaustion. Even 100ms sleeps every 500 records prevent connection starvation that blocks other users.
  7. Implement progress logging every 100-500 records using gs.log() with record counts and timestamps. Jobs failing at the 95% mark with no logging take hours to debug and re-run safely.
  8. Wrap individual record processing in try-catch blocks with gs.error() logging. One bad record with a null reference or ACL violation will stop the entire job, leaving thousands of records unprocessed.

Side Effects & Platform Behavior

  • All business rules (Before, After, Async) fire for every record operation - insert, update, delete. This includes assignment rules, notification rules, and workflow triggers that can cascade into thousands of additional operations.
  • ACLs are enforced using the system user context, which has elevated privileges but still respects table-level ACLs. Records you can't normally access via UI may be inaccessible to your job.
  • Every execution creates entries in syslog and syslog_transaction tables with execution time, status, and any logged messages. Failed jobs create ERROR level entries visible to all system administrators.
  • Database operations generate audit records in sys_audit for audited tables. Bulk operations can generate millions of audit records, consuming significant database space and affecting performance for days.
  • Email notifications triggered by business rules will queue in sys_email and send immediately unless suppressed. Jobs processing incident assignments can trigger hundreds of notification emails to users and groups.
  • Transform maps and import sets ignore scheduled job context - they always run as the importing user or admin. Don't call transform operations from scheduled jobs expecting consistent user context.
  • Session state and gs.getUser() return the system user. User preferences, time zones, and language settings default to system values, not individual user settings.
  • Client-side code (g_form, alert(), DOM manipulation) fails silently or throws undefined reference errors. Only server-side APIs (gs.*, GlideRecord) work in scheduled jobs.
  • REST API calls and web service integrations use the instance's outbound IP address and system certificates. Firewall rules and SSL certificate validation may behave differently than user-initiated calls.
  • Memory usage accumulates throughout execution and isn't released until job completion. Variables holding large datasets or GlideRecord results consume heap space for the entire execution duration.

Debugging When It Breaks

The most common failure is the dreaded "silence" - your scheduled job simply stops working with no obvious error. Users report missing reports or stale data, but the job appears to run successfully in the Scheduled Jobs module. This typically means the job started but hit an uncaught exception early in execution, causing it to exit without processing any records. The job status shows "Success" because the script didn't crash the entire execution context.

Database timeout errors appear as "java.sql.SQLException: Lock wait timeout exceeded" or "Transaction rolled back" messages in System Log > All. These indicate your job is trying to process too many records simultaneously or competing with other database operations for table locks. Look for ERROR level entries with your script name and check the "Duration" field in the scheduled job execution history - anything over 5 minutes is suspicious.

Memory exhaustion manifests as "java.lang.OutOfMemoryError: Java heap space" followed by complete job failure. This happens when you're loading too many GlideRecord results into memory without proper limits. The job will show "Failed" status, and subsequent executions may also fail until the next system restart clears the memory pressure. Check your setLimit() calls and ensure you're not storing large result sets in JavaScript arrays.

Quick diagnostic checklist:

  • Check execution history in System Definition > Scheduled Jobs - look for duration spikes or status changes
  • Search System Log > All for your script name and ERROR level messages in the last 24 hours
  • Verify the job's Run as user has necessary table access and isn't locked/inactive
  • Test query conditions manually in Scripts - Background with setLimit(10) to verify expected results
  • Add temporary gs.log() statements at the beginning and every 50 records to track execution progress

Quick Reference

  • Always use gs.log() instead of gs.print() - scheduled jobs don't have a console output, and gs.print() messages disappear
  • Maximum safe execution time is 5 minutes (300,000ms) - implement runtime checks to prevent timeout kills
  • The Run as field overrides script execution context - use system for admin operations, specific users for ACL-restricted tables
  • Cron expressions use server timezone, not user timezone - test schedule changes on sub-prod before deploying
  • Jobs with Automatically run script unchecked will not execute - common cause of "my job stopped working" tickets
  • Use GlideDateTime for date calculations instead of JavaScript Date() - timezone handling is more reliable
  • Business rule order of execution still applies - your scheduled job updates can trigger other business rules that modify the same records
  • Include source identification in all gs.log() messages using the second parameter: gs.log('message', 'MyJobName')
  • Test with Scripts - Background before scheduling - but remember background scripts run as your user, not the job's Run as user
  • Scheduled job execution history is automatically purged after 30 days - export logs for long-term troubleshooting of recurring issues