Continue to load HTML when <script src='...' is taking too long to load?

Viewed 2069

I was asked in a Job interview:

if a script takes more than X seconds to load , the whole page should be loaded (except this synchronous script). Note that we should not change the script to run asynchronously (for example, via appendChild). No server.

Well, I had a couple of approaches :

-remove the dom
-window.abort
-mess up the document by document.write("'</s'+'cript>'")
-moving it to an iframe
-adding headers of CSP

Nothing worked.

Here is the code (for example) to remove the script dom tag :

Notice that there is TEXT after the script. So it is expected to see the text after 1 sec.

<body>
  <script>
    setTimeout( function (){
 document.querySelector('#s').parentNode.removeChild(document.querySelector('#s'))

    },1000); //change here
  </script>

<script id ='s'  src="https://www.mocky.io/v2/5c3493592e00007200378f58?mocky-delay=40000ms" ></script>
    <span>!!TEXT!!</span>
</body>

Question

I can't seem to find the trick for how can I make the page continue loading after a certain timeout. How can I do that?

Fiddle

BTW I've seen interesting approaches here

5 Answers

Since it has been pointed to my attention that you said this interview happened a "long time ago", below solution is probably not what they expected at that time.
I will admit I have no idea what they were expecting then, but with today's APIs, it is doable:

You can setup a ServiceWorker which will handle all the requests of your page as would do a proxy (but hosted in the browser), and which would be able to abort the request if it takes too long.
But to do so, we need the AbortController API which is still considered an experimental technology, so once again, that may not be what they expected as an answer during this interview...

Anyway, here is what our ServiceWorker could look like to accomplish the requested task:

self.addEventListener('fetch', async function(event) {
  const controller = new AbortController();
  const signal = controller.signal;

  const fetchPromise = fetch(event.request.url, {signal})
    .catch(err => new Response('console.log("timedout")')); // in case you want some default content

  // 5 seconds timeout:
  const timeoutId = setTimeout(() => controller.abort(), 5000);
  event.respondWith(fetchPromise);
});

