Get Started with Dynamics 365 Plugin Development: A Practical Guide

Get Started with Dynamics 365 Plugin Development: A Practical Guide

Get Started with Dynamics 365 Plugin Development: A Practical Guide

Write the plugin, understand every line, register it, and confirm it actually runs.

What a plugin actually is

A plugin is how you run custom C# code the moment something happens in Dynamics 365 — a record created, updated, or deleted.

Plugins run on the server, not inside your own application, so the setup and the debugging look a little different from a normal C# project.

This guide covers everything you need: writing the plugin, understanding what each piece of code does, registering it in D365, and testing it.

Scenario

Trigger: a new Account record is created.

Logic: if the description field is left blank, the plugin fills it in with a note like "Description auto-populated by plugin on {date}".

This is a simple example, but it uses the same pattern you'll use in almost every plugin: read the record, check a value, update it.

Prerequisites

Before you start
  • Visual Studio 2022 (Community edition is fine). During setup, install the ".NET desktop development" workload — that's what gives you the Class Library (.NET Framework) project template.
  • .NET Framework 4.6.2 as the project's target framework. D365 plugins must use .NET Framework, not .NET Core or .NET 5+, or the plugin won't load.
  • NuGet package: Microsoft.CrmSdk.CoreAssemblies — adds the SDK classes used in the code below.
  • Plugin Registration Tool, available as a NuGet package (Microsoft.CrmSdk.XrmTooling.PluginRegistrationTool) or as a tool inside XrmToolBox. You'll use this to upload your plugin to D365.
  • A D365 environment with a user account that has System Administrator (or System Customizer) access, so you can register plugins and view logs.
  • A strong name key (.snk), created in Visual Studio. D365 requires plugin assemblies to be signed.

Step 1: Create the project

  1. Open Visual Studio → Create a new project.
  2. Search for Class Library (.NET Framework) (not the plain "Class Library" template, which uses .NET Core).
  3. Name it AccountPlugins, confirm the target framework is .NET Framework 4.6.2, and create it.
  4. Right-click the project → Manage NuGet PackagesBrowse → search Microsoft.CrmSdk.CoreAssembliesInstall.
  5. Right-click the project → PropertiesSigning tab → check Sign the assemblyNew... → create a .snk file (password optional). This step is required before you can register the plugin later.

Step 2: Write the plugin class

Every plugin implements IPlugin, an interface with one method: Execute. D365 calls this method whenever your registered event happens.

C# · SetDefaultDescription.cs
using System;
using Microsoft.Xrm.Sdk;

namespace AccountPlugins
{
    public class SetDefaultDescription : IPlugin
    {
        public void Execute(IServiceProvider serviceProvider)
        {
            ITracingService tracingService =
                (ITracingService)serviceProvider.GetService(typeof(ITracingService));

            IPluginExecutionContext context =
                (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));

            IOrganizationServiceFactory serviceFactory =
                (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            IOrganizationService service = serviceFactory.CreateOrganizationService(context.UserId);

            tracingService.Trace("Plugin started for message: " + context.MessageName);

            if (context.InputParameters.Contains("Target") &&
                context.InputParameters["Target"] is Entity account)
            {
                if (!account.Contains("description"))
                {
                    account["description"] = $"Description auto-populated by plugin on {DateTime.Now}";
                    service.Update(account);
                    tracingService.Trace("Description field updated.");
                }
                else
                {
                    tracingService.Trace("Description already had a value, nothing to do.");
                }
            }
        }
    }
}

What each line means

IServiceProvider serviceProvider

D365 passes this into your plugin automatically. Think of it as a toolbox; everything else you need comes out of it using GetService(typeof(...)).

ITracingService

Lets you write log messages. Since plugins run on the server, you can't debug them like a normal app, so these logs are how you check what happened.

IPluginExecutionContext

Tells you what triggered the plugin, like the message name (Create, Update, etc.) and the record itself. context.InputParameters["Target"] is the actual record being created or updated.

IOrganizationServiceFactory

Creates the service you'll use to talk to D365.

IOrganizationService

The object you use to Create, Retrieve, Update, or Delete records. In this plugin, service.Update(account) saves the change.

Account createdCreate message
PostOperationplugin pipeline stage
Execute() runschecks description
service.Update()description populated

Step 3: Build the assembly

Build the project (use the Release configuration). Check that AccountPlugins.dll was created under bin\Release with no errors. If the build fails because of signing, go back and confirm you completed the signing step in Step 1.

Step 4: Register the plugin

Open the Plugin Registration Tool and connect to your D365 environment:

  1. CREATE NEW CONNECTION → enter your environment URL → sign in.
  2. Register menu → Register New Assembly → select AccountPlugins.dll → set Isolation Mode to Sandbox → click Register Selected Plugins.
  3. Right-click the SetDefaultDescription class in the tree → Register New Step.
  4. Fill in the step (see the mock panel below), then click Register New Step to save.
Plugin Registration Tool AccountPlugins.dll SetDefaultDescription
Register
View
Help
Register
Actions
Debug
Register New Step — SetDefaultDescription Not Registered
Create
account
PostOperation
Synchronous

Click "Register New Step" above to see how the tool confirms the step was saved.

Step 5: Verify it works

Create a new Account record without filling in the Description field, then save it. Reopen the record and check the Description field — it should now show the auto-generated note.

If it doesn't work

Open the Plugin Registration Tool, right-click your step, and use Profile to see the trace log and find out where it stopped.

The rule of thumb

Every plugin follows the same shape: pull the target record out of InputParameters, check a value, and use IOrganizationService to act on it. Once this pattern is comfortable, moving from a simple field default to more complex business logic is mostly a matter of what you check and what you do next — not how the plugin is wired together.

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.