What It Is

Catalog Client Scripts execute browser-side JavaScript specifically within the context of Service Catalog items, providing dynamic behavior for catalog variables during the ordering process. Unlike standard Client Scripts that operate on form fields, these scripts work with catalog variables using specialized methods like g_form.getValue() and g_form.getReference() where the parameter is the catalog variable name rather than a field name. They bridge the gap between static catalog forms and the dynamic user experiences that modern service delivery demands.

Architecturally, Catalog Client Scripts sit in the presentation layer, executing in the user's browser after the catalog form renders. They run in both the Service Portal (Angular-based) and the classic catalog interface, though with subtle behavioral differences that can catch developers off guard. The script execution context includes access to the g_form API, catalog variable values, and the ability to make GlideAjax calls back to the server. Unlike server-side catalog code that processes during submission, these scripts provide immediate feedback and validation as users interact with the form.

ServiceNow processes Catalog Client Scripts by injecting them into the catalog form's HTML after evaluating their conditions and determining applicability to the current catalog item. The platform serializes the catalog item's variables into a client-side data structure, then attaches event handlers based on the script's type (onChange, onLoad, onSubmit). When Service Portal renders the same catalog item, ServiceNow translates these scripts to work within the Angular framework, sometimes requiring additional wrapper code that developers never see but explains why certain advanced techniques behave differently between interfaces.

Without Catalog Client Scripts, you cannot provide real-time validation of catalog variable combinations, dynamically populate choice lists based on other selections, or hide/show variable sections based on user input. Standard Client Scripts won't fire on catalog forms because catalog variables aren't database fields—they're metadata that becomes fields only after submission. You also cannot achieve conditional mandatory variables, complex field dependencies, or user-friendly error messages without resorting to server-side validation that only triggers on submit, creating a poor user experience.

ServiceNow administrators typically create basic Catalog Client Scripts for simple show/hide logic and mandatory field validation, while developers handle complex scenarios involving GlideAjax calls, advanced variable manipulation, and integration with external systems. Platform architects use them as part of larger catalog automation strategies, often paired with Catalog UI Policies for declarative rules and backed by Script Includes for server-side processing. The complexity ranges from simple if/then logic to sophisticated workflows that dynamically restructure the entire catalog form based on user selections.

Catalog Client Scripts work closely with Catalog UI Policies, which handle simpler declarative rules without custom code, and often rely on GlideAjax calls to Script Includes for server-side data retrieval. They complement Workflow activities and Business Rules that process the submitted request, creating a full stack from user interaction to backend fulfillment. Understanding the relationship between these components is crucial—UI Policies for simple rules, Client Scripts for complex logic, and Business Rules for post-submission processing—prevents over-engineering simple requirements and under-engineering complex ones.

How It Works Under the Hood

When a user accesses a catalog item, ServiceNow's catalog engine queries the sc_cat_item_client_script table for scripts associated with that item, evaluating conditions to determine which scripts apply to the current user and context. The platform then serializes the catalog variables into a client-side JavaScript object and injects the applicable script code into the form's HTML. For Service Portal, an additional translation layer converts the traditional g_form API calls to work within the Angular framework, which explains why some advanced DOM manipulation techniques work differently between classic and portal interfaces.

The script execution environment includes access to specialized APIs that developers often overlook: g_form.getDisplayValue() for choice lists, g_form.addOption() for dynamic choice manipulation, and g_form.getControl() for direct DOM access when necessary. Behind the scenes, ServiceNow maintains a real-time mapping between catalog variable names and their current values, automatically handling type conversion and validation rules defined in the variable definitions. When scripts make server calls through GlideAjax, the platform preserves the current catalog context, allowing server-side scripts to access the same variable values for complex processing.

The key architectural insight is that catalog variables exist in a hybrid state—they're not database fields until the request is submitted, but they behave like fields in the client-side scripting environment. ServiceNow accomplishes this by creating a virtual field layer that translates between the catalog variable metadata and the familiar g_form API. This explains why certain field methods work differently on catalog variables and why direct DOM manipulation sometimes produces unexpected results—you're working with a carefully constructed abstraction layer rather than actual form fields.

