GlideRecord Performance in ServiceNow: The Complete Optimisation Guide

Most ServiceNow performance problems trace back to poorly written GlideRecord queries. Scripts that load too many records, count with loops instead of GlideAggregate, execute queries inside loops, or trigger unnecessary Business Rules can slow your entire instance. This guide covers every major GlideRecord anti-pattern, why each one causes problems, and exactly how to fix it.

Why GlideRecord performance matters

ServiceNow runs on a shared application server architecture. When your script queries a large table without limits, it loads record data into application server memory. If 100 users trigger that script simultaneously — or a Scheduled Job runs it every minute — the memory pressure accumulates quickly. The result is slow form loads, slow saves, slow list views, and eventually instance degradation that affects every user.

The good news: the fixes are straightforward once you know the patterns. Most performance problems in custom scripts come from five anti-patterns that are easy to identify and easy to fix.

Anti-pattern 1 — Querying without a limit on large tables

This is the most common performance issue in ServiceNow. A GlideRecord query without a limit on a large table (incident, task, cmdb_ci, sys_audit, etc.) loads every matching record into memory.

// Bad — loads ALL active incidents into memory
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true');
gr.query();
while (gr.next()) {
    // process each record
    doSomethingWith(gr);
}
gs.log('Processed: ' + count);

On a production instance with 100,000 active incidents, this loads 100,000 records into memory. If this runs in a Business Rule that fires on every incident save, it runs thousands of times per day.

// Good — always use setLimit() when you do not need all records
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^state=1^priority=1');
gr.setLimit(100); // Never load more than you need
gr.query();
while (gr.next()) {
    doSomethingWith(gr);
}

The rule: if you know the maximum number of records you need, set a limit. If you need all records (for a data migration, for example), process in batches using chooseWindow().

// Batch processing with chooseWindow() — for large data operations
var batchSize = 500;
var startRow = 0;
var hasMore = true;

while (hasMore) {
    var gr = new GlideRecord('incident');
    gr.addEncodedQuery('active=true');
    gr.orderBy('sys_id'); // Must order to ensure consistent pagination
    gr.chooseWindow(startRow, startRow + batchSize);
    gr.query();

    var count = 0;
    while (gr.next()) {
        processRecord(gr);
        count++;
    }

    if (count < batchSize) {
        hasMore = false; // Last batch
    } else {
        startRow += batchSize;
        gs.log('Processed batch starting at row: ' + startRow, 'BatchJob');
    }
}

Anti-pattern 2 — Counting records with a GlideRecord loop

This is possibly the most impactful anti-pattern because it combines two problems: loading all records into memory just to count them, when the database can count them without loading anything.

// Bad — loads every matching record just to count them
var count = 0;
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^priority=1');
gr.query();
while (gr.next()) {
    count++;
}
gs.log('P1 count: ' + count);
// Good — database counts, returns one integer
var ga = new GlideAggregate('incident');
ga.addEncodedQuery('active=true^priority=1');
ga.addAggregate('COUNT');
ga.query();
if (ga.next()) {
    gs.log('P1 count: ' + ga.getAggregate('COUNT'));
}

The performance difference scales with the number of records. On a table with 10 matching records, the difference is negligible. On a table with 10,000 matching records, GlideAggregate is orders of magnitude faster. For the complete GlideAggregate reference, see the GlideAggregate complete guide.

Anti-pattern 3 — The N+1 query problem (query inside a loop)

Executing a GlideRecord query inside a while loop that is already iterating over a GlideRecord causes one additional database query per record in the outer loop. If the outer loop has 500 records, you make 501 database queries instead of 1.

// Bad — N+1 queries
while (gr.next()) {
    var userGr = new GlideRecord('sys_user');
    userGr.get(gr.getValue('assigned_to')); // One DB query per incident
    gs.log('Assigned to: ' + userGr.getValue('name'));
}
// Good option 1 — use dot-walking to get display values without extra query
while (gr.next()) {
    // Dot-walking resolves in the original query — no extra DB hit
    gs.log('Assigned to: ' + gr.assigned_to.getDisplayValue());
    gs.log('Group: ' + gr.assignment_group.getDisplayValue());
}
// Good option 2 — pre-fetch all needed data into a lookup map
// Collect all assignment_group sys_ids first
var groupIds = [];
while (gr.next()) {
    groupIds.push(gr.getValue('assignment_group'));
}

// One query to get all group names
var groupMap = {};
var groupGr = new GlideRecord('sys_user_group');
groupGr.addQuery('sys_id', 'IN', groupIds.join(','));
groupGr.query();
while (groupGr.next()) {
    groupMap[groupGr.sys_id.toString()] = groupGr.getValue('name');
}

// Now process the original records using the map — no extra queries
gr.initialize(); gr.query(); // Re-query the original records
while (gr.next()) {
    var groupName = groupMap[gr.getValue('assignment_group')] || 'Unassigned';
    gs.log(gr.number + ' | ' + groupName);
}

Dot-walking is usually the simplest fix when you only need a display value. The lookup map approach is better when you need multiple fields from the related record.

Anti-pattern 4 — Loading all fields when you only need a few

By default, GlideRecord loads every column in the table into the result set. For wide tables (cmdb_ci has 100+ columns; incident has 80+), this is significant unnecessary data transfer.

// Bad — loads all 80+ incident columns, uses only 3
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^priority=1');
gr.query();
while (gr.next()) {
    var number = gr.getValue('number');
    var desc = gr.getValue('short_description');
    var state = gr.getValue('state');
    // Only using 3 out of 80+ fields loaded
}
// Good — specify which fields you need
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^priority=1');
gr.addSelectField('number');
gr.addSelectField('short_description');
gr.addSelectField('state');
gr.addSelectField('assigned_to');
gr.query();
while (gr.next()) {
    gs.log(gr.number + ': ' + gr.short_description);
}

