3

I need to execute an operation that needs to be executed relatively fast (let's say 10 times per second. It should be fast enough, but I can sacrifice speed if there are issues.) This is an ajax request, so potentially I do not know how much time it takes - it could even take seconds if network is bad.

The usual:

setInterval(() => operation(), 100);

Will not work here, because if the network is bad and my operation takes more than 100 ms, It might be scheduled one after another, occupying JS engine time (please correct me if I'm wrong)

The other possible solution is to recursively run it:

function execute() {
   operation();
   setTimeout(execute, 100);
}

This means that there will be 100 ms between the calls to operation(), which is OK for me. The problem with this is that I'm afraid that it will fail at some point because of stack overflow. Consider this code:

i = 0;
function test() { if (i % 1000 == 0) console.log(i); i++; test(); }

If I run it my console, this fails in around 12000 calls. if I add setTimeout in the end, this would mean 12000 / 10 / 60 = 20 minutes, potentially ruining the user experience.

Are there any simple ways how to do this and be sure it can run for days?

FZs
  • 16,581
  • 13
  • 41
  • 50
Archeg
  • 8,364
  • 7
  • 43
  • 90
  • 1
    Doing a call 10 times a second is a lot for ajax. You may want to consider switching to something designed for realtime data transfer, like websockets. – frodo2975 May 29 '20 at 13:38
  • 1
    Introducing service worker into your app is a plausible solution. – aksappy May 29 '20 at 13:44
  • @frodo2975 This is interesting. Any good reads on why websockets are better in situations like this? – Archeg May 30 '20 at 18:46
  • Websockets is a faster and more efficient protocol that's specifically designed for things that are realtime. See this answer for more details: https://stackoverflow.com/questions/10377384/why-use-ajax-when-websockets-is-available – frodo2975 May 30 '20 at 23:51

2 Answers2

3

There's no "recursion" in asynchronous JavaScript. The synchronous code (the test function) fails because each call occupies some space in the call stack, and when it reaches the maximum size, further function calls throw an error.

However, asynchrony goes beyond the stack: when you call setTimeout, for example, it queues its callback in the event loop and returns immediately. Then, the code, that called it can return as well, and so on until the call stack is empty. setTimeout fires only after that.

The code queued by setTimeout then repeats the process, so no calls accumulate in the call stack.

Therefore, "recursive" setTimeout is a good solution to your problem.

Check this example (I recommend you to open it in fullscreen mode or watch it in the browser console):

Synchronous example:

function synchronousRecursion(i){ 
  if(i % 5000 === 0) console.log('synchronous', i)
  synchronousRecursion(i+1);
  //The function cannot continue or return, waiting for the recursive call
  //Further code won't be executed because of the error
  console.log('This will never be evaluated')
}

try{
  synchronousRecursion(1)
}catch(e){
  console.error('Note that the stack accumuates (contains the function many times)', e.stack)
}
/* Just to make console fill the available space */
.as-console-wrapper{max-height: 100% !important;}

Asynchronous example:

function asynchronousRecursion(i){ 
  console.log('asynchronous',i)
  console.log('Note that the stack does not accumuate (always contains a single item)', new Error('Stack trace:').stack)
  setTimeout(asynchronousRecursion, 100, i+1);
  //setTimeout returns immediately, so code may continue
  console.log('This will be evaluated before the timeout fires')
  //<-- asynchronusRecursion can return here
}

asynchronousRecursion(1)
/* Just to make console fill the available space */
.as-console-wrapper{max-height: 100% !important;}
FZs
  • 16,581
  • 13
  • 41
  • 50
  • I'm not sure I understand. How do you know that the second example does not accumulate the stack? I actually tested it before with this code: `function test() { console.trace(); console.log("-"); setTimeout(test, 100); }` - as you can see with each call `console.trace()` shows bigger stacktrace. – Archeg May 30 '20 at 18:42
  • I think your example does not fail with stack overflow just because it is slower (consequence of using `setTimeout`) – Archeg May 30 '20 at 18:43
  • @Archeg No. Using `setTimeout` won't ever overflow the stack! I'll try to explain... – FZs May 30 '20 at 18:44
  • @Archeg Some browsers' DevTools are so smart that `console.trace()` can keep track of where the asynchronous event was initiated, and log it in the trace for easier debugging (but this isn't the part of ECMAScript itself, and shouldn't cause problems). These traces (indicated by `(async)` in Chrome) do not exist in the real stack (I used `console.log(new Error().stack)` to get the *real* stack). – FZs May 30 '20 at 18:51
  • @Archeg And if you understand how the JS event loop works, you'll also understand that stack accumulation is impossible in async code: as JS is single-threaded (without Web Workers, of course), it can be asynchronous because of thing called "event loop". It works by queuing async *tasks*, each task being a function call. Since only a single part of code can be executed at the same time, each task awaits that the previous one returns. However, when it returns, its stack entry must pop out **before** the next async task is called. – FZs May 30 '20 at 18:59
  • @Archeg And, if you don't believe me, just copy-paste my asynchronous example, set the timeout to `1`ms, and let it run as long as you like in the background, then check, how much "recursion" have you reached without crashing... – FZs May 30 '20 at 19:08
  • 1
    You are right, `console.log(new Error().stack)` does show where I'm wrong. I understand stack and event-loop, I was actually thinking the same before asking the question, but decided to test with `console.trace()` and didn't realize it is lying. Then I thought that JS has some magic behind `setTimeout`, like maybe saving a stack and restoring it? IDK. Didn't expect `console.trace()` to be smart. Thanks for the explanation, this helped me a lot! – Archeg May 30 '20 at 19:16
  • @Archeg [The too smart DevTools tricked us again!](https://www.freecodecamp.org/news/mutating-objects-what-will-be-logged-in-the-console-ffb24e241e07/) – FZs May 30 '20 at 19:27
0

The two alternatives you showed here actually share the flaw you're concerned about, which is that the callbacks might bunch up and run together. Using setTimeout like this is (for your purposes) identical to calling setInterval (except for some small subtleties that don't apply with a light call like making an AJAX request.)

It sounds like you might want to guarantee that the callbacks run in order, or potentially that if multiple callbacks come in at once, that only the most recent one is run.

To build a service that runs the most recent callback, consider a setup like this:

let lastCallbackOriginTime = 0;

setInterval(()=>{
    const now = new Date().getTime();
    fetch(url).then(x=>x.json()).then(res=>{
        if ( now > lastCallbackOriginTime ) {
            // do interesting stuff 
            lastCallbackOriginTime = now;
        }
        else console.log('Already ran a more recent callback');
    });
}, 100);

Or let's make it run the callbacks in order. To do this, just make each callback depend on a promise returned by the previous one.

let promises = [Promise.resolve(true)];
setInterval(()=>{
    const promise = new Promise((resolve, reject)=> {
fetch(url).then(x=>x.json()).then(serviceResponse=>{
        const lastPromise = promises[promises.length - 1];
        lastPromise.then(()=>resolve(serviceResponse));
    }).then((serviceResponse)=>{
        // Your actual callback code 
    });
    promises.push(promise)

   });
}, 100);


Jacob Brazeal
  • 654
  • 9
  • 16