The Request Lifecycle

  1. User navigates to catalog item → ServiceNow queries sc_cat_item_client_script table for applicable scripts based on conditions and user context
  2. Platform serializes catalog variable definitions and default values into client-side JavaScript objects
  3. Form renders with catalog variables mapped to virtual field layer → onLoad scripts execute with full g_form API access
  4. Event handlers attach to catalog variables → onChange scripts fire as users interact with variables
  5. GlideAjax calls preserve catalog context → server-side Script Includes can access current variable values for complex processing
  6. Form submission triggers onSubmit scripts for final validation → variable values serialize to request record → server-side processing begins
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

Catalog Client Script — Laptop Request onChange.js
// onChange script for 'operating_system' variable
// Dynamically populate software options based on OS selection
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    // Avoid infinite loops during form initialization
    if (isLoading || newValue === '') return;
    
    // Clear current software selections when OS changes
    g_form.clearOptions('software_package');
    g_form.setValue('software_package', '');
    
    // Call server to get compatible software for selected OS
    var ga = new GlideAjax('CatalogUtils');
    ga.addParam('sysparm_name', 'getSoftwareForOS');
    ga.addParam('sysparm_os', newValue);
    ga.addParam('sysparm_department', g_form.getValue('department'));
    
    ga.getXMLAnswer(function(response) {
        var packages = JSON.parse(response);
        // Populate dropdown with compatible software
        packages.forEach(function(pkg) {
            g_form.addOption('software_package', pkg.value, pkg.label);
        });
    });
}
Script Include — CatalogUtils.js
var CatalogUtils = Class.create();
CatalogUtils.prototype = Object.extendsObject(AbstractAjaxProcessor, {
    
    getSoftwareForOS: function() {
        // Get parameters from client script
        var osType = this.getParameter('sysparm_os');
        var department = this.getParameter('sysparm_department');
        var packages = [];
        
        // Query software compatibility table
        var gr = new GlideRecord('u_software_catalog');
        gr.addQuery('u_operating_system', osType);
        gr.addQuery('u_active', true);
        // Add department-specific filtering if needed
        if (department) {
            gr.addQuery('u_available_to', 'CONTAINS', department);
        }
        gr.orderBy('u_name');
        gr.query();
        
        while (gr.next()) {
            packages.push({
                value: gr.getValue('sys_id'),
                label: gr.getDisplayValue('u_name')
            });
        }
        
        // Return JSON for client consumption
        return JSON.stringify(packages);
    },
    
    type: 'CatalogUtils'
});

Real-World Scenarios

Hardware Request with Dynamic Cost Calculation

A laptop ordering form needs real-time cost calculation based on hardware specifications and quantity, with costs varying by department budget codes. The calculation must update immediately as users change specifications to prevent budget overruns.

Catalog Client Script — Hardware Cost Calculator onChange.js
// Triggers on changes to any cost-affecting variable
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    if (isLoading) return;
    
    // Get current specifications
    var processor = g_form.getValue('processor_type');
    var memory = g_form.getValue('memory_size');
    var storage = g_form.getValue('storage_type');
    var quantity = g_form.getValue('quantity') || 1;
    var department = g_form.getValue('requesting_department');
    
    // Only calculate if we have minimum required values
    if (!processor || !memory || !storage) {
        g_form.setValue('total_cost', '');
        g_form.setValue('cost_per_unit', '');
        return;
    }
    
    var ga = new GlideAjax('HardwarePricingUtils');
    ga.addParam('sysparm_name', 'calculateHardwareCost');
    ga.addParam('sysparm_processor', processor);
    ga.addParam('sysparm_memory', memory);
    ga.addParam('sysparm_storage', storage);
    ga.addParam('sysparm_quantity', quantity);
    ga.addParam('sysparm_department', department);
    
    ga.getXMLAnswer(function(response) {
        var pricing = JSON.parse(response);
        g_form.setValue('cost_per_unit', pricing.unitCost);
        g_form.setValue('total_cost', pricing.totalCost);
        
        // Show warning if over department budget
        if (pricing.overBudget) {
            g_form.addErrorMessage('Warning: Request exceeds department budget limit');
        } else {
            g_form.clearMessages();
        }
    });
}
Script Include — HardwarePricingUtils.js
var HardwarePricingUtils = Class.create();
HardwarePricingUtils.prototype = Object.extendsObject(AbstractAjaxProcessor, {
    
    calculateHardwareCost: function() {
        var processor = this.getParameter('sysparm_processor');
        var memory = this.getParameter('sysparm_memory');
        var storage = this.getParameter('sysparm_storage');
        var quantity = parseInt(this.getParameter('sysparm_quantity'));
        var department = this.getParameter('sysparm_department');
        
        // Base laptop cost
        var baseCost = 800;
        var processorCost = this._getProcessorCost(processor);
        var memoryCost = this._getMemoryCost(memory);
        var storageCost = this._getStorageCost(storage);
        
        var unitCost = baseCost + processorCost + memoryCost + storageCost;
        var totalCost = unitCost * quantity;
        
        // Check department budget
        var budget = this._getDepartmentBudget(department);
        var overBudget = totalCost > budget;
        
        return JSON.stringify({
            unitCost: unitCost.toFixed(2),
            totalCost: totalCost.toFixed(2),
            overBudget: overBudget,
            remainingBudget: (budget - totalCost).toFixed(2)
        });
    },
    
    _getProcessorCost: function(processor) {
        var costs = { 'i5': 0, 'i7': 300, 'i9': 800 };
        return costs[processor] || 0;
    },
    
    type: 'HardwarePricingUtils'
});

