When Client-Side JavaScript Hits Its Limits in Dynamics 365
A real-world story of throttling, silent failures, and why server-side won.
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.
Click Submit above to see the interaction — wired the same way the real ribbon command works.
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
<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
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
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.
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
await Xrm.WebApi.createRecord("salesorderdetail", line); // self-throttle between calls await new Promise(r => setTimeout(r, 300));
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
for (const chunk of chunkArray(lines, 5)) { await Promise.all(chunk.map(line => Xrm.WebApi.createRecord("salesorderdetail", line) )); }
Firing 5 simultaneous requests per chunk concentrated the burst. The throttle hit harder, not softer.
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.
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
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