How I Loaded 100K+ Dynamics 365 Contacts Without Crashing the Browser

How I Loaded 100K+ Dynamics 365 Contacts Without Crashing the Browser
Dynamics 365 · Web Resource · JavaScript

How I Loaded 100,000+ Contacts in Dynamics 365 Without Crashing the Browser

A real-world deep dive into OData pagination, in-memory filtering, and building a CRM-native owner dashboard.

April 2026 8 min read Dynamics 365 Web Resource
Situation

100K+ Contact records in Dynamics 365. Client needed to view them grouped by Owner — active & inactive — which CRM views don't natively support.

Task

Build a custom embedded web resource letting users select an owner and instantly see all their contacts with live Active / Inactive counts.

Action

Fetch distinct owners first via OData. On owner selection, paginate contact records (5,000/page) filtered by that owner — no full-dataset load needed.

Result

1,576 contacts for one owner rendered cleanly. No lag, no API failures, fully embedded inside Dynamics 365 as a native dashboard.

The Problem: Dynamics 365 Has No Native "Group by Owner" View

The client managed a database of over 100,000 contact records across dozens of owners — account managers, team leads, admins. Their ask was straightforward: give us a way to click on an owner's name and immediately see every contact assigned to them, along with whether those contacts are active or inactive.

Sounds simple. But Dynamics 365's native views and dashboards don't offer this kind of owner-scoped contact summary out of the box. Charts can aggregate, but they can't let you drill into a per-owner contact list with live counts in a single, clean interface.

The constraint The Dynamics 365 Web API enforces a maximum page size of 5,000 records per request. With 100K+ contacts, a naΓ―ve single-call approach would fail immediately — either a timeout, a memory explosion, or a silent API error.

We needed something smarter. The solution had to feel native to CRM, work within the platform's API constraints, and not make users wait unnecessarily when switching between owners.

What We Built: A CRM-Embedded Owner Dashboard

The final web resource embeds directly inside Dynamics 365 as a dashboard component. Here's how it looks in action:

Contact Dashboard — Initial Load (Fetching Owners)
Dashboard initial state — loading owner list from Dynamics 365
Contact Dashboard — Owner Dropdown Populated
Owner dropdown populated — select any owner to load their contacts
Contact Dashboard — Laura Sosa-Cuza selected, contacts loaded
Laura Sosa-Cuza selected — 1,576 contacts displayed with active/inactive counts
Live Result — Laura Sosa-Cuza
The app first fetches distinct owners via the Web API and populates the dropdown. Once you pick an owner, it makes a second targeted API call — paginating only that owner's contact records using $filter=_ownerid_value eq {guid}. No full-dataset load. No unnecessary data transfer. Just the records you actually need.
1,576
Total contacts
1,549
Active
27
Inactive
100K+
Total records

The Architecture: Fetch Owners First, Then Filter by Selection

The core insight was to separate the owner discovery phase from the contact loading phase. Rather than loading all 100K contacts upfront (expensive and slow), we first retrieve just the distinct list of owners — a lightweight call. Only when the user picks someone do we fetch that person's contacts specifically.

This keeps the initial load fast, memory usage low, and every selection targeted:

1

Page load — fetch distinct owners

On load, query the Web API for the unique set of contact owners — just their IDs and display names. This is a small, fast call. Use the result to populate the owner dropdown immediately.

2

Owner selection — targeted API call

When a user selects an owner, make a fresh API call filtered by that owner's GUID: $filter=_ownerid_value eq {guid}. Only that owner's records are fetched — not the entire 100K dataset.

3

Paginated contact fetch

Even a single owner may have thousands of contacts, so we still paginate using @odata.nextLink — 5,000 records per page — until all of that owner's contacts are loaded into a local array.

4

Render with live counts

As records arrive, tally statecode === 0 (Active) vs inactive in the same loop. Render the grid and update the Total / Active / Inactive counters in one O(n) pass.

The Code — Step by Step

1. Fetching distinct owners on load

On page load, we query contacts with $apply=groupby((_ownerid_value,...)) — or simply iterate a small owners fetch — to populate the dropdown without pulling full contact data.

fetchOwners()
JavaScript
async function fetchOwners() {
  const clientUrl = getClientUrl();
  let nextLink = null;

  do {
    const url = nextLink || `${clientUrl}/api/data/v9.2/contacts
      ?$select=_ownerid_value
      &$apply=groupby((_ownerid_value,
        _ownerid_value@OData.Community.Display.V1.FormattedValue))`;

    const res = await fetch(url, {
      headers: {
        "Accept": "application/json",
        "OData-Version": "4.0",
        "OData-MaxVersion": "4.0",
        "Prefer": "odata.maxpagesize=5000, odata.include-annotations=*"
      }
    });

    const data = await res.json();
    if (!data?.value) break;

    data.value.forEach(r => {
      const id   = r["_ownerid_value"];
      const name = r["_ownerid_value@OData.Community.Display.V1.FormattedValue"];
      if (id && name && !nameMap.has(id)) {
        nameMap.set(id, name);
        nameToIdMap.set(name.toLowerCase(), id);
      }
    });

    nextLink = data["@odata.nextLink"];
  } while (nextLink);
}
Why odata.include-annotations=*? The formatted owner name (e.g. "Laura Sosa-Cuza") is not stored directly on the contact record — it comes back as an OData annotation: _ownerid_value@OData.Community.Display.V1.FormattedValue. Without the include-annotations preference header, you'd only get the raw GUID and would need a separate lookup call per owner.

