You Shouldn’t Call window.open() Asynchronously

The Window.open() function lets one webpage open another page in a new window or tab. This ability was, of course, immediately abused to serve popup ads, so browsers now have popup-blockers that try to detect and block that kind of behavior. If you’re using this API, you need to design around the heuristics of the popup-blocker.

Modern browsers have strict popup blocker policies. Popup windows must be opened in direct response to user input, and a separate user gesture event is required for each Window.open() call. This prevents sites from spamming users with lots of windows.

MDN Web Docs

The particulars of exactly how the popup-blocker works is a browser-specific implementation detail, but in general Window.open() should only be called with user input event (e.g. click) in the call stack. However, when you start throwing asynchronous operations into the mix, like fetching a URL from the server before opening it, the browser has a tendency to get confused and block the new window anyways.

Here’s a naive implementation that can exhibit this behavior:

async function handleButtonClicked() {
	const targetUrl = await getUrlFromServer();
	window.open(targetUrl);
}

This results in a suboptimal user experience where the user needs to explicitly approve the new window, turning one click into two.

I’ve found that taking a slightly different approach avoids this problem.

Instead of trying to open the window asynchronously, you can open a blank window synchronously in the event handler, then asynchronously alter its contents. This is possible because Window.open() returns a WindowProxy instance (if the new window opens successfully), which can be used to manipulate the newly-opened window. In this case, we just need to alter its location to navigate it to a different URL.

function handleButtonClicked() {
	const newWindow = window.open();
	if (newWindow) {
		getUrlFromServer().then((targetUrl) => {
			newWindow.location.href = targetUrl;
		}).catch(() => {
			newWindow.close();
		});
	}
}

By default, the new window will open with about:blank. If the async operation is expected to take more than a fraction of a second, then I’d recommend initially populating the new window with some loading indicator.

An important caveat to this approach is that you cannot set the noopener or noreferrer window features, since providing either of those causes Window.open() to return null instead of a WindowProxy instance as usual. If you’re opening a page with potentially untrusted content, then one workaround for this is to manually set newWindow.opener to null.