Watch for timing issues when multiple variables change rapidly—debounce the calculations to avoid overwhelming the server with requests. Also be aware that budget checks should be re-validated server-side during fulfillment since client-side values can be manipulated.

⚠️

Always validate critical business rules server-side during fulfillment. Client-side scripts can be bypassed or manipulated, so treat them as user experience enhancements rather than security controls.

Access Request with Manager Approval Routing

An application access form needs to automatically determine approval requirements based on the requested application's risk level and the requester's role. High-risk applications require additional security review steps that users should see before submitting.

Catalog Client Script — Access Request onChange.js
// Triggers when application selection changes
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    if (isLoading || newValue === '') {
        g_form.setVisible('security_justification', false);
        g_form.setVisible('manager_approval_note', false);
        g_form.setValue('estimated_approval_time', '');
        return;
    }
    
    var ga = new GlideAjax('AccessRequestUtils');
    ga.addParam('sysparm_name', 'getApplicationApprovalInfo');
    ga.addParam('sysparm_application', newValue);
    ga.addParam('sysparm_requester', g_user.userID);
    
    ga.getXMLAnswer(function(response) {
        var approvalInfo = JSON.parse(response);
        
        // Show/hide additional fields based on risk level
        if (approvalInfo.riskLevel === 'high') {
            g_form.setVisible('security_justification', true);
            g_form.setMandatory('security_justification', true);
            g_form.setValue('estimated_approval_time', '5-7 business days');
        } else {
            g_form.setVisible('security_justification', false);
            g_form.setMandatory('security_justification', false);
            g_form.setValue('estimated_approval_time', '1-2 business days');
        }
        
        // Show manager approval requirements
        if (approvalInfo.requiresManagerApproval) {
            g_form.setVisible('manager_approval_note', true);
            g_form.setValue('approver_display', approvalInfo.managerName);
        } else {
            g_form.setVisible('manager_approval_note', false);
        }
        
        // Display compliance requirements
        if (approvalInfo.complianceNotes) {
            g_form.addInfoMessage(approvalInfo.complianceNotes);
        }
    });
}
Script Include — AccessRequestUtils.js
var AccessRequestUtils = Class.create();
AccessRequestUtils.prototype = Object.extendsObject(AbstractAjaxProcessor, {
    
    getApplicationApprovalInfo: function() {
        var appId = this.getParameter('sysparm_application');
        var requesterId = this.getParameter('sysparm_requester');
        
        // Get application details
        var app = new GlideRecord('u_applications');
        if (!app.get(appId)) {
            return JSON.stringify({ error: 'Application not found' });
        }
        
        var riskLevel = app.getValue('u_risk_level');
        var requiresManagerApproval = app.getBooleanAttribute('u_manager_approval_required');
        
        // Get requester's manager
        var user = new GlideRecord('sys_user');
        user.get(requesterId);
        var managerName = user.manager.getDisplayValue();
        
        // Determine compliance requirements
        var complianceNotes = '';
        if (riskLevel === 'high') {
            complianceNotes = 'This application requires security review due to data sensitivity. Additional approval time may be required.';
        }
        
        return JSON.stringify({
            riskLevel: riskLevel,
            requiresManagerApproval: requiresManagerApproval,
            managerName: managerName,
            complianceNotes: complianceNotes,
            estimatedDays: riskLevel === 'high' ? 7 : 2
        });
    },
    
    type: 'AccessRequestUtils'
});

