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.

No comments:

Post a Comment