Timers are deceptively simple in JavaScript. Call setInterval(fn, delay)
and boom, your function runs repeatedly. But under the hood, setInterval
has sharp edges like overlapping executions, piling up calls, and imprecise control that many developers don't see until they hit real-world timing issues.
When I say
setInterval
causes "overlapping executions", I mean it schedules new runs even if the previous one hasn't finished. In JavaScript, these executions are actually queued and never run at the exact same time. Still, this can lead to multiple pending executions piling up, which can cause performance issues.
In this post, we'll walk through why setInterval
can be problematic, and how to implement a smarter, safer version of it yourself. You'll end up with a reusable utility that behaves like setInterval
, but without its flaws.
The Problem with setInterval
Let's start with what happens if you naively use setInterval
for a task that takes longer than its interval:
// simulate a task that takes 3 seconds
function sleep(ms) {
const start = Date.now();
while (Date.now() - start < ms) {
// busy wait
}
}
let count = 0;
function tick() {
const now = new Date().toLocaleTimeString();
console.log(`[${ now }] Tick ${ ++count } START`);
sleep(3000);
console.log(`[${ new Date().toLocaleTimeString() }] Tick ${ count } END`);
}
// execute tick() every 1 second
setInterval(tick, 1000);
You might expect this to run every second. Instead, you get overlapping calls:
[19:50:01] Tick 1 START
[19:50:04] Tick 1 END
[19:50:04] Tick 2 START
[19:50:07] Tick 2 END
[19:50:07] Tick 3 START
[19:50:10] Tick 3 END
...
Each execution lasts 3 seconds, but since setInterval
doesn't wait for one execution to finish before scheduling the next, the timer keeps firing, causing call stacking and potentially freezing your app.
The Safer Alternative
Here's the idea: recursive setTimeout
. Instead of setting a fixed interval that blindly schedules calls, we wait until the current execution completes before scheduling the next one.
function betterInterval(task, delay) {
const wrapper = async () => {
await task();
setTimeout(wrapper, delay);
};
wrapper();
}
This version avoids overlapping calls and guarantees a minimum delay between the end of one run and the start of the next.
[20:10:01] Tick 1 START
[20:10:04] Tick 1 END
[20:10:05] Tick 2 START
[20:10:08] Tick 2 END
[20:10:09] Tick 3 START
[20:10:12] Tick 3 END
...
When this Is better
Use Case | Better | Custom Interval? |
---|---|---|
Lightweight periodic logging | ❌ | setInterval is fine |
Network polling | ✅ | Prevents overlapping requests |
CPU-heavy tasks | ✅ | Avoids browser lock-ups |
Real-time UI animation | ❌ | Use requestAnimationFrame |
Dynamically adjustable loops | ✅ | Use setTimeout based loop |
Building a Fully Featured Replacement
Let's take it further. Here's a custom utility that:
- Prevents overlaps
- Maintains accurate timing (like
setInterval
) - Supports start, stop, and dynamic delay updates
function createInterval(task, delay) {
let timerId = null;
let running = false;
const loop = async () => {
const start = performance.now();
await task();
const elapsed = performance.now() - start;
const nextDelay = Math.max(0, delay - elapsed);
if (running) {
timerId = setTimeout(loop, nextDelay);
}
};
return {
start() {
if (!running) {
running = true;
loop();
}
},
stop() {
running = false;
if (timerId) {
clearTimeout(timerId);
}
},
setDelay(newDelay) {
delay = newDelay;
},
};
}
// example usage
const smartInterval = createInterval(tick, 1000);
smartInterval.start();
smartInterval.stop();
smartInterval.setDelay(500);
Final Thoughts
setInterval
is easy, but it's often too blunt an instrument for real-world timing needs. If your task is asynchronous, long-running, or dynamically timed, it's worth reaching for a smarter solution.
By building your own interval logic using setTimeout
, you avoid overlapping calls, gain precise control, ensure better performance, and it gives you the control you didn't know you were missing.