Be careful with user context in GlideAjax calls—the server-side script runs as the calling user, so ensure they have read access to referenced tables. Consider caching application metadata to avoid repeated database queries for commonly requested applications.

Conference Room Booking with Availability Check

A meeting room reservation form must check real-time availability when users select date, time, and location, preventing double-bookings while showing alternative options. The system needs to account for setup/cleanup time and recurring meetings.

Catalog Client Script — Room Booking onChange.js
// Triggers when date, time, or location changes
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    if (isLoading) return;
    
    var bookingDate = g_form.getValue('booking_date');
    var startTime = g_form.getValue('start_time');
    var endTime = g_form.getValue('end_time');
    var location = g_form.getValue('location');
    var roomType = g_form.getValue('room_type');
    
    // Clear previous availability messages
    g_form.hideFieldMsg('available_rooms', true);
    
    // Need minimum date/time info to check availability
    if (!bookingDate || !startTime || !endTime) {
        g_form.clearOptions('available_rooms');
        return;
    }
    
    // Show loading indicator
    g_form.showFieldMsg('available_rooms', 'Checking availability...', 'info');
    
    var ga = new GlideAjax('RoomBookingUtils');
    ga.addParam('sysparm_name', 'checkRoomAvailability');
    ga.addParam('sysparm_date', bookingDate);
    ga.addParam('sysparm_start_time', startTime);
    ga.addParam('sysparm_end_time', endTime);
    ga.addParam('sysparm_location', location);
    ga.addParam('sysparm_room_type', roomType);
    
    ga.getXMLAnswer(function(response) {
        var availability = JSON.parse(response);
        
        // Clear and repopulate room options
        g_form.clearOptions('available_rooms');
        g_form.hideFieldMsg('available_rooms', true);
        
        if (availability.availableRooms.length > 0) {
            availability.availableRooms.forEach(function(room) {
                g_form.addOption('available_rooms', room.id, room.name + ' (Cap: ' + room.capacity + ')');
            });
            g_form.showFieldMsg('available_rooms', availability.availableRooms.length + ' rooms available', 'success');
        } else {
            g_form.showFieldMsg('available_rooms', 'No rooms available for selected time. Try: ' + availability.suggestions.join(', '), 'warning');
        }
    });
}
Script Include — RoomBookingUtils.js
var RoomBookingUtils = Class.create();
RoomBookingUtils.prototype = Object.extendsObject(AbstractAjaxProcessor, {
    
    checkRoomAvailability: function() {
        var date = this.getParameter('sysparm_date');
        var startTime = this.getParameter('sysparm_start_time');
        var endTime = this.getParameter('sysparm_end_time');
        var location = this.getParameter('sysparm_location');
        var roomType = this.getParameter('sysparm_room_type');
        
        // Convert to proper datetime for comparison
        var requestStart = new GlideDateTime(date + ' ' + startTime);
        var requestEnd = new GlideDateTime(date + ' ' + endTime);
        
        // Add 15-minute buffer for setup/cleanup
        requestStart.addSeconds(-900); // 15 minutes before
        requestEnd.addSeconds(900);    // 15 minutes after
        
        var availableRooms = [];
        var suggestions = [];
        
        // Query all rooms matching criteria
        var roomGr = new GlideRecord('u_conference_rooms');
        if (location) roomGr.addQuery('u_location', location);
        if (roomType) roomGr.addQuery('u_room_type', roomType);
        roomGr.addQuery('u_active', true);
        roomGr.query();
        
        while (roomGr.next()) {
            if (this._isRoomAvailable(roomGr.sys_id, requestStart, requestEnd)) {
                availableRooms.push({
                    id: roomGr.getValue('sys_id'),
                    name: roomGr.getValue('u_name'),
                    capacity: roomGr.getValue('u_capacity')
                });
            }
        }
        
        // If no rooms available, suggest alternative times
        if (availableRooms.length === 0) {
            suggestions = this._getAlternativeTimes(date, location, roomType);
        }
        
        return JSON.stringify({
            availableRooms: availableRooms,
            suggestions: suggestions
        });
    },
    
    type: 'RoomBookingUtils'
});