2. Fetching contacts for the selected owner

When the user picks an owner from the dropdown, we fire a targeted API call with $filter scoped to that owner's GUID. We then paginate through their contacts using @odata.nextLink until all records are retrieved.

fetchContactsByOwner(ownerId)
JavaScript
async function fetchContactsByOwner(ownerId) {
  const clientUrl = getClientUrl();
  let nextLink = null;
  let contacts = [];

  do {
    // Filter server-side — only fetch THIS owner's records
    const url = nextLink ||
      `${clientUrl}/api/data/v9.2/contacts
        ?$select=contactid,fullname,statecode,_ownerid_value
        &$filter=_ownerid_value eq ${ownerId}`;

    const res = await fetch(url, {
      headers: {
        "Accept": "application/json",
        "OData-Version": "4.0",
        "OData-MaxVersion": "4.0",
        "Prefer": "odata.maxpagesize=5000, odata.include-annotations=*"
      }
    });

    const data = await res.json();
    if (!data?.value) break;

    contacts = contacts.concat(data.value);
    nextLink = data["@odata.nextLink"];

  } while (nextLink);

  return contacts;
}

// Owner dropdown change handler
document.getElementById("ownerInput")
  .addEventListener("change", async function() {
    const name = this.value.toLowerCase();
    if (!name) { clearBody(); return; }

    const id = nameToIdMap.get(name);
    if (!id) return;

    showBodyLoading();
    const contacts = await fetchContactsByOwner(id);
    render(contacts);
  });

3. Rendering the grid with live counts

The render function iterates over the fetched array once, builds the HTML string, and tallies active vs inactive in the same pass — a single O(n) loop.

render(data)
JavaScript
function render(data) {
  let active = 0, inactive = 0, html = "";

  data.forEach(c => {
    const status = c.statecode === 0 ? "Active" : "Inactive";
    if (status === "Active") active++;
    else inactive++;

    const owner = c["_ownerid_value@OData.Community.Display.V1.FormattedValue"]
                  || "Unassigned";

    html += `
      <div class="cell">${c.fullname || ""}</div>
      <div class="cell">${owner}</div>
      <div class="cell">${status}</div>
    `;
  });

  document.getElementById("grid-body").innerHTML = html;
  document.getElementById("total").innerText   = data.length;
  document.getElementById("active").innerText  = active;
  document.getElementById("inactive").innerText = inactive;
}

Complete Source Code

Below is the full HTML file as deployed as a Dynamics 365 Web Resource. Drop it in, register the web resource, and add it to your dashboard.

