Practical Use Cases for JS Closures in Real Apps
Learn practical closure patterns used in production JavaScript. Covers debounce, throttle, currying, partial application, once functions, and state machines using closures.
Closures are more than a theory topic. Every debounce function, every throttle utility, every memoized computation, and every module in your codebase relies on closures. This guide covers real patterns you will use in production code, with full working examples.
Debounce: Delay Until Idle
Debounce delays a function call until the user stops triggering it. Each new trigger resets the timer. This is essential for search inputs, window resize handlers, and form validation:
function debounce(fn, delay) {
let timerId = null; // Private via closure
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// Usage: Only fires after user stops typing for 300ms
const searchInput = document.querySelector("#search");
const handleSearch = debounce((event) => {
console.log("Searching for:", event.target.value);
// Fetch search results here
}, 300);
searchInput.addEventListener("input", handleSearch);With Immediate Option
Some debounce implementations fire immediately on the first trigger and then wait for idle:
function debounce(fn, delay, immediate = false) {
let timerId = null;
return function (...args) {
const callNow = immediate && timerId === null;
clearTimeout(timerId);
timerId = setTimeout(() => {
timerId = null;
if (!immediate) fn.apply(this, args);
}, delay);
if (callNow) fn.apply(this, args);
};
}Throttle: Limit Execution Rate
Throttle ensures a function runs at most once within a given time window. Use it for scroll handlers, mouse move tracking, and API rate limiting:
function throttle(fn, limit) {
let waiting = false;
let lastArgs = null;
return function (...args) {
if (waiting) {
lastArgs = args; // Save the latest call
return;
}
fn.apply(this, args); // Execute immediately
waiting = true;
setTimeout(() => {
waiting = false;
if (lastArgs) {
fn.apply(this, lastArgs); // Run the trailing call
lastArgs = null;
}
}, limit);
};
}
// Usage: Fires at most once every 200ms during scroll
window.addEventListener(
"scroll",
throttle(() => {
console.log("Scroll position:", window.scrollY);
}, 200)
);Debounce vs Throttle Comparison
| Feature | Debounce | Throttle |
|---|---|---|
| Fires when | User stops triggering | At fixed intervals during triggering |
| Best for | Search input, form validation, resize end | Scroll tracking, mouse moves, rate limiting |
| Missed calls | Discarded (only the last matters) | Saved as trailing call |
| First trigger | Delayed (unless immediate: true) | Immediate |
Once: Run Exactly One Time
A once wrapper ensures a function only executes on the first call. Subsequent calls return the first result:
function once(fn) {
let called = false;
let result;
return function (...args) {
if (called) return result;
called = true;
result = fn.apply(this, args);
return result;
};
}
const initialize = once(() => {
console.log("App initialized");
return { ready: true };
});
initialize(); // "App initialized" -> { ready: true }
initialize(); // Returns { ready: true } without logging
initialize(); // Same cached resultWith Reset Capability
function onceWithReset(fn) {
let called = false;
let result;
function wrapper(...args) {
if (called) return result;
called = true;
result = fn.apply(this, args);
return result;
}
wrapper.reset = () => {
called = false;
result = undefined;
};
return wrapper;
}Currying: One Argument at a Time
Currying transforms a function that takes multiple arguments into a chain of functions that each take one argument. The closure stores previously provided arguments:
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
// Return a new function that waits for more arguments
return function (...moreArgs) {
return curried.apply(this, [...args, ...moreArgs]);
};
};
}
// A function that takes 3 arguments
function addThree(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(addThree);
// All these work:
console.log(curriedAdd(1, 2, 3)); // 6
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6Practical Currying Example
const formatLog = curry((level, module, message) => {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${level}] [${module}] ${message}`;
});
// Pre-configure loggers for different contexts
const errorLog = formatLog("ERROR");
const authError = errorLog("AUTH");
const dbError = errorLog("DB");
console.log(authError("Invalid token"));
// [2026-03-05T22:10:00.000Z] [ERROR] [AUTH] Invalid token
console.log(dbError("Connection timeout"));
// [2026-03-05T22:10:00.000Z] [ERROR] [DB] Connection timeoutPartial Application: Pre-Fill Some Arguments
Partial application fixes some arguments and returns a function that takes the rest. Unlike currying, it fills multiple arguments at once:
function partial(fn, ...presetArgs) {
return function (...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
function request(method, baseURL, endpoint, data) {
console.log(`${method} ${baseURL}${endpoint}`, data || "");
// In a real app: return fetch(...)
}
const apiRequest = partial(request, "GET", "https://api.example.com");
apiRequest("/users"); // GET https://api.example.com/users
apiRequest("/posts", { limit: 10 }); // GET https://api.example.com/posts { limit: 10 }
const postToAPI = partial(request, "POST", "https://api.example.com");
postToAPI("/users", { name: "Alice" }); // POST https://api.example.com/users { name: "Alice" }Stateful Iterators
Closures allow you to create custom iterators that maintain their position:
function createRangeIterator(start, end, step = 1) {
let current = start;
return {
next() {
if (current > end) {
return { done: true, value: undefined };
}
const value = current;
current += step;
return { done: false, value };
},
reset() {
current = start;
},
peek() {
return current <= end ? current : undefined;
}
};
}
const iter = createRangeIterator(1, 5);
console.log(iter.next()); // { done: false, value: 1 }
console.log(iter.next()); // { done: false, value: 2 }
console.log(iter.peek()); // 3
console.log(iter.next()); // { done: false, value: 3 }State Machines
Closures are perfect for simple state machines where the current state is private:
function createTrafficLight() {
const transitions = {
green: "yellow",
yellow: "red",
red: "green"
};
const durations = {
green: 30000,
yellow: 5000,
red: 20000
};
let current = "red";
let timerId = null;
return {
getState() {
return current;
},
next() {
current = transitions[current];
return current;
},
startAuto(onChange) {
const tick = () => {
this.next();
if (onChange) onChange(current);
timerId = setTimeout(tick, durations[current]);
};
timerId = setTimeout(tick, durations[current]);
},
stop() {
clearTimeout(timerId);
timerId = null;
}
};
}
const light = createTrafficLight();
console.log(light.getState()); // "red"
light.next();
console.log(light.getState()); // "green"Middleware Chain
Build middleware pipelines (like Express.js) using closures:
function createPipeline() {
const middlewares = [];
return {
use(fn) {
middlewares.push(fn);
return this; // Enable chaining
},
execute(context) {
let index = 0;
function next() {
if (index >= middlewares.length) return;
const middleware = middlewares[index++];
middleware(context, next);
}
next();
return context;
}
};
}
const pipeline = createPipeline();
pipeline
.use((ctx, next) => {
ctx.timestamp = Date.now();
next();
})
.use((ctx, next) => {
ctx.user = ctx.user || "anonymous";
next();
})
.use((ctx, next) => {
console.log(`[${ctx.timestamp}] User: ${ctx.user} -> ${ctx.path}`);
next();
});
pipeline.execute({ path: "/home", user: "Alice" });Event Emitter
A minimal pub/sub system using closures for private subscriber storage:
function createEventEmitter() {
const listeners = new Map(); // Private
return {
on(event, callback) {
if (!listeners.has(event)) {
listeners.set(event, []);
}
listeners.get(event).push(callback);
return this;
},
off(event, callback) {
if (!listeners.has(event)) return;
const fns = listeners.get(event);
listeners.set(event, fns.filter((fn) => fn !== callback));
return this;
},
emit(event, ...args) {
if (!listeners.has(event)) return;
listeners.get(event).forEach((fn) => fn(...args));
},
once(event, callback) {
const wrapper = (...args) => {
callback(...args);
this.off(event, wrapper);
};
this.on(event, wrapper);
return this;
}
};
}
const emitter = createEventEmitter();
emitter.on("message", (text) => console.log("Got:", text));
emitter.once("connect", () => console.log("Connected!"));
emitter.emit("connect"); // "Connected!"
emitter.emit("connect"); // (nothing - was once)
emitter.emit("message", "Hello"); // "Got: Hello"Pattern Summary Table
| Pattern | Closed-Over State | Use Case |
|---|---|---|
| Debounce | timerId | Search input, resize handler |
| Throttle | waiting, lastArgs | Scroll, mouse move, rate limiting |
| Once | called, result | Initialization, one-time setup |
| Curry | args accumulator | Logging, config, reusable pipelines |
| Partial | presetArgs | API clients, pre-configured functions |
| Iterator | current position | Custom iteration, pagination |
| State Machine | current state | UI flows, traffic lights, game states |
| Event Emitter | listeners map | Pub/sub, event systems |
Rune AI
Key Insights
- Debounce delays execution until input stops: It clears and resets a timer on each call, so the wrapped function only fires when the user pauses
- Throttle limits execution frequency: It guarantees the function runs at most once per time window, saving a trailing call for the latest arguments
- Once captures a single result: The first call executes and caches the result, all subsequent calls return the cache without executing again
- Currying and partial application pre-fill arguments: Both store provided arguments in closure scope and return a function that waits for the remaining ones
- State machines use closures for private transitions: The current state and transition map live inside the closure, exposing only controlled methods to the outside
Frequently Asked Questions
What is the most common real-world use of closures?
How does debounce work under the hood?
What is the difference between currying and partial application?
Can closures replace classes for managing state?
Are these closure patterns already built into JavaScript?
Conclusion
Closures power the most important utility patterns in JavaScript. From debounce and throttle to currying, once-wrappers, state machines, and event emitters, the pattern is always the same: a function returns another function that captures private variables in its scope.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.