Remember that availability checks are point-in-time snapshots—implement optimistic locking or final validation during submission to handle race conditions. Also consider timezone handling if your organization spans multiple zones, as client-side date/time values may not match server expectations.

The Classic Mistake

⚠️

Using g_form.getValue() on catalog variables before they're fully initialized in the onChange handler.

Anti-pattern — Do Not Use This.js
// onChange for 'location' variable
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    // This fails intermittently - getValue returns null or stale data
    var urgency = g_form.getValue('urgency');
    var category = g_form.getValue('category');
    
    if (urgency == '1' && category == 'hardware') {
        g_form.setVisible('special_instructions', true);
        g_form.setValue('approval_required', 'true');
    }
    
    // Chained variable updates that rely on previous setValue calls
    g_form.setValue('location_manager', getManagerForLocation(newValue));
    var manager = g_form.getValue('location_manager'); // Often returns null
    updateApprovalWorkflow(manager);
}

This fails because catalog variables load asynchronously and getValue() returns null or stale values during the variable initialization phase. The browser console shows "Cannot read property of null" errors or silent logic failures where conditions never match. ServiceNow's catalog form renderer loads variables in dependency order, but your script executes before dependent variables are fully populated. Additionally, chained setValue() calls don't immediately update the DOM, so subsequent getValue() calls in the same execution context return the old value.

The Fix.js
// onChange for 'location' variable
function onChange(control, oldValue, newValue, isLoading, isTemplate) {
    // Guard against initialization calls
    if (isLoading) return;
    
    // Use setTimeout to ensure DOM updates complete
    setTimeout(function() {
        var urgency = g_form.getValue('urgency');
        var category = g_form.getValue('category');
        
        if (urgency == '1' && category == 'hardware') {
            g_form.setVisible('special_instructions', true);
            g_form.setValue('approval_required', 'true');
        }
        
        // Chain dependent operations with additional timeout
        g_form.setValue('location_manager', getManagerForLocation(newValue));
        setTimeout(function() {
            var manager = g_form.getValue('location_manager');
            if (manager) updateApprovalWorkflow(manager);
        }, 100);
    }, 50);
}
💡

Always check isLoading parameter and use setTimeout(function(){}, 50) when reading values of other catalog variables in onChange handlers.

Performance Rules

  1. Never call GlideAjax synchronously (ga.getXMLWait()) in onChange handlers. Synchronous calls over 3 seconds freeze the browser tab and generate "Unresponsive script" warnings in Chrome.
  2. Limit g_form.setValue() calls to maximum 5 per onChange execution. Each call triggers DOM manipulation and dependent variable recalculation, causing exponential performance degradation beyond 5 updates.
  3. Debounce rapid onChange events using setTimeout and clearTimeout. Users typing in reference fields trigger 10-15 onChange events per second, overwhelming the portal with Ajax requests and causing 504 gateway timeouts.
  4. Cache GlideAjax responses in global JavaScript objects for the session duration. Repeated calls to the same Script Include with identical parameters should return cached results to prevent redundant server round trips that add 200-500ms per call.
  5. Avoid g_form.getReference() calls in loops or rapid onChange handlers. Each call generates a REST API request to retrieve full record data, and more than 10 concurrent reference calls cause portal session exhaustion.
  6. Use g_form.hideFieldMsg() before showFieldMsg() to prevent message DOM element accumulation. Displaying more than 20 field messages without clearing creates layout thrashing that slows form interactions by 2-3 seconds.
  7. Minimize g_form.getDisplayValue() usage on choice fields with over 100 options. The method performs linear search through all choice values, adding 100-200ms delay per call on large choice lists.
  8. Batch multiple setVisible() and setReadonly() calls inside a single setTimeout to force DOM batching. Individual calls trigger immediate layout recalculation, while batched calls update the DOM once, reducing render time by 70%.

