Michal Miškerník

Reliable timeouts in React apps

Posted on 03 August, 2019

Let's say your React app presents the user with a task that has a time limit. The user must submit the task before the time runs out, otherwise the app will submit the task automatically.

The simple solution would be to call setTimeout at the appropriate moment, probably when the user starts the task.

const Task = () => {
  const timeLimit = useSelector(selectTimeLimit);
  const dispatch = useDispatch();

  useEffect(() => {
    const id = setTimeout(() => {
      dispatch({ type: "TIMEOUT" });
    }, timeLimit);

    return () => {
      clearTimeout(id);
    };
  }, [timeLimit, dispatch]);

  // ...
};

The app shown here uses Redux to manage it's state. The TIMEOUT action will check if the task has been submitted and if not, it will submit it. useSelector and useDispatch are hooks provided by react-redux.

This solution seems fine—the component will set a timer that dispatches the appropriate action when the time limit passes. However, things are not as easy as they might seem.

Let's say the user reloads the app, but the app remembers the task has already been started, and renders the Task component again. The time limit is still the same, but the real time left is less. So let's use the amount of time that is left to finish the task, instead of the time limit.

const Task = () => {
  const timeLeft = useSelector(selectTimeLeft);
  const dispatch = useDispatch();

  useEffect(() => {
    const id = setTimeout(() => {
      dispatch({ type: "TIMEOUT" });
    }, timeLeft);

    return () => {
      clearTimeout(id);
    };
  }, [timeLeft, dispatch]);

  // ...
};

Great, now the task is submitted when it's supposed to be, even if the user reloads the app!

You release the app to your users and after some time you start to get random reports of users trying to submit the task even after the time runs out. How can this be? Your app definitely submits the task automatically once time runs out!

Turns out, things are much more difficult than you expected. See, it cannot be assumed that when you call setTimeout(fn, 5000), the function will actually be called after 5 seconds have passed in the real world. For example, if the user puts their computer to sleep, or closes the browser on their phone, the browser will call the function at some point in the future, when it's possible. More details can be found in the setTimeout documentation on MDN.

Of course, you will have implemented checks on your server that make sure the task cannot be submitted after the time runs out. You could leave the code as it is, but let's say you want to provide your users with a good experience: try to submit the task when the time runs out, or let them know that the time ran out and it is no longer possible.

Instead, you can use the setInterval function and check at a specific interval if the task should be submitted. Note that like setTimeout, setInterval might not be called at exactly the specified interval, so for example for setInterval(fn, 1000), the function can be called first after a second, then after two seconds, then second again, and finally after 10 minutes.

const Task = () => {
  const timeLeft = useSelector(selectTimeLeft);
  const dispatch = useDispatch();

  useEffect(() => {
    const startTime = Date.now();
    let lastCheck = startTime;

    const checkTimeout = () => {
      const currentTime = Date.now();

      const intervalStart = lastCheck - startTime;
      const intervalEnd = currentTime - startTime;

      if (timeLeft >= intervalStart && timeLeft < intervalEnd) {
        dispatch({ type: "TIMEOUT" });
      }

      lastCheck = currentTime;
    };

    const id = setInterval(checkTimeout, 1000);

    return () => {
      clearInterval(id);
    };
  }, [timeLeft, dispatch]);
};

Let's unpack what's happening here. When the component mounts, the current time is saved. We need to use the real-world time here, as it is not affected by anything, even if for example the computer is put to sleep.

The checkTimeout function is set to be called every second, but remember, this is not guaranteed. The function calculates the time between the component mounting and last call to the function, and the time between the component mounting and now.

Then it compares these two values with the time that is left. If the time ran out somewhere between the last and the current call of the function, it will dispatch the TIMEOUT action.

This way the action is guaranteed to be dispatched at most once, as soon as possible after the time runs out.

I encountered this problem in the Hundred5 app that candidates use to take tests. We implemented the initial solution and started getting random reports of users submitting the test after time ran out. As it happened only a few times per month, it took months to set up proper error reporting that finally revealed the issue. The second solution was implemented shortly after that.

Instead of using hooks and calling setInterval in the component, the code runs as a Redux middleware—and indeed this is a good example for when a middleware can be very useful.

Served by Vercel