We are running in a blocking performance issue on a page that we built using Lightning Web Components.
Our page has many LWC components, each calling various apex methods via an imperative method call. The apex methods in turn make callouts to a third-party API.
We found that the page had terrible performance taking 30+ seconds to load even though each of the third-party API calls would only take 1-2 seconds each.
After some investigation, we found this article: https://jsforce.github.io/blog/posts/20150620-lightning-boxcarred-action-behavior.html which seems to explain our issue: Lightning framework automatically bundles our Apex calls into one and runs each method in the bundle sequentially (instead of in parallel), which leads to the terrible performance we are seeing. This bundling is called boxcarring.
In Aura, there is a way to turn off this boxcarring by calling action.setBackground() before calling $A.enqueueAction(action);
How can we achieve the same in LWC? This is pretty much a deal breaker for us so I would say that it is critical to provide this ability in LWC. Or to turn OFF boxcarring altogether in LWC as it destroys performance and does not seem to offer any advantage (as pointed out by the article).
I posted an idea for this, please vote for it if you ran into the same problem: https://success.salesforce.com/ideaView?id=0873A000000CZogQAG
UPDATE: We ended up creating our own service LWC component to handle apex calls. It features a priority queue so that we can specify which calls should be handled first (because they are visible first) as well as a limit on the number of concurrent calls to avoid having too many boxcarred calls taking a long time. This workaround improved performance enough for us until Salesforce can hopefully improve their boxcarring and handle calls in parallel instead of sequentially. Here is the code for our apexService.js:
const MAX_CONCURRENT_CALLS = 6;
const PRIORITY_DELAY = 1000;
let priorityQueue = [];
let ongoingCallCount = 0;
const processQueue = () => {
if (priorityQueue.length === 0) {
return;
}
//this function is used below in the loop, when the apex promise resolves
const processCall = (result, callback) => {
ongoingCallCount--;
callback(result);
processQueue(); //this will restart the queue processing in case it was halted because the max number of concurrent calls was reached
}
while (priorityQueue.length > 0) {
if (ongoingCallCount >= MAX_CONCURRENT_CALLS) {
//we reached the max number of concurrent calls, so abort! When an ongoing call finishes, it will restart the queue processing
break;
}
ongoingCallCount++;
const item = priorityQueue.shift();
item.apexPromise(item.params)
.then(result => {
processCall(result, item.callback);
})
.catch(error => {
processCall(error, item.handleError);
});
}
}
export const enqueueApex = (priority = 1, apexPromise, params, callback, handleError) => {
const item = { priority: priority, apexPromise: apexPromise, params: params, callback: callback, handleError: handleError };
//iterate through the priorityQueue to insert our new item before any items of later priority
let wasInserted = false;
for (let i = 0; i < priorityQueue.length; i++) {
if (item.priority < priorityQueue[i].priority) {
priorityQueue.splice(i, 0, item);
wasInserted = true;
break;
}
}
if (!wasInserted) { //if we didn't find any items of later priority in the queue, the new item is added at the end
priorityQueue.push(item);
}
if (priority === 1) {
processQueue();
}
else {
// introduces a delay that is proportional to the priority
// eslint-disable-next-line @lwc/lwc/no-async-operation
setTimeout(processQueue, PRIORITY_DELAY * (priority - 1));
}
}
This can then be called from other components as such:
enequeueApex(1, apexControllerMethod, paramsToTheApexMethod,
result => {
//do something here with the results from the apex call
},
error => {
//handle error here
}
);