Command Queue Pattern

I MANAGED TO OUTSOURCE AWAIT KEYWORD TO DIFFERENT FILE AND ACHIEVE EXACT SAME BEHAVIOR BAHAHHAHAHHAHAHAH

This pattern serializes UI commands that may include async work. It is useful when user actions should read like a sequence:

window.commandQueue.push(saveIfNeeded);
window.commandQueue.push(openNextScreen);

Instead of scattering workflow decisions across event callbacks, callbacks can be wrapped in Promises and used as queue steps.

Queue Implementation

class CommandQueue {
	#items = [];
	#running = false;

	push(fn) {
		this.#items.push(fn);
		this.#run();
	}

	async #run() {
		if (this.#running) return;
		this.#running = true;

		try {
			while (this.#items.length) {
				const fn = this.#items.shift();
				await fn();
			}
		} catch (error) {
			this.#items = [];
			console.error("Command queue aborted:", error);
		} finally {
			this.#running = false;
		}
	}
}

window.commandQueue = new CommandQueue();

Each queued function is called in order. If it returns a Promise, the queue waits for that Promise before continuing. If it returns nothing, the queue treats it as already complete.

Basic Usage

button.addEventListener("click", () => {
	if (needsSave()) {
		window.commandQueue.push(saveAsync);
	}
	window.commandQueue.push(() => navigateTo(nextUrl));
});

This is equivalent in spirit to this successful path:

if (needsSave()) {
	await saveAsync();
}
navigateTo(nextUrl);

If saveAsync() rejects, await throws and navigateTo(nextUrl) does not run unless that error is caught before the navigation line. The queue version has the same intended control flow, but centralizes the catch/log/clear behavior inside CommandQueue.

The queue is useful when commands may be pushed from different event handlers but still need to run one at a time.

Wrapping Events In Promises

Many browser and library APIs are event-based instead of Promise-based. Wrap the event in a Promise so the queue has something real to await.

function saveAsync() {
	return new Promise((resolve, reject) => {
		function resolveSaveOnSwap(event) {
			form.removeEventListener("htmx:afterSwap", resolveSaveOnSwap);

			if (event.detail.successful) {
				resolve();
			} else {
				reject(event);
			}
		}

		form.addEventListener("htmx:afterSwap", resolveSaveOnSwap);
		saveButton.click();
	});
}

The important part is that the Promise resolves only when the async work has actually completed. Calling saveButton.click() by itself is not enough, because click() returns immediately.

Permanent Versus Temporary Listeners

Use permanent listeners for local state updates:

function markSavedOnSwap(event) {
	if (!event.detail.successful) return;
	saveButton.disabled = true;
}

form.addEventListener("htmx:afterSwap", markSavedOnSwap);

Use temporary listeners for queue control:

function resolveSaveOnSwap(event) {
	form.removeEventListener("htmx:afterSwap", resolveSaveOnSwap);
	event.detail.successful ? resolve() : reject(event);
}

removeEventListener removes only the exact function reference passed to it. It does not remove every listener for that event type.

Error Behavior

If a queued command rejects, the queue aborts the current run:

catch (error) {
	this.#items = [];
	console.error("Command queue aborted:", error);
}

Clearing #items is important. Otherwise, dependent commands such as navigation can remain queued and run later after an unrelated command is pushed.

Good Fit

Poor Fit

Checklist