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.
100K+ Contact records in Dynamics 365. Client needed to view them grouped by Owner — active & inactive — which CRM views don't natively support.
Build a custom embedded web resource letting users select an owner and instantly see all their contacts with live Active / Inactive counts.
Fetch distinct owners first via OData. On owner selection, paginate contact records (5,000/page) filtered by that owner — no full-dataset load needed.
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.
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:
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:
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.
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.
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.
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.
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);
}
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.
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.
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 -->
<!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
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.
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.
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).
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
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: contentson 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.aspxscript reference is non-negotiable for any CRM-hosted web resource that needs to call the Web API.