Count Working Days in JavaScript With UK Bank Holidays
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
A debounce function in JavaScript wraps another function so that it only runs once the calls have stopped for a set time, such as 300 milliseconds. Every new call cancels the previous timer with clearTimeout() and starts a fresh one with setTimeout(), so a burst of ten keystrokes turns into one search instead of ten. Below is a short debounce function example you can copy, the output from actually running it, a leading-edge version for buttons, and a live search box that uses it.
I first needed this on my portfolio when I added a search box to my blog list. Every key press was filtering the list and re-drawing it, which felt jumpy on my old phone. Debouncing fixed it in ten lines with no library. All the code below was run with Node 20 and in headless Chrome, and the outputs are copied straight from the terminal.
Here's the whole thing. It takes the function you want to delay (fn) and how long to wait in milliseconds (wait), and gives you back a new function to call instead:
// debounce.js: run fn only after calls stop for `wait` ms
function debounce(fn, wait = 300) {
let timer = null;
function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
}
debounced.cancel = () => {
clearTimeout(timer);
timer = null;
};
return debounced;
}
module.exports = { debounce };
Three small details make this version safer than the one-liners you often see:
function for debounced, not an arrow function, so this is whatever object you called it on. That matters for methods, as you'll see below....args, and the last call's arguments win. For a search box, that's the final text the user typed.cancel() method, so you can stop a pending call, for example when a modal closes or a component is removed.The trick is a closure. The timer variable lives inside debounce(), and every call to the returned function shares that one variable. On each call:
clearTimeout(timer) cancels the call that was waiting, if there was one.setTimeout() schedules a new call wait milliseconds from now.wait ms, the timer fires and fn runs once with the latest arguments.If closures still feel a bit magic, it helps to know that setTimeout() doesn't block anything. It just puts a callback on a queue, the same idea I explain in my JavaScript async/await tutorial for beginners. MDN's page on setTimeout() is worth a read too, especially the part about the returned timer ID.
To see it working without a browser, I faked someone typing "css", pausing, and then typing " grid". Each "key press" is a setTimeout at a set time, and the log shows the time rounded to 100 ms:
const { debounce } = require("./debounce");
const start = Date.now();
const log = (msg) => console.log(`${String(Math.round((Date.now() - start) / 100) * 100).padStart(4)}ms ${msg}`);
const search = debounce((query) => log(`search for "${query}"`), 300);
// Pretend someone types "css" quickly, pauses, then types "grid"
const keys = [
[0, "c"], [100, "cs"], [200, "css"],
[800, "css g"], [900, "css gr"], [1000, "css gri"], [1100, "css grid"],
];
for (const [at, text] of keys) {
setTimeout(() => { log(`typed "${text}"`); search(text); }, at);
}
// Output:
// 0ms typed "c"
// 100ms typed "cs"
// 200ms typed "css"
// 500ms search for "css"
// 800ms typed "css g"
// 900ms typed "css gr"
// 1000ms typed "css gri"
// 1100ms typed "css grid"
// 1400ms search for "css grid"
Seven calls went in, but the search only ran twice: 300 ms after "css" and 300 ms after "css grid". The calls in between were cancelled because a newer one replaced them before the timer ran out. That's exactly what you want for a search box, an autosave, or a resize handler.
Here's the browser version I tested. It filters a small list of post titles, but the showResults function could just as easily call an API. The debounce-browser.js file is the same function as above without the module.exports line:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Debounced search</title>
</head>
<body>
<label for="q">Search tips</label>
<input id="q" type="search" autocomplete="off">
<ul id="results"></ul>
<script src="debounce-browser.js"></script>
<script>
const tips = ["CSS flexbox vs grid", "CSS position sticky", "JavaScript async/await", "JavaScript map and filter", "PHP PDO"];
const input = document.getElementById("q");
const list = document.getElementById("results");
function showResults(query) {
const q = query.trim().toLowerCase();
const matches = q ? tips.filter((t) => t.toLowerCase().includes(q)) : [];
list.replaceChildren(...matches.map((t) => {
const li = document.createElement("li");
li.textContent = t;
return li;
}));
console.log(`searched "${query}": ${matches.length} result(s)`);
}
const debouncedSearch = debounce(showResults, 300);
input.addEventListener("input", (e) => debouncedSearch(e.target.value));
</script>
</body>
</html>
I ran it in headless Chrome and sent three input events 50 ms apart ("c", "cs", "css"). The console only logged one search, and the list showed the right results:
searched "css": 2 result(s)
list: CSS flexbox vs grid | CSS position sticky
Notice that the debounced function is created once, outside the event listener. A common bug is writing input.addEventListener("input", (e) => debounce(showResults, 300)(e.target.value)), which makes a brand new debounce with its own timer on every key press, so nothing is ever cancelled. If you build forms like this, my guide to HTML form input types and validation covers the type="search" input and friends.
this and cancelling a callBecause debounced is a regular function that calls fn.apply(this, args), you can use it as a method and this still points at the object. The cancel() method stops a pending call completely:
const { debounce } = require("./debounce");
const form = {
name: "contact form",
autosave: debounce(function (field) {
console.log(`${this.name}: autosaved ${field}`);
}, 200),
};
form.autosave("email");
form.autosave("message"); // only this one runs, and `this` is still `form`
const ping = debounce(() => console.log("this should never print"), 200);
ping();
ping.cancel(); // e.g. the user left the page before the timer fired
setTimeout(() => console.log("done"), 400);
// Output:
// contact form: autosaved message
// done
Only the last autosave ran, it could read this.name, and the cancelled ping never printed. If you had used an arrow function inside the wrapper and called fn(...args), this.name would be undefined.
The version above is a trailing debounce: it waits for the burst to end. For a "Save" or "Pay" button you usually want the opposite. The first click should work straight away, and the extra nervous clicks should be ignored. That's a leading-edge debounce:
// Leading-edge debounce: run on the FIRST call, ignore the rest of the burst
function debounceLeading(fn, wait = 300) {
let timer = null;
return function (...args) {
if (timer === null) fn.apply(this, args);
clearTimeout(timer);
timer = setTimeout(() => { timer = null; }, wait);
};
}
const start = Date.now();
const save = debounceLeading(() => {
console.log(`saved at ~${Math.round((Date.now() - start) / 100) * 100}ms`);
}, 300);
// Five nervous clicks, 50ms apart, then one more click a second later
[0, 50, 100, 150, 200, 1200].forEach((at) => setTimeout(save, at));
// Output:
// saved at ~0ms
// saved at ~1200ms
Five clicks in 200 ms caused one save, at the very start. A click a second later counted again, because the 300 ms quiet period had passed. For a form that sends data to a server, I'd still disable the button while the request is in flight, but this stops accidental double submits.
People mix these up, so here's the short version:
A quick test: if the user would be annoyed that nothing updates until they stop, you want throttle. If they'd be annoyed by flicker or wasted requests, you want debounce.
There's no magic number, but these are the values I've settled on after trying them on my own projects:
useMemo or useRef. A re-render creates a new function, which has the same effect as the mistake above.debounced() returns undefined. Put the result in the DOM or state inside fn, or wrap the timer in a Promise if you really need one.cancel() so the timer doesn't run code against something that no longer exists.It's a wrapper that delays running a function until it hasn't been called for a set time. Each call resets the timer, so a rapid burst of calls results in a single run with the most recent arguments.
Collect them with rest parameters (...args) in the wrapper and pass them on with fn.apply(this, args) when the timer fires, as in the example above. The arguments from the last call are the ones used.
If lodash is already in your project, _.debounce is well tested and supports leading, trailing and maxWait options. If not, the ten-line version here covers most cases without adding a dependency.
setTimeout() just delays one call. Debounce uses setTimeout() plus clearTimeout() so that each new call cancels the one before it, which is what turns many calls into one.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.
How UK student loan repayments are worked out in 2026/27: per-period thresholds, 9% over, rounding down, two plans at once and a floating-point trap.