ContactOwnerDashboard.html — full source
HTML + JS
<!-- ContactOwnerDashboard.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Contacts by Owner</title>
  <!-- Required for CRM context (getClientUrl) -->
  <script src="../WebResources/ClientGlobalContext.js.aspx"></script>
  <style>
    body { margin: 0; font-family: 'Segoe UI'; background: #f5f5f5; padding: 10px; }
    .container { width: 95%; margin: auto; background: #fff; border-radius: 10px; padding: 20px; }
    .filters { display: flex; gap: 15px; align-items: center; }
    .counts { margin-left: auto; display: flex; gap: 20px; font-weight: 600; }
    .grid { display: grid; grid-template-columns: 2fr 2fr 1fr; margin-top: 20px; }
    .cell { padding: 10px; border-bottom: 1px solid #eee; }
    .header { font-weight: bold; background: #fafafa; }
  </style>
</head>
<body>
  <div class="container">
    <div class="filters">
      <select id="ownerInput" style="padding:10px;border-radius:8px;border:1px solid #ccc;width:40%">
        <option value="">-- Select Owner --</option>
      </select>
      <div class="counts">
        <div>Total: <span id="total">0</span></div>
        <div>Active: <span id="active">0</span></div>
        <div>Inactive: <span id="inactive">0</span></div>
      </div>
    </div>
    <div class="grid">
      <div class="cell header">Full Name</div>
      <div class="cell header">Owner</div>
      <div class="cell header">Status</div>
      <div id="grid-body" style="display:contents"></div>
    </div>
  </div>

  <script>
    let nameMap     = new Map();
    let nameToIdMap = new Map();

    function getClientUrl() {
      return window.parent.Xrm.Utility.getGlobalContext().getClientUrl();
    }

    async function fetchOwners() {
      const clientUrl = getClientUrl();
      let nextLink = null;
      do {
        const url = nextLink ||
          `${clientUrl}/api/data/v9.2/contacts?$select=_ownerid_value&$apply=groupby((_ownerid_value,_ownerid_value@OData.Community.Display.V1.FormattedValue))`;
        const res = await fetch(url, { headers: {
          "Accept": "application/json",
          "OData-Version": "4.0",
          "OData-MaxVersion": "4.0",
          "Prefer": "odata.maxpagesize=5000, odata.include-annotations=*"
        }});
        const data = await res.json();
        if (!data?.value) break;
        data.value.forEach(r => {
          const id   = r["_ownerid_value"];
          const name = r["_ownerid_value@OData.Community.Display.V1.FormattedValue"];
          if (id && name && !nameMap.has(id)) {
            nameMap.set(id, name);
            nameToIdMap.set(name.toLowerCase(), id);
          }
        });
        nextLink = data["@odata.nextLink"];
      } while (nextLink);
    }

    async function fetchContactsByOwner(ownerId) {
      const clientUrl = getClientUrl();
      let nextLink = null;
      let contacts = [];
      do {
        const url = nextLink ||
          `${clientUrl}/api/data/v9.2/contacts?$select=contactid,fullname,statecode,_ownerid_value&$filter=_ownerid_value eq ${ownerId}`;
        const res = await fetch(url, { headers: {
          "Accept": "application/json",
          "OData-Version": "4.0",
          "OData-MaxVersion": "4.0",
          "Prefer": "odata.maxpagesize=5000, odata.include-annotations=*"
        }});
        const data = await res.json();
        if (!data?.value) break;
        contacts = contacts.concat(data.value);
        nextLink = data["@odata.nextLink"];
      } while (nextLink);
      return contacts;
    }

    function populateDropdown() {
      const select = document.getElementById("ownerInput");
      nameMap.forEach((name) => {
        const opt = document.createElement("option");
        opt.value = name; opt.textContent = name;
        select.appendChild(opt);
      });
    }

    function render(data) {
      let active = 0, inactive = 0, html = "";
      data.forEach(c => {
        const status = c.statecode === 0 ? "Active" : "Inactive";
        if (status === "Active") active++; else inactive++;
        const owner = c["_ownerid_value@OData.Community.Display.V1.FormattedValue"] || "Unassigned";
        html += `<div class="cell">${c.fullname||""}</div>
                 <div class="cell">${owner}</div>
                 <div class="cell">${status}</div>`;
      });
      document.getElementById("grid-body").innerHTML = html;
      document.getElementById("total").innerText   = data.length;
      document.getElementById("active").innerText  = active;
      document.getElementById("inactive").innerText = inactive;
    }

    function showBodyLoading() {
      document.getElementById("grid-body").innerHTML =
        `<div style="grid-column:1/-1;padding:20px;text-align:center;color:#666;font-style:italic">Loading contacts...</div>`;
      ["total","active","inactive"].forEach(id => document.getElementById(id).innerText = "0");
    }

    function clearBody() {
      document.getElementById("grid-body").innerHTML = "";
      ["total","active","inactive"].forEach(id => document.getElementById(id).innerText = "0");
    }

    document.getElementById("ownerInput").addEventListener("change", async function() {
      const name = this.value.toLowerCase();
      if (!name) { clearBody(); return; }
      const id = nameToIdMap.get(name);
      if (!id) return;
      showBodyLoading();
      const contacts = await fetchContactsByOwner(id);
      render(contacts);
    });

    (async function() {
      showBodyLoading();
      await fetchOwners();
      populateDropdown();
      clearBody();
    })();
  </script>
</body>
</html>

Deploying as a Dynamics 365 Web Resource

1

Create the web resource

In Dynamics 365, go to Settings → Customizations → Customize the System → Web Resources → New. Set type to HTML, upload the file, and save.

2

Verify the ClientGlobalContext reference

The script tag <script src="../WebResources/ClientGlobalContext.js.aspx"> is essential — it injects the Xrm context object that gives access to getClientUrl(). Without it, all API calls fail.

3

Add to a dashboard

Create a new dashboard, add a Web Resource component, and point it to your newly created resource. Set an appropriate height (at least 600px for comfortable scrolling).

4

Check security roles

Users need read access to the contact entity and the systemuser entity to see both contacts and formatted owner names. Verify in their security role settings.

Performance Considerations

Memory footprint Because we only load one owner's contacts at a time (not the full 100K), memory usage stays very low — typically just a few MB per selection. If a single owner has an exceptionally large number of records, pagination ensures we never request more than 5,000 at once from the API.

The two-phase approach — owners first, then contacts on demand — means the initial load is nearly instant. The heavier work only happens when the user makes a selection, and even then it's scoped to a single owner's dataset rather than the entire 100K.

Fetch calls are sequential within each pagination loop (intentional await) rather than parallel — this avoids hammering the CRM API endpoint. In practice, even owners with 1,000+ contacts load in a few seconds on a typical corporate network.

Key Takeaways

  • The Dynamics 365 Web API caps page size at 5,000. Always design for pagination using @odata.nextLink.
  • Fetch distinct owners first with $apply=groupby — avoid loading the full dataset just to populate a dropdown.
  • Use $filter=_ownerid_value eq {guid} to scope contact fetches to a single owner — don't pull 100K records when you only need one person's data.
  • The odata.include-annotations=* preference header is necessary to receive formatted lookup values — without it you only get raw GUIDs.
  • display: contents on a grid child lets inner elements participate in the parent grid layout directly — an elegant pattern for dynamic row insertion without wrapper elements.
  • The ClientGlobalContext.js.aspx script reference is non-negotiable for any CRM-hosted web resource that needs to call the Web API.

When Client-Side JavaScript Hits Its Limits in Dynamics 365

When Client-Side JavaScript Hits Its Limits in Dynamics 365

When Client-Side JavaScript Hits Its Limits in Dynamics 365

A real-world story of throttling, silent failures, and why server-side won.

Production environment · 09:47 AM

The technician clicks Submit. The spinner appears. A few seconds pass — success. The Sales Order opens. But something feels wrong. Seventeen installed products on the job. Eleven line items on the order.

No error. No alert. No trace in the console. Six records had simply vanished into thin air.

This is the story of what happened, why it happened, and how we stopped it from happening again.

The goal

When a field technician finishes an Installation Job and clicks Submit from the Dynamics 365 ribbon, the system should automatically push every installed product into the linked Sales Order — no manual entry, no copy-paste.

Dynamics 365 Installation Jobs Sales Orders
Home
Details
Related
Manage
Actions
Activity
IJ-00142 · Fleet Installation — Sample Customer Draft
Sample Customer Ltd.
SO-20245
Workshop A
17 items

Click Submit above to see the interaction — wired the same way the real ribbon command works.

Submit clickRibbon button
Installed productsretrieveMultiple
Product installationsnested loop
Create SO linessalesorderdetail
Update SOtotals & costs

How it was built

The Submit button is registered via RibbonDiffXml and bound to updateSalesOrder, passing PrimaryControl as the execution context.

Ribbon button — XML registration

XML · RibbonDiffXml
<CommandDefinition Id="sample_InstallationJob.Submit.Command">
  <EnableRules>
    <EnableRule Id="sample_EnableRule.StatusDraft" />
  </EnableRules>
  <Actions>
    <JavaScriptFunction
      FunctionName="updateSalesOrder"
      Library="$webresource:sample_InstallationJob.js">
      <CrmParameter Value="PrimaryControl" />
    </JavaScriptFunction>
  </Actions>
</CommandDefinition>

Entry point

JavaScript · updateSalesOrder
async function updateSalesOrder(executionContext) {
  const status     = executionContext.getAttribute("sample_status").getValue();
  const salesorder = executionContext.getAttribute("sample_salesorder").getValue();

  // Only act on Draft records (141540000)
  if (status !== 141540000 || !salesorder) return;

  const confirmed = await Xrm.Navigation.openConfirmDialog(
    { text: "Submit this Installation Job?", title: "Confirm" },
    { height: 120, width: 260 }
  );
  if (!confirmed.confirmed) return;

  const totals = await addProductsToSalesOrder(executionContext, salesorder);

  await Xrm.WebApi.updateRecord("salesorder", salesorder[0].id, {
    sample_totallaboramount:  totals.labor,
    sample_totaldealeramount: totals.dealer
  });
  executionContext.data.entity.save();
}

The nested loop — where it gets dangerous

JavaScript · addProductsToSalesOrder (simplified)
for (const ip of installedProducts.entities) {

  // API call 1 — retrieve this product's installations
  const installations = await Xrm.WebApi
    .retrieveMultipleRecords("sample_productinstallation", filter);

  for (const inst of installations.entities) {

    // API call 2 — resolve default UOM
    const product = await Xrm.WebApi
      .retrieveRecord("product", inst._sample_productid_value, sel);

    // API call 3 — fires once per installation (60+ times on large jobs)
    await Xrm.WebApi.createRecord("salesorderdetail", line);
  }
}

With 20 installed products and 2–3 installations each, this fires 60–80 API calls in rapid succession — all from the browser, all under the signed-in user's quota.

The problem nobody warned us about

On small jobs (3–5 products) everything worked perfectly. On large fleet jobs — 20 vehicles, 3 installs each — some line items would silently disappear after Submit. No exception, no log, just missing records.

Why records vanish silently

Dataverse service protection limits throttle rapid API calls per user. Under load, some createRecord calls receive a 429 Too Many Requests response — but Xrm.WebApi does not always surface this as a thrown exception. The await resolves empty, the record is never written, and the loop continues silently.

Two fixes were attempted before accepting the root cause.

Fix attempt 1 — artificial delay

JavaScript · attempt 1
await Xrm.WebApi.createRecord("salesorderdetail", line);
// self-throttle between calls
await new Promise(r => setTimeout(r, 300));
Result

Helped slightly on mid-size jobs. Large jobs now took 30+ seconds, and records were still dropped on the biggest batches. Worse UX, same root cause.

Fix attempt 2 — parallel chunks

JavaScript · attempt 2
for (const chunk of chunkArray(lines, 5)) {
  await Promise.all(chunk.map(line =>
    Xrm.WebApi.createRecord("salesorderdetail", line)
  ));
}
Made it worse

Firing 5 simultaneous requests per chunk concentrated the burst. The throttle hit harder, not softer.

The root truth

Client-side JavaScript in Dynamics runs under the user's API quota, in the browser, with no retry logic and no transactional guarantee. When the tab closes mid-operation everything in flight is lost. No amount of client-side code fully solves this for bulk operations.

The fix — move it server-side

The JavaScript keeps what it does well: confirmation dialog, status update, form field refresh. The bulk record-creation loop moves to a server-side process that runs asynchronously, with retry logic, under a service account's quota, completely outside the browser.

Power Automate approach

A cloud flow triggers on the Installation Job status change (Draft → Submitted), loops through installed products in Dataverse, and creates salesorderdetail lines with built-in retry and concurrency controls — no infrastructure, no deployment pipeline required.

Other server-side options

Native

Dataverse plugin (C#)

Full transaction support. Use ExecuteMultiple to batch all creates in a single round-trip.

Native

Custom API + plugin

JS calls one action endpoint. The plugin handles all looping server-side. Cleanest architecture.

Advanced

Azure Functions

Full control over batching, retry, and observability. JS just fires an HTTP POST to your endpoint.

Enterprise

Azure Logic Apps

ARM-deployable, versioned flows. Ideal for orgs already using Azure DevOps pipelines.

API Level

ExecuteMultiple

Bundle all creates into one Dataverse batch request — reduces 60 round-trips to 1.

The rule of thumb

Use Xrm.WebApi for reading data to drive UX decisions and patching single records. The moment your code has a loop with createRecord calls inside it, treat the JavaScript as an orchestrator — collect the input, fire a server-side trigger, and let that process do the bulk work.

Xrm WebApi vs XMLHttpRequest in Dynamics 365

JavaScript in Dynamics 365 – Xrm.WebApi, XMLHttpRequest & Practical Code Examples
Dynamics 365 · JavaScript · Web Resource

JavaScript in Dynamics 365 – Xrm.WebApi, XMLHttpRequest & Practical Code Examples

A step-by-step guide for beginners and professionals on the modern way to write JavaScript in D365 — covering Xrm.WebApi, deprecated XMLHttpRequest, form utilities, and real-world code examples.

April 2026 10 min read D365 Certified Developer Beginner + Professional
Who Is This For

Beginners just starting with D365 JavaScript and experienced developers wanting to modernise their codebase.

What You'll Learn

Xrm.WebApi operations, why XMLHttpRequest is deprecated, formContext, navigation, and utility helpers.

Code Examples

3 complete, copy-ready JavaScript examples covering multiple records, all field types, and single-record fetch.

Key Outcome

Write clean, future-proof D365 JavaScript that won't break in modern browsers or upcoming platform updates.

Introduction

If you are working with Dynamics 365 or Power Apps Model-Driven Apps, JavaScript is one of the most important skills you need. It allows you to add custom logic, fetch data, update records, and control form behaviour — all without writing server-side code.

In this guide, we will cover everything step by step:

  • What is Xrm.WebApi and why it is the right choice today
  • ⚠️What is XMLHttpRequest and why Microsoft is deprecating it
  • πŸ”§Other important JavaScript utilities every D365 developer must know
  • πŸ“‹Real code examples — retrieve multiple records, read all field types, and fetch a single record by GUID

Whether you are just starting with D365 development or already have experience, this guide will help you write better, cleaner, and future-proof JavaScript.


What is JavaScript in Dynamics 365?

In Dynamics 365, JavaScript is written inside Web Resources. These are .js files that you upload to your solution and attach to form events — such as form load, field change, or record save.

JavaScript in D365 allows you to:

  • πŸ“₯Read and write field values on a form
  • πŸ”Fetch records from Dataverse using the Web API
  • πŸ“Create, update, or delete records programmatically
  • πŸ””Show messages, alerts, and confirmation dialogs
  • πŸ”—Navigate between records and open forms

Microsoft provides a built-in global object called Xrm which gives you access to everything — form context, WebApi, navigation, and utilities. This is your starting point for all JavaScript development in D365.


⚡ Xrm.WebApi – The Modern Standard

What is Xrm.WebApi?

Xrm.WebApi is the official Microsoft-recommended JavaScript API for interacting with Dataverse data. It replaced the old XMLHttpRequest approach and is now the standard for all D365 JavaScript development.

✅ Officially supported by Microsoft ✅ Promise-based and async ✅ Clean, readable syntax ✅ No extra setup needed

Supported Operations

OperationMethodWhen to Use
Get a single record by IDXrm.WebApi.retrieveRecordWhen you already have the record GUID
Get multiple recordsXrm.WebApi.retrieveMultipleRecordsWhen you need a list with filters
Create a new recordXrm.WebApi.createRecordInsert new data into Dataverse
Update an existing recordXrm.WebApi.updateRecordModify fields of an existing record
Delete a recordXrm.WebApi.deleteRecordRemove a record from Dataverse

⚠️ XMLHttpRequest – Now Going Deprecated

What is XMLHttpRequest?

XMLHttpRequest (XHR) was the old way of making HTTP calls in Dynamics 365 JavaScript. Before Xrm.WebApi existed, developers used XHR to call the Web API directly. Many older projects still use it — however, Microsoft has officially deprecated synchronous XMLHttpRequest in form scripts, and modern browsers are actively restricting its use.

Deprecation Notice Microsoft's official documentation now flags synchronous XMLHttpRequest as deprecated. Using it can cause form freezes, browser errors, and unexpected behaviour in modern environments. Do not use it in any new development.

What Did the Old Code Look Like?

XMLHttpRequest — OLD WAY
JavaScript
// ❌ OLD WAY – Do NOT use this in new development
var req = new XMLHttpRequest();
req.open(
    "GET",
    Xrm.Page.context.getClientUrl() +
    "/api/data/v9.2/accounts?$select=name,telephone1",
    false  // false = synchronous — this freezes the entire form
);
req.setRequestHeader("OData-MaxVersion", "4.0");
req.setRequestHeader("OData-Version",    "4.0");
req.setRequestHeader("Accept",           "application/json");
req.send();

var result = JSON.parse(req.responseText);
console.log(result.value[0].name);

Why Is It Deprecated?

ProblemExplanation
🚫 Synchronous executionThe form completely freezes while waiting for the API response
🚫 Poor user experienceUsers cannot interact with the form during the call
🚫 Browser restrictionsChrome, Edge, and Firefox are actively blocking synchronous XHR
🚫 Microsoft warningOfficial Microsoft docs now flag this as deprecated behaviour
✅ Use insteadXrm.WebApi — async, promise-based, and fully supported
Developer Note If you find XMLHttpRequest in any existing customisation, plan to refactor it. It may still work in some environments today, but it is not safe for long-term use and can break with any browser update.

πŸ”§ Other Important JavaScript Utilities in D365

Before jumping into code examples, here are the other commonly-used objects every D365 JavaScript developer uses on a daily basis.

1. formContext – Working with Form Fields

formContext is how you read and write values on a Dynamics 365 form. It is always obtained through the executionContext parameter passed to your function.

Important Always use executionContext.getFormContext() to get the form context. The old Xrm.Page approach is also deprecated and must not be used in any new development.
formContext — Read, Write & Control Fields
JavaScript
function onFormLoad(executionContext) {

    // Always get formContext from executionContext — never use Xrm.Page
    var formContext = executionContext.getFormContext();

    // ✅ Read a field value
    var accountName = formContext.getAttribute("name").getValue();
    console.log("Account Name: " + accountName);

    // ✅ Set a field value
    formContext.getAttribute("telephone1").setValue("9999999999");

    // ✅ Make a field required
    formContext.getAttribute("emailaddress1").setRequiredLevel("required");

    // ✅ Show or hide a field control
    formContext.getControl("telephone1").setVisible(false);

    // ✅ Disable a field (read-only)
    formContext.getControl("name").setDisabled(true);
}

2. Xrm.Navigation – Open Forms and Dialogs

Use Xrm.Navigation to open record forms, show alerts, or display confirmation dialogs to the user.

Xrm.Navigation — Forms & Dialogs
JavaScript
// ✅ Open an existing record
Xrm.Navigation.openForm({
    entityName: "contact",
    entityId:   "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
});

// ✅ Open a new record form (no ID needed)
Xrm.Navigation.openForm({
    entityName: "account"
});

// ✅ Show a confirmation dialog
Xrm.Navigation.openConfirmDialog(
    { text: "Are you sure you want to proceed?" },
    { height: 200, width: 400 }
).then(function(result) {
    if (result.confirmed) {
        console.log("User clicked OK.");
    } else {
        console.log("User cancelled.");
    }
});

// ✅ Show a simple alert dialog
Xrm.Navigation.openAlertDialog({
    text:               "Record saved successfully.",
    confirmButtonLabel: "OK"
});

3. Xrm.Utility – Helper Functions

Use Xrm.Utility for common helper operations like showing a loading spinner, getting the logged-in user's ID, or reading the organisation URL.

Xrm.Utility — Common Helpers
JavaScript
// ✅ Show a loading spinner while processing
Xrm.Utility.showProgressIndicator("Please wait, loading data...");

// ✅ Hide the loading spinner when done
Xrm.Utility.closeProgressIndicator();

// ✅ Get current logged-in user ID
var userId = Xrm.Utility.getGlobalContext().getUserId();
console.log("User ID: " + userId);

// ✅ Get current organisation base URL
var orgUrl = Xrm.Utility.getGlobalContext().getClientUrl();
console.log("Org URL: " + orgUrl);

// ✅ Get current user security roles
var userRoles = Xrm.Utility.getGlobalContext().getUserRoles();
console.log("User Roles: " + userRoles);

πŸ“‹ Step-by-Step Code Examples

1
Retrieve Multiple Records
🎯 Scenario: Fetch all active Account records and read their name, phone, and email.

This is the most common requirement in D365 development — fetching a list of records from Dataverse with specific fields and filter conditions. We use Xrm.WebApi.retrieveMultipleRecords with OData query options.

1

Build the OData query options

Use $select, $filter, $orderby, and $top to control exactly what data you get back.

2

Call retrieveMultipleRecords

Pass the entity logical name and your options string to the API method.

3

Check if records were returned

Always check result.entities.length before attempting to read any data.

4

Loop and read each field

Use forEach to iterate and read the value of each field by its logical name.

5

Handle errors

Always add an error function — never leave .then() without one. Silent failures are hard to debug.

getMultipleAccounts() — retrieveMultipleRecords
JavaScript
function getMultipleAccounts() {

    // Step 1 – Build the OData query options
    // $select  – only fetch the fields you need (never fetch all columns)
    // $filter  – statecode 0 = Active records only
    // $orderby – sort by name A to Z
    // $top     – limit to 50 records to avoid performance issues
    var options = "?$select=name,telephone1,emailaddress1,statecode" +
                  "&$filter=statecode eq 0" +
                  "&$orderby=name asc" +
                  "&$top=50";

    // Step 2 – Call retrieveMultipleRecords
    Xrm.WebApi.retrieveMultipleRecords("account", options).then(

        function success(result) {

            // Step 3 – Check if any records were returned
            if (result.entities.length === 0) {
                console.log("No active accounts found.");
                return;
            }

            console.log("Total Records Found: " + result.entities.length);
            console.log("===================================");

            // Step 4 – Loop through each record and read field values
            result.entities.forEach(function(record) {

                var name   = record["name"]          || "Not Available";
                var phone  = record["telephone1"]    || "Not Available";
                var email  = record["emailaddress1"] || "Not Available";
                var status = record["statecode"] === 0 ? "Active" : "Inactive";

                console.log("Account Name : " + name);
                console.log("Phone        : " + phone);
                console.log("Email        : " + email);
                console.log("Status       : " + status);
                console.log("-----------------------------------");
            });
        },

        function error(err) {
            // Step 5 – Always handle errors
            console.error("Error fetching accounts: " + err.message);
            Xrm.Navigation.openAlertDialog({ text: "Error: " + err.message });
        }
    );
}
Developer Tip Always use $select to fetch only the fields you need. Fetching all columns is one of the most common performance mistakes in D365 JavaScript — it slows form load and consumes unnecessary API capacity.
2
Retrieve First Record – Reading All Field Types
🎯 Scenario: Fetch the first active Account and correctly read every field type — text, number, date, boolean, option set, and lookup.

This example is essential for beginners. Every field type in Dataverse is returned differently in the API response. You need to know exactly how to read each one to avoid bugs in your code.

getFirstAccount() — All Field Types ($top=1)
JavaScript
function getFirstAccount() {

    // For lookup fields, use the _fieldname_value format in $select
    var options = "?$select=name,telephone1,emailaddress1," +
                  "revenue,numberofemployees,"     + // Number / Currency
                  "createdon,"                     + // Date field
                  "donotbulkemail,"                + // Boolean (Two Option)
                  "industrycode,"                  + // Option Set (Choice)
                  "_primarycontactid_value"         + // Lookup field
                  "&$filter=statecode eq 0" +
                  "&$top=1";                        // Only first record

    Xrm.WebApi.retrieveMultipleRecords("account", options).then(

        function success(result) {

            if (result.entities.length === 0) {
                console.log("No record found.");
                return;
            }

            var record = result.entities[0];

            // ✅ Text Field (Single Line of Text)
            var name  = record["name"];
            var phone = record["telephone1"];
            var email = record["emailaddress1"];
            console.log("Name  : " + name);
            console.log("Phone : " + phone);
            console.log("Email : " + email);

            // ✅ Number and Currency Fields
            var revenue   = record["revenue"];
            var employees = record["numberofemployees"];
            console.log("Revenue   : " + revenue);
            console.log("Employees : " + employees);

            // ✅ Date Field – Convert to readable local format
            var createdOn     = record["createdon"];
            var formattedDate = new Date(createdOn).toLocaleDateString("en-IN");
            console.log("Created On : " + formattedDate);

            // ✅ Boolean / Two Option Field
            var doNotEmail = record["donotbulkemail"];
            console.log("Do Not Email : " + (doNotEmail ? "Yes" : "No"));

            // ✅ Option Set / Choice Field
            // Raw value = integer stored in Dataverse
            // FormattedValue = the label the user sees on the form
            var industryCode = record["industrycode"];
            var industryName = record["industrycode@OData.Community.Display.V1.FormattedValue"];
            console.log("Industry (Value) : " + industryCode);
            console.log("Industry (Label) : " + industryName);

            // ✅ Lookup Field – Three pieces of information
            // 1. The GUID of the related record
            // 2. The display name shown to users
            // 3. The entity logical name of the related table
            var contactId   = record["_primarycontactid_value"];
            var contactName = record["_primarycontactid_value@OData.Community.Display.V1.FormattedValue"];
            var contactType = record["_primarycontactid_value@Microsoft.Dynamics.CRM.lookuplogicalname"];
            console.log("Contact ID     : " + contactId);
            console.log("Contact Name   : " + contactName);
            console.log("Contact Entity : " + contactType);
        },

        function error(err) {
            console.error("Error: " + err.message);
        }
    );
}

How to Read Lookup Fields – Explained Simply

Lookup fields return three separate pieces of information via OData annotations. Here is what each one gives you:

AnnotationWhat It ReturnsExample Value
_primarycontactid_valueThe GUID of the related recordxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
@OData.Community.Display.V1.FormattedValueThe display name shown to usersJohn Smith
@Microsoft.Dynamics.CRM.lookuplogicalnameThe logical name of the related entitycontact
Developer Tip For Option Set fields, always read both the raw integer value (industrycode) and the formatted label (@OData.Community.Display.V1.FormattedValue). Use the raw integer for comparisons in your logic and the label when displaying text to the user.
3
Retrieve a Single Record Directly by ID
🎯 Scenario: You have an Account ID from a lookup field on the form. Fetch that specific account's details directly.

When you already know the exact GUID of a record — for example from a lookup field on the current form — use retrieveRecord instead of retrieveMultipleRecords. It is faster, simpler, and more direct.

getSingleAccount() — retrieveRecord by GUID
JavaScript
function getSingleAccount(executionContext) {

    var formContext = executionContext.getFormContext();

    // Step 1 – Read the lookup field from the form
    var lookupValue = formContext.getAttribute("parentaccountid").getValue();

    // Step 2 – Check if a value is selected in the lookup
    if (lookupValue === null || lookupValue.length === 0) {
        console.log("No parent account selected on the form.");
        return;
    }

    // Step 3 – Lookup fields always return an array – use index [0]
    // Also remove curly braces from the GUID if present
    var accountId = lookupValue[0].id
                        .replace("{", "")
                        .replace("}", "");

    // Step 4 – Define the fields to fetch
    var options = "?$select=name,telephone1,emailaddress1,_primarycontactid_value";

    // Step 5 – Call retrieveRecord with entity name, GUID, and options
    Xrm.WebApi.retrieveRecord("account", accountId, options).then(

        function success(record) {

            var name        = record["name"];
            var phone       = record["telephone1"];
            var email       = record["emailaddress1"];
            var contactName = record["_primarycontactid_value@OData.Community.Display.V1.FormattedValue"];
            var contactId   = record["_primarycontactid_value"];

            console.log("Account Name : " + name);
            console.log("Phone        : " + phone);
            console.log("Email        : " + email);
            console.log("Contact Name : " + contactName);
            console.log("Contact ID   : " + contactId);
        },

        function error(err) {
            console.error("Error retrieving account: " + err.message);
        }
    );
}
Developer Tip Lookup field values from formContext are always returned as an array — even when only a single record is selected. Always use lookupValue[0] to access the selected record's ID, name, and entity type.

OData Query Options – Quick Reference

These are the query options you will use most often inside your Xrm.WebApi calls. Understanding them is essential for writing efficient D365 JavaScript.

OptionPurposeExample
$selectFetch only specific fields — always use this$select=name,telephone1
$filterFilter records by a condition$filter=statecode eq 0
$orderbySort results ascending or descending$orderby=name asc
$topLimit the number of records returned$top=50
$expandFetch fields from a related entity in the same call$expand=primarycontactid($select=fullname)

Active Records

$filter=statecode eq 0

Inactive Records

$filter=statecode eq 1

Filter by Text

$filter=name eq 'Contoso'

Filter by Lookup

$filter=_ownerid_value eq <GUID>


Key Takeaways

  • Always use Xrm.WebApi for all data operations — it is the official Microsoft standard for modern D365 development.
  • XMLHttpRequest is deprecated — avoid it completely in new development and plan to refactor it in older code.
  • Always use $select to limit the fields you fetch. Never retrieve all columns — it is the most common performance mistake in D365 JavaScript.
  • Use $filter, $orderby, and $top to keep your queries efficient and results controlled.
  • For lookup fields, use the _fieldname_value format in $select and read three OData annotations to get the ID, display name, and entity type.
  • For option set fields, always read both the raw integer value and the FormattedValue annotation to get the readable label.
  • Always use executionContext.getFormContext() — never use the deprecated Xrm.Page object in any new development.
  • Always add an error handler in every WebApi call. Never leave .then() without an error function — silent failures are very hard to debug.
  • Lookup field values from formContext are always returned as an array — always use lookupValue[0] to access the selected record.
✨ JavaScript in D365 might feel overwhelming at the start, but once you understand Xrm.WebApi and formContext, everything else starts to fall into place. Start small, test in the browser console, and build step by step. Every certified D365 developer started exactly where you are right now. Keep experimenting, keep building — and enjoy the journey. πŸš€