And here it is as a plnkr. (There is a kind of a cheat in that it uses two pages to avoid waiting once the full 50s, one for registering the ServiceWorker, and the other one where the slow-network occurs. But since the requirements say that the slow network happens "On occasions", I think it's still valid to assume we were able to register it at least once.)
But if that's really what they wanted, then you'd even be better just to cache this file.

And as said in comments, if you ever face this issue IRL, then by all means try to fix the root cause instead of this hack.

My guess is that this was a problem they were trying to solve but had not, so they were asking candidates for a solution, and they would be impressed with anyone who had one. My hope would be that it was a trick question and they knew it and wanted to see if you did.

Given their definitions, the task is impossible using what was widely supported in 2017. It is sort of possible in 2019 using ServiceWorkers.

As you know, in the browser, the window runs in a single thread that runs an event loop. Everything that is asynchronous is some kind of deferred task object that is put on a queue for later execution1. Communications between the window's threads and workers' threads are asynchronous, Promises are asynchronous, and generally XHR's are done asynchronously.

If you want to call a synchronous script, you need to make a call that blocks the event loop. However, JavaScript does not have interrupts or a pre-emptive scheduler, so while the event loop is blocked, there is nothing else that can run to cause it to abort. (That is to say, even if you could spin up a worker thread that the OS runs in parallel with the main thread, there is nothing the worker thread can do to cause the main thread to abort the read of the script.) The only hope you could have of having a timeout on the fetch of the script is if there were a way to set an operating-system-level timeout on the TCP request, and there is not. The only way to get a script other than via the HTML script tag, which has no way to specify a timeout, is XHR (short for XMLHttpRequest) or fetch where it is supported. Fetch only has an asynchronous interface, so it is no help. While it is possible to make a synchronous XHR request, according to MDN (the Mozilla Developer's Network), many browsers have deprecated synchronous XHR support on the main thread entirely. Even worse, XHR.timeout() "shouldn't be used for synchronous XMLHttpRequests requests used in a document environment or it will throw an InvalidAccessError exception."

So if you block the main thread to wait for the synchronous loading of the JavaScript, you have no way to abort the loading when you have decided it is taking too long. If you do not block the main thread, then the script will not execute until after the page has loaded.

Q.E.D

Partial Solution with Service Workers

@Kaiido argues this can be solved with ServiceWorkers. While I agree that ServiceWorkers were designed to solve problems like this, I disagree that they answer this question for a few reasons. Before I get into them, let me say that I think Kaiido's solution is fine in the much more general case of having a Single Page App that is completely hosted on HTTPS implement some kind of timeout for resources to prevent the whole app from locking up, and my criticisms of that solution are more about it being a reasonable answer to the interview question than any failing of the solution overall.

  • OP said the question came from "a long time ago" and service worker support in production releases of Edge and Safari is less than a year old. ServiceWorkers are still not considered "standard" and AbortController is still today not fully supported.
  • In order for this to work, Service Workers have to be installed and configured with a timeout prior to the page in question being loaded. Kaiido's solution loads a page to load the service workers and then redirects to the page with the slow JavaScript source. Although you can use clients.claim() to start the service workers on the page where they were loaded, they still will not start until after the page is loaded. (On the other hand, they only have to be loaded once and they can persist across browser shutdowns, so in practice it is not entirely unreasonable to assume that the service workers have been installed.)
  • Kaiido's implementation imposes the same timeout for all resources fetched. In order to apply only to the script URL in question, the service worker would need to be preloaded with the script URL before the page itself was fetched. Without some kind of whitelist or blacklist of URLs, the timeout would apply to loading the target page itself as well as loading the script source and every other asset on the page, synchronous or not. While of course it is reasonable in this Q&A environment to submit an example that is not production ready, the fact that to limit this to the one URL in question means that the URL needs to be separately hard coded into the service worker makes me uncomfortable with this as a solution.
  • ServiceWorkers only work on HTTPS content. I was wrong. While ServiceWorkers themselves have to be loaded via HTTPS, they are not limited to proxying HTTPS content.

That said, my thanks to Kaiido for providing a nice example of what a ServiceWorker can do.

1There is an excellent article from Jake Archibald that explains how asynchronous tasks are queued and executed by the window's event loop.

If the page doesn't reach the block that sets window.isDone = true, the page will be reloaded with a querystring telling it not to write the slow script tag to the page in the first place.

I'm using setTimeout(..., 0) to write the script tag to the page outside of the current script block, this does not make the loading asynchronous.

Note that the HTML is properly blocked by the slow script, then after 3 seconds the page reloads with the HTML appearing immediately.

This may not work inside of jsBin, but if you test it locally in a standalone HTML page, it will work.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  
  
</head>
<body>
  <script>
        
    setTimeout( function (){
       
     
      //    window.stop();
    
      window.location.href = window.location.href = "?prevent"
         
 
    }, 3000); //change here
  </script>
  
  <script>
    function getSearchParams(){
      return window.location.search.slice(1).split('&').reduce((obj, t) => {
        const pair = t.split('=')
        
        obj[pair[0]] = pair[1]
        
        return obj
      }, {})
    }

    console.log(getSearchParams())
    
    if(!getSearchParams().hasOwnProperty('prevent')) setTimeout(e => document.write('<script id ="s"  src="https://www.mocky.io/v2/5c3493592e00007200378f58?mocky-delay=4000ms"><\/script>'), 0)
  </script>
    <span>!!TEXT!!</span>
</body>
</html>

Here is a solution the uses window.stop(), query params, and php.

<html>
<body>
<?php if(!$_GET["loadType"] == "nomocky"):?>
<script>
var waitSeconds = 3; //the number of seconds you are willing to wait on the hanging script
var timerInSeconds = 0;
var timer = setInterval(()=>{
timerInSeconds++;
console.log(timerInSeconds);
  if(timerInSeconds >= waitSeconds){
    console.log("wait time exceeded. stop loading the script")
    window.stop();
    window.location.href = window.location.href + "?loadType=nomocky"
  }
},1000)
setTimeout(function(){
   document.getElementById("myscript").addEventListener("load", function(e){
     if(document.getElementById("myscript")){
        console.log("loaded after " + timerInSeconds + " seconds");
        clearInterval(timer);
     }
  })
},0)
</script>
<?php endif; ?>

<?php if(!$_GET["loadType"] == "nomocky"):?>
<script id='myscript' src="https://www.mocky.io/v2/5c3493592e00007200378f58?mocky-delay=20000ms"></script>
<?php endif; ?>

<span>!!TEXT!!</span>
</body>
</html>

You can use async property in script tag it will load your js file asynchronous. script src="file.js" async>

Related