// Note: addSelectField (not addFieldToSelect — verify against your version)
// On some versions, use:
gr.fields = 'number,short_description,state,assigned_to';
gr.query();

This optimisation is most impactful on CMDB tables (which are very wide) and in loops that process large numbers of records.

Anti-pattern 5 — Calling update() inside a Before Business Rule

In a Before Business Rule, the platform saves the record automatically after all Before rules complete. Calling current.update() inside a Before rule causes an extra, unnecessary database write — and can trigger additional Business Rules, potentially causing infinite loops.

// Bad — redundant update() in a Before rule
(function executeRule(current, previous) {
    current.setValue('priority', calculatePriority(current));
    current.update(); // Wrong — platform will save this automatically
})(current, previous);

// Good — just set the value; the platform saves it
(function executeRule(current, previous) {
    current.setValue('priority', calculatePriority(current));
    // No update() needed in a Before rule
})(current, previous);

In an After Business Rule, you do need to call current.update() to persist changes, because the platform has already saved the record by the time After rules run. For the complete picture on when each rule type fires, see the guide to Business Rule types.

setWorkflow(false) and autoSysFields(false)

When running bulk updates in Scheduled Jobs or migration scripts, two flags dramatically reduce the side effects and performance cost of updates:

var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^category=hardware^state=3');
gr.query();
while (gr.next()) {
    gr.setValue('category', 'software');
    gr.setWorkflow(false);    // Do not fire Business Rules on this save
    gr.autoSysFields(false);  // Do not update sys_updated_on, sys_updated_by
    gr.update();
}

Use both flags when:

  • Running a data migration or bulk update where you do not want to trigger notification workflows
  • Making technical corrections where the sys_updated fields should not change
  • Performance is critical and the Business Rules on this table do not need to fire for this update

Use them carefully: if the Business Rules on this table do important work (sending notifications, updating related records), skipping them may leave data in an inconsistent state.

Using gr.get() vs gr.addQuery() for single-record lookups

When you need exactly one record by sys_id or by a unique key, gr.get() is cleaner and slightly more efficient than addQuery() + query() + next():

// Verbose — works but verbose
var gr = new GlideRecord('incident');
gr.addQuery('sys_id', sysId);
gr.query();
if (gr.next()) {
    // use gr
}

// Cleaner — gr.get() returns true if found, false if not
var gr = new GlideRecord('incident');
if (gr.get(sysId)) {
    // use gr
}

// Get by a non-sys_id unique field
var gr = new GlideRecord('incident');
if (gr.get('number', 'INC0001234')) {
    // use gr — second argument is the field value, first is the field name
}

Ordering results efficiently

orderBy() tells the database to sort results. Ordering on indexed fields (sys_created_on, number, state, priority) is fast. Ordering on non-indexed, text, or calculated fields can be slow on large tables.

// Fast — ordering on indexed fields
gr.orderBy('number');
gr.orderBy('sys_created_on');
gr.orderBy('priority');
gr.orderByDesc('sys_created_on'); // Descending

// Potentially slow on large tables — ordering on non-indexed fields
gr.orderBy('short_description'); // Text field, not indexed by default

getRowCount() — use with care

gr.getRowCount() returns the total number of rows that would match the query — including rows beyond the setLimit(). This requires a separate database count query. For simply checking whether any records exist, it is more efficient to check if gr.next() returns true:

// Expensive if you only need to know whether any records exist
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^caller_id=' + userId);
gr.query();
var count = gr.getRowCount(); // Extra DB query
if (count > 0) { ... }

// Efficient — just check if at least one record exists
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^caller_id=' + userId);
gr.setLimit(1);
gr.query();
if (gr.next()) {
    // At least one matching record exists
}

The performance decision tree

Before writing a GlideRecord query, ask these questions:

  • Do I need a count? → Use GlideAggregate, not GlideRecord
  • Do I need a sum, average, min, or max? → Use GlideAggregate
  • Do I need exactly one record by sys_id? → Use gr.get(sysId)
  • Do I need all records on a large table? → Use chooseWindow() for batch processing
  • Do I need only some fields? → Use addSelectField() / gr.fields
  • Am I in a loop that already iterates over records? → Use dot-walking or a pre-fetch map instead of inner queries
  • Am I in a Before Business Rule? → Never call current.update()
  • Am I doing a bulk update? → Use setWorkflow(false) and autoSysFields(false)

Diagnosing slow scripts

If a script is performing slowly in production, the process for diagnosing it:

  1. Add timestamps at the start and end of key operations: var start = new GlideDateTime(); ... gs.log('Duration: ' + (new GlideDateTime().getNumericValue() - start.getNumericValue()) + 'ms');
  2. Test the query in Scripts - Background with production-representative data to get accurate timings
  3. Check if the table has an index on the fields you are querying — work with your platform admin to add indexes for critical queries on large tables
  4. Use the Instance Scan to identify slow query patterns — ServiceNow's built-in scanner flags many of the anti-patterns in this guide

For diagnosing broader instance performance issues, see the guide to troubleshooting a slow ServiceNow instance.

Related guides

50 developer patterns in one guide

The NowSpectrum Pro Tips and Tricks guide covers 50 battle-tested patterns — GlideRecord performance, GlideAggregate, Script Includes, Flow Designer, and debugging techniques.

Get the Pro Tips Guide →
← Back to all posts