The Problem

You have a Business Rule that fires on a record update. It reads a reference field, calls getRefRecord() on it, and then uses the returned record to do something — update a field, trigger a notification, write to a related table. In testing it works perfectly. In production, intermittently, it does the wrong thing. The reference record it acts on is the old one, not the one that was just committed.

No errors. No exceptions. No skip log entries. The business rule appears to have run correctly — it just operated on stale data.

This is the async context trap.

This is particularly dangerous because it's silent. The Business Rule doesn't fail — it succeeds, with wrong data. In audit-sensitive environments (legal, financial, healthcare), this can produce incorrect records that are difficult to trace back to the root cause.

What Async Context Actually Means

When ServiceNow executes a Business Rule synchronously — the default — it runs within the same database transaction as the record save. The current object reflects the record being committed, and any reference fields on current are resolved against the current transaction state.

When a Business Rule is configured to run asynchronously, it's handed off to a separate worker thread after the transaction has committed. By the time the async worker picks it up, a new database connection is opened. The current object is reconstructed from a snapshot that was captured at queue time — not at execution time.

For most fields on current, this doesn't matter. The field values are stored directly in the snapshot. But reference fields are different.

Conditions That Push You Into Async

A Business Rule runs asynchronously when any of the following are true:

  • The Business Rule's When field is set to async
  • The Business Rule's When field is set to after and it's been automatically promoted to async due to execution time thresholds (this can happen silently on upgraded instances)
  • The Business Rule is triggered from a scheduled job or import set processor
  • The Business Rule is invoked via a REST import or Transform Map with async processing enabled
The silent promotion case is the nastiest. After a major upgrade, ServiceNow may reclassify certain Business Rules as async based on new execution time thresholds. A rule that was reliably sync pre-upgrade can become async post-upgrade with no notification. This is how the Washington-to-Xanadu upgrade broke my Business Rules without any error in the log.

Why getRefRecord() Lies

When you call current.some_reference_field.getRefRecord(), you're asking GlideRecord to follow the reference and return the referenced record. In a sync context, this works as expected — the system queries the database for the current committed state of that record.

In an async context, the problem is the snapshot. The current object was serialised at queue time and deserialised in the worker thread. The reference field contains the sys_id that was captured at that moment. When getRefRecord() executes in the worker thread, it queries the database — but the GlideElement object it's operating on may carry a cached display value from the snapshot that doesn't match the current database state.

In practice, what you observe is one of two things:

  1. The reference returns the pre-update version of the referenced record. The sys_id is correct, but cached field values from the snapshot are used in preference to fresh database values.
  2. The reference returns null or an empty record. This happens when the snapshot's display value is stale enough that the element's internal state is inconsistent.
Do not trust getRefRecord() in any Business Rule that may run asynchronously. Even if your rule is currently configured as sync, a future upgrade or configuration change could move it to async and silently break this behaviour.

Detecting It

The tricky part is that this failure is intermittent. In a low-volume environment, the async worker often picks up the job so quickly that the database state it reads is still current. The bug only manifests under load, when there's a lag between queue time and execution time.

To confirm you're hitting this problem, add a temporary diagnostic log to your Business Rule:

JavaScript (ServiceNow)
// Diagnostic — add temporarily to confirm async context issue
var refField = current.your_reference_field;
var sysId = refField.toString();
var displayVal = refField.getDisplayValue();

// Fetch the record directly
var check = new GlideRecord('target_table');
check.get(sysId);
var liveVal = check.some_field.toString();

// Via getRefRecord()
var ref = refField.getRefRecord();
var refVal = ref.some_field.toString();

gs.log('ASYNC DIAGNOSTIC: sysId=' + sysId
  + ' | live=' + liveVal
  + ' | getRefRecord=' + refVal
  + ' | match=' + (liveVal === refVal));

If you see match=false in your logs — especially under load — you've confirmed the issue. The getRefRecord() value and the live database value are diverging.

The Fix

The fix is straightforward once you understand the cause: never trust the GlideElement's cached state in async context. Instead, extract the sys_id from the reference field as a string — which is reliably stored in the snapshot — and use it to perform an explicit, fresh GlideRecord query in the worker thread.

JavaScript (ServiceNow) — Wrong approach
// ✕ WRONG — do not use in async context
// getRefRecord() may return stale or empty data
var refRecord = current.your_reference_field.getRefRecord();
var value = refRecord.some_field.toString();
JavaScript (ServiceNow) — Correct approach
// ✓ CORRECT — explicit re-query using sys_id
// The sys_id stored in the reference field is reliable;
// the GlideElement's cached display value is not.

var refSysId = current.your_reference_field.toString();

if (refSysId) {
  var refRecord = new GlideRecord('target_table');
  if (refRecord.get(refSysId)) {
    // Safe — this is a fresh database read
    var value = refRecord.some_field.toString();
    // ... do your work here
  }
}

The key insight: .toString() on a reference GlideElement reliably returns the sys_id from the snapshot. The sys_id itself is not stale — it's just the cached display values and field data attached to the element that can't be trusted. By using the sys_id to drive a fresh GlideRecord.get(), you bypass the cache entirely and read directly from the database.

The Broader Pattern

This same principle applies to any data you're reading from current in an async Business Rule that you then use to query or update related records. The rule is simple:

In async context, treat current as a set of sys_ids and primitive values only. Do not rely on GlideElement methods that resolve relationships or navigate references.

Specifically, avoid the following in async Business Rules:

  • getRefRecord() on any reference field
  • Dot-walking through reference fields (e.g. current.ref_field.related_field.toString()) — this internally calls reference resolution which has the same staleness problem
  • getDisplayValue() on reference fields — the display value is cached at snapshot time

The safe alternatives are:

  • current.field.toString() for primitive values and sys_ids
  • Fresh GlideRecord.get(sysId) calls for any referenced data you need
  • current.getValue('field_name') for string values where you want to be explicit

A note on dot-walking specifically

Dot-walking deserves special mention because it looks harmless and works correctly in sync context. current.assignment_group.manager.email.toString() is perfectly fine in a sync Business Rule. In async, this chain of reference resolutions becomes unreliable at each step. The safest approach is to break it into explicit queries:

JavaScript (ServiceNow) — Safe dot-walk replacement
// Instead of: current.assignment_group.manager.email.toString()
// In async — do this:

var groupSysId = current.assignment_group.toString();
if (!groupSysId) return;

var group = new GlideRecord('sys_user_group');
if (!group.get(groupSysId)) return;

var managerSysId = group.manager.toString();
if (!managerSysId) return;

var manager = new GlideRecord('sys_user');
if (!manager.get(managerSysId)) return;

var email = manager.email.toString();

Verbose? Yes. But it's explicit, debuggable, and correct regardless of context.

Summary

The GlideRecord async context trap is one of those bugs that's obvious in hindsight but genuinely difficult to diagnose in production — because it's silent, intermittent, and leaves no trace in the logs. The three things to remember:

  1. Know when you're async. Check your Business Rule's When field. Be aware that upgrades can promote rules to async silently.
  2. Never call getRefRecord() in async context. Extract the sys_id with .toString() and do a fresh GlideRecord.get() instead.
  3. Don't dot-walk in async context. Break reference chains into explicit queries. It's more code, but it's always correct.
Defensive coding approach: Write all Business Rules as if they might run async, even if they're currently configured as sync. Future upgrades, admin changes, or execution time thresholds can change the context without warning. The explicit re-query pattern costs you a few extra lines and is safe in both contexts.