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
- User navigates to catalog item → ServiceNow queries
sc_cat_item_client_scripttable for applicable scripts based on conditions and user context - Platform serializes catalog variable definitions and default values into client-side JavaScript objects
- Form renders with catalog variables mapped to virtual field layer →
onLoadscripts execute with fullg_formAPI access - Event handlers attach to catalog variables →
onChangescripts fire as users interact with variables - GlideAjax calls preserve catalog context → server-side Script Includes can access current variable values for complex processing
- Form submission triggers
onSubmitscripts for final validation → variable values serialize to request record → server-side processing begins
Enjoying this? Get one deep-dive per week.
Join 1,000+ ServiceNow pros — scripts, GlideRecord patterns, Flow Designer techniques, and career moves. Free.
The Core Pattern
// 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);
});
});
}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.
// 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();
}
});
}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.
// 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);
}
});
}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.
// 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');
}
});
}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.
// 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.
// 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
- Never call
GlideAjaxsynchronously (ga.getXMLWait()) in onChange handlers. Synchronous calls over 3 seconds freeze the browser tab and generate "Unresponsive script" warnings in Chrome. - 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. - Debounce rapid onChange events using
setTimeoutandclearTimeout. Users typing in reference fields trigger 10-15 onChange events per second, overwhelming the portal with Ajax requests and causing 504 gateway timeouts. - 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.
- 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. - Use
g_form.hideFieldMsg()beforeshowFieldMsg()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. - 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. - Batch multiple
setVisible()andsetReadonly()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_mtomtable, potentially executing complex approval workflows or notification rules you didn't expect. - Each
setValue()call writes to the user's browser session storage underSNOW.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_producerserver-side regardless of yoursetMandatory(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_transactiontable. - Portal page analytics in
pa_cube_factrecord 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_sectioncache, 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_transactionwhen 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')notg_form.getValue('variables.variable_name')- catalog variables don't need the variables prefix - The
isLoadingparameter in onChange istrueduring form initialization and should trigger early returns - Multi-row Variable Sets create array variables accessible as
variable_name_1, variable_name_2etc., 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
'', notnullorundefined - 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 returnsnullbecause variables aren't standard form fields - Container variables (sections) can't have values set or retrieved - they're purely organizational elements