Use async/await to implement mutual exclusion in JavaScript
This loop:
async function enqueue(task) {
  while (previousPromise) {
    await previousPromise;
  }

will (asynchronously) block forever, after the first time previousPromise has been assigned to. An expression which is a Promise (for example, the previousPromise) will always remain a Promise, and will be truthy. You can use await previousPromise to extract its resolve value into a different expression, but the previousPromise remains a Promise. No more than two tasks can be completed using that enqueue.
A better method would be to reassign the Promise every time enqueue is called, while chaining onto the previous Promise with a .then:
previousPromise = previousPromise.then(() =&gt; executeTask(task));

It'll also be easier to manage if the previousPromise stays the same shape (that is, of a Promise), rather than having to manage it possibly being null. Initialize it to Promise.resolve().
Demo below, where a task takes 1 second: it starts 2 tasks immediately, then 2 more tasks after 1.5 seconds (so these 4 together finish after 4 seconds), then another after 7 seconds (which finishes on second 8).


const executeTask = () =&gt; new Promise(resolve =&gt; setTimeout(() =&gt; {
  console.log('task resolving');
  resolve();
}, 1000));
let previousPromise = Promise.resolve();

function enqueue(task) {
  previousPromise = previousPromise.then(() =&gt; executeTask(task));
}

console.log('start');
enqueue();
enqueue();
setTimeout(() =&gt; {
  enqueue();
  enqueue();
}, 1500);
setTimeout(() =&gt; {
  enqueue();
}, 7000);



Another thing to consider is: what do you want to occur when one of the tasks rejects (if that's ever a possibility)? You might want to add on a .catch onto the end of executeTask to make sure the next task can be started, without the whole Promise chain breaking:
previousPromise = previousPromise
  .then(() =&gt; executeTask(task))
  .catch((err) =&gt; { /* log error? Make sure previousPromise always resolves */ });