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.

No comments:

Post a Comment