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.
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
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:
- 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.
- 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.
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:
// 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.
// ✕ 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();
// ✓ 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
currentas 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:
// 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:
- Know when you're async. Check your Business Rule's When field. Be aware that upgrades can promote rules to async silently.
- Never call
getRefRecord()in async context. Extract the sys_id with.toString()and do a freshGlideRecord.get()instead. - Don't dot-walk in async context. Break reference chains into explicit queries. It's more code, but it's always correct.