Side Effects & Platform Behavior

  • Variable updates trigger Before and After Business Rules on the sc_item_option_mtom table, potentially executing complex approval workflows or notification rules you didn't expect.
  • Each setValue() call writes to the user's browser session storage under SNOW.catalog_form_data, and session storage exceeding 5MB causes form submission failures.
  • Mandatory variable validation bypasses Client Scripts entirely - the platform validates required fields on the sc_cat_item_producer server-side regardless of your setMandatory(false) calls.
  • Reference field changes trigger ACL evaluation against the target table, potentially exposing or hiding records based on the current user's roles, visible in the syslog_transaction table.
  • Portal page analytics in pa_cube_fact record every Ajax call your Client Script makes, contributing to ServiceNow's transaction volume licensing calculations.
  • Using Client Scripts on catalog items with Variable Sets causes the script to execute multiple times per form load - once per Variable Set instance plus once for the main item.
  • Field visibility changes update the sys_ui_section cache, and excessive visibility toggling (>50 per minute) triggers platform cache invalidation across all user sessions.
  • JavaScript errors in onLoad scripts prevent subsequent onChange and onSubmit handlers from registering, breaking all catalog interactivity with no visible error message to users.
  • Client Script execution writes timing data to sys_script_client_transaction when instance debugging is enabled, and scripts exceeding 5 seconds get flagged for admin review.
  • Mobile portal rendering ignores showFieldMsg() calls entirely, making mobile users miss critical validation messages that display properly in desktop browsers.

Debugging When It Breaks

The most common failure symptoms include variables that don't update when users change related fields, reference fields that show "Loading..." indefinitely, or forms that submit with unexpected values. Users typically report "the form doesn't work" or "nothing happens when I change this field," while developers see JavaScript console errors like "Cannot read property 'getValue' of undefined" or "g_form is not defined."

Start debugging in your browser's Developer Tools Console (F12) to catch client-side JavaScript errors and examine g_form object state. Check ServiceNow's System Log > All for Ajax request failures and Script Include errors. The JavaScript Executor in System Definition > Script Debugger shows detailed execution traces with variable values at each step. Look specifically in the Network tab for failed Ajax calls returning 403 (permission denied) or 500 (server error) status codes.

Watch for error messages containing "ScriptableServiceRequest is not defined" (indicates wrong Script Include type), "User does not have permission to read table" (ACL issues), or "Maximum call stack size exceeded" (infinite onChange loops). Log entries in System Log show timestamps matching your test actions and contain the exact line numbers where server-side processing failed.

Quick diagnostic checklist:

  • Verify the catalog item is active and Client Script applies to the correct catalog item
  • Test variable names using console.log(g_form.getVariablesNames()) to confirm exact spelling
  • Check if Script Include referenced by GlideAjax is type "Client Callable" and extends AbstractAjaxProcessor
  • Confirm user has read permissions on referenced tables and fields
  • Add console.log() statements at the beginning of each function to verify execution order

Quick Reference

  • Use g_form.getValue('variable_name') not g_form.getValue('variables.variable_name') - catalog variables don't need the variables prefix
  • The isLoading parameter in onChange is true during form initialization and should trigger early returns
  • Multi-row Variable Sets create array variables accessible as variable_name_1, variable_name_2 etc., not as true arrays
  • Boolean catalog variables store string values 'true' and 'false', not JavaScript booleans
  • Reference variables with no value selected return empty string '', not null or undefined
  • Script Include functions called via GlideAjax must return strings - complex objects get serialized unpredictably
  • Date/Time variables return values in the user's time zone format, not GMT or system format
  • Catalog Client Scripts don't execute on mobile Service Portal unless explicitly enabled in the portal widget configuration
  • Using g_form.getElement() on catalog variables returns null because variables aren't standard form fields
  • Container variables (sections) can't have values set or retrieved - they're purely organizational elements