Creating a precise countdown with Vanilla JS
On a recent technical interview I had for a big tech company, in one of the steps of the process I was asked the following:
Create a countdown from 1:30 to zero using plain javascript and HTML, and that’s all.
“Cool”, I said to myself, that’s something I can achieve easily. The total time I had to develop that was 45 minutes, which at first looked more than enough.
I started doing the simplest thing I could imagine, mimicking what a normal clock or countdown does: at each 1-second interval, we would decrease the value of the counter by 1 until the end of the 90-second period. This was the result:
const initialSeconds = 90;let remainingSeconds;/*** Returns seconds in format mm:ss* @param {Number} seconds* @return {string}*/const formatMinutesSeconds = (seconds) => { const thisDate = new Date(seconds * 1000);
return `${thisDate.getMinutes()}:${thisDate.getSeconds()}`;};/*** Renders the remaining time in a format mm:ss* @param {Number} seconds*/const renderCountdown = seconds => { const counterElement = document.getElementById('counter'); const stringCounter = formatMinutesSeconds(seconds); counterElement.innerText = stringCounter;};/*** Starts a countdown with the given seconds* @param {Number} seconds*/const startCountdown = (seconds) => { remainingSeconds = seconds; setInterval(_ => {
if (remainingSeconds > 0) { remainingSeconds--; renderCountdown(remainingSeconds);
} }, 1000); renderCountdown(remainingSeconds);};startCountdown(initialSeconds);
As you can see, I used the native setInterval
function to mimic a tick on a regular clock, and decreased the total amount of seconds by 1. By that time, around 10–15 minutes had passed and the interviewer told me:
Well done, but do you think it will be precise enough?
As you may know (or not), Javascript works with a single thread with no context switching, which in other words, means that functions are executed from the beginning until the end without interruptions, so, if there was another function somewhere taking a lot of time (let’s say a long for
statement), it would cause some delays. Using a setInterval, will only guarantee that the callback function will be executed at least n seconds after the time is created, but if the Event Loop is busy at the moment of its completion, it will wait until it finishes all the previous tasks before running the code we want.
If we want to be precise, this solution won’t work.
I started thinking of possible variations to that.
First, I thought of using a web worker, which will made the timer to run in the background and that may not solve it entirely, but that wasn’t the path the interviewer wanted me to take. He was interested on seeing how would I compare Date objects no matter when was the check being done, so, if the event loop happened to be locked for 5 seconds, the next “tick” will reduce the countdown by 5 seconds and it will be on sync.
With the code I already had written, I needed to make a few changes to make it work like that. After struggling for some time, I came up with this solution:
const initialSeconds = 90;/*** Returns seconds in format mm:ss* @param {Number} seconds*/const formatMinutesSeconds = (seconds) => { const thisDate = new Date(seconds * 1000); return `${thisDate.getMinutes()}:${thisDate.getSeconds()}`;};let remainingSeconds;const renderCountdown = seconds => { const counterElement = document.getElementById('counter'); const stringCounter = formatMinutesSeconds(seconds); counterElement.innerText = stringCounter;};const startCountdown = (seconds) => { let initialTime = new Date(); setInterval(_ => { const newTime = new Date(); const dateDiff = new Date(newTime - initialTime); let secondsPassed = dateDiff.getSeconds(); if (secondsPassed > 0) { renderCountdown(seconds - secondsPassed); } }, 100); renderCountdown(seconds);}
startCountdown(initialSeconds);
The “magic” happens inside the callback function of setInterval. What I’m doing basically, is to store the Date object representing the time where the countdown started, and after 100 milliseconds, I’m checking if a second has passed (calculating the difference between the current time and the time the countdown started) and if that’s true, I will render the countdown with the new remaining seconds.
Well done, that looks good. To finish, now create a button to start and resume the countdown.
By then, I didn’t have much time left, but I tried my best. What I did first, was creating a button to start/pause the countdown. That button would be in charge of creating the interval, and to clear it if it already existed.
The first blocker I found (that, in the end, was the one that caused my failure in finishing the task on time) was related to the initialTime
variable I previously created. I encountered a few problems because when pausing the countdown and resuming it a few moments later, the value of seconds — secondsPassed
was out of sync, because that intialTime
was still the same. One of the solutions could have been to store the new initialTime
after starting the countdown again, but that made no sense in my head, since the name of the variable made me think that I shouldn’t reassign its value.
In the end, the interview finished and I couldn’t come up with a solution, but then with more time, I was able to make it work. Here is the final code:
const initialSeconds = 90;/*** Returns seconds in format mm:ss* @param {Number} seconds*/const formatMinutesSeconds = (seconds) => { const thisDate = new Date(seconds * 1000); return `${thisDate.getMinutes()}:${thisDate.getSeconds()}`;};const renderCountdown = seconds => { const counterElement = document.getElementById('counter'); const stringCounter = formatMinutesSeconds(seconds); counterElement.innerText = stringCounter;};window.addEventListener('DOMContentLoaded', () => { const startButton = document.getElementById('btnStart'); let remainingSeconds = initialSeconds; let intervalCountdown;
startButton.addEventListener('click', () => { startCountdown(remainingSeconds); });
renderCountdown(remainingSeconds);
const startCountdown = (seconds) => { let initialTime = new Date(); if (!intervalCountdown) { intervalCountdown = setInterval(_ => { const newTime = new Date(); const dateDiff = new Date(newTime - initialTime); let secondsPassed = dateDiff.getSeconds();
if (secondsPassed > 0) {
remainingSeconds = seconds - secondsPassed; renderCountdown(remainingSeconds); } }, 100); } else { clearInterval(intervalCountdown); intervalCountdown = null; } };});
As you can see, I’d store the remaining seconds in a variable and continue from there if the counter is resumed. It’s not as precise as it could be, but works.
Next steps
- Make it more accurate by storing the milliseconds passed, or even better, to update the Date object where the pause button was hit too.
- Improve the formatting of the timer, now if the seconds are less than 10, it will display something like
1:4
for 1 minute 4 seconds. - Clear the interval when it reaches 0 seconds and restart the counter.
Conclusion
After I finished the exercise, I realised how simple it really was. The main problem I encountered was related to the refactor of what I wrote before. I didn’t want to change my initial design and struggled a lot to make it work with what I already had.
Whenever I have to think to understand what the code is doing, I ask myself if I can refactor the code to make that understanding more immediately apparent.
― Martin Fowler, Refactoring: Improving the Design of Existing Code
What I should have done, was to read again the code I wrote and see what I really needed, and from there start all over again. Of course that when you have a clock running down telling you “you only have 10 minutes left” it makes things more difficult, but thankfully, I never faced that in real life and that’s not what I look for in a company I want to work for.
That experience was great though, and I learned a lot, the key is to have a clear mind and think before coding.