JavaScript Zero to Advanced #10: Event Loop, Error Handling, Debounce/Throttle, Generators & Memoization
You made it to the final part! You can now write functions, work with data, manipulate the DOM, handle async code and organize programs with classes and modules. This last part covers the concepts that make the difference between code that works and code that is robust and fast: the event loop, error handling patterns, debounce and throttle, generators, and memoization.
The event loop: how JavaScript schedules work
JavaScript runs on a single thread, so how can it handle timers, network requests and clicks "at the same time"? The answer is the event loop. In simplified form:
- The call stack runs your synchronous code until it is empty.
- Then all microtasks run: Promise callbacks (
then,catch, code afterawait) andqueueMicrotask. - Then one macrotask runs: a
setTimeout/setIntervalcallback, an I/O callback or a UI event. After it, the microtask queue is drained again, and the browser may repaint the screen. - Repeat.
Try to predict the output before reading it:
console.log("1. sync: start")
setTimeout(() => console.log("6. macrotask: setTimeout 0"), 0)
Promise.resolve()
.then(() => console.log("3. microtask: then"))
.then(() => console.log("5. microtask: chained then"))
queueMicrotask(() => console.log("4. microtask: queueMicrotask"))
console.log("2. sync: end")
Output:
1. sync: start
2. sync: end
3. microtask: then
4. microtask: queueMicrotask
5. microtask: chained then
6. macrotask: setTimeout 0
Even with a delay of 0, the setTimeout callback runs last, because all synchronous code and all microtasks come first. The chained then runs after queueMicrotask because it is only queued once the first then has finished.
Why blocking the stack is bad
While synchronous code runs, nothing else can happen: no timers, no clicks, no repaint. A heavy loop freezes the page:
const start = Date.now()
setTimeout(() => {
console.log(`Timer fired after ~${Math.round((Date.now() - start) / 100) * 100}ms`)
}, 0)
while (Date.now() - start < 500) {
// simulate 500ms of heavy synchronous work
}
console.log("Heavy work finished")
Output:
Heavy work finished
Timer fired after ~500ms
The timer asked for 0 ms but had to wait 500 ms. For heavy computations, split the work into chunks or move it to a Web Worker (browser) or a worker thread (Node.js).
Error handling patterns
In Part 8 we used try/catch. In larger apps you want errors that carry meaning, so the caller can decide what to do. Extend Error to create custom error classes:
class ValidationError extends Error {
constructor(field, message) {
super(message)
this.name = "ValidationError"
this.field = field
}
}
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`)
this.name = "NotFoundError"
}
}
function findUser(id) {
if (typeof id !== "number") throw new ValidationError("id", "id must be a number")
if (id !== 1) throw new NotFoundError(`User ${id}`)
return { id, name: "Budi" }
}
for (const id of [1, "abc", 7]) {
try {
console.log("Found:", findUser(id).name)
} catch (error) {
if (error instanceof ValidationError) {
console.log(`400 Bad input (${error.field}): ${error.message}`)
} else if (error instanceof NotFoundError) {
console.log(`404: ${error.message}`)
} else {
throw error // unknown error: do not swallow it
}
}
}
Output:
Found: Budi
400 Bad input (id): id must be a number
404: User 7 not found
When you catch a low-level error and throw a more meaningful one, keep the original with the cause option, so no debugging information is lost:
function loadConfig(text) {
try {
return JSON.parse(text)
} catch (error) {
throw new Error("Could not load config", { cause: error })
}
}
try {
loadConfig("{ invalid json")
} catch (error) {
console.log(error.message)
console.log("Caused by:", error.cause.name)
}
Output:
Could not load config
Caused by: SyntaxError
Retrying with exponential backoff
Network calls sometimes fail temporarily. A small helper can retry them, waiting longer after each failure:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function retry(task, attempts = 3, baseDelay = 100) {
for (let i = 1; i <= attempts; i++) {
try {
return await task()
} catch (error) {
console.log(`Attempt ${i} failed: ${error.message}`)
if (i === attempts) throw error
await wait(baseDelay * 2 ** (i - 1)) // 100ms, 200ms, 400ms...
}
}
}
let calls = 0
const flakyRequest = async () => {
calls++
if (calls < 3) throw new Error("Network glitch")
return "Data loaded"
}
console.log("Result:", await retry(flakyRequest))
Output:
Attempt 1 failed: Network glitch
Attempt 2 failed: Network glitch
Result: Data loaded
Finally, add a global safety net for errors nobody caught: window.addEventListener("unhandledrejection", ...) in the browser or process.on("unhandledRejection", ...) in Node.js. Log them to your monitoring tool; do not use them as normal control flow.
Debounce and throttle
Some events fire very often: typing, scrolling, resizing. Running expensive work (like an API search) on every single event wastes resources. Two classic techniques help:
- Debounce: wait until the events stop for a given time, then run once. Perfect for search boxes.
- Throttle: run at most once per time interval, no matter how many events arrive. Perfect for scroll and resize handlers.
function debounce(fn, delay) {
let timerId
return (...args) => {
clearTimeout(timerId)
timerId = setTimeout(() => fn(...args), delay)
}
}
const search = debounce((query) => console.log(`Searching for "${query}"`), 300)
// Simulate a user typing one character every 100ms...
const keystrokes = ["j", "ja", "jav", "java"]
keystrokes.forEach((text, i) => setTimeout(() => search(text), i * 100))
// ...then typing again later
setTimeout(() => search("javascript"), 1000)
Output:
Searching for "java"
Searching for "javascript"
Four keystrokes produced only one search, because each new call cancels the previous timer. Only when the user pauses for 300 ms does the search run. Notice how the debounced function uses a closure (Part 4) to remember timerId.
function throttle(fn, interval) {
let lastRun = 0
return (...args) => {
const now = Date.now()
if (now - lastRun >= interval) {
lastRun = now
fn(...args)
}
}
}
const onScroll = throttle((n) => console.log(`Handled scroll event #${n}`), 250)
// Simulate 10 scroll events, one every 100ms
let eventCount = 0
const intervalId = setInterval(() => {
eventCount++
onScroll(eventCount)
if (eventCount === 10) clearInterval(intervalId)
}, 100)
Output:
Handled scroll event #1
Handled scroll event #4
Handled scroll event #7
Handled scroll event #10
Ten events, only four handled: each one is the first event that arrives at least 250 ms after the previous handled event. In the browser you would use them like input.addEventListener("input", debounce(handler, 300)). Libraries such as Lodash provide battle-tested versions with extra options.
Generators
A generator function (function*) can pause with yield and resume later. Calling it returns an iterator; each next() runs the function until the next yield. Values are produced lazily, only when asked for, so a generator can even be infinite:
function* idGenerator(prefix) {
let id = 1
while (true) {
yield `${prefix}-${id++}`
}
}
const nextOrderId = idGenerator("ORD")
console.log(nextOrderId.next().value)
console.log(nextOrderId.next().value)
console.log(nextOrderId.next().value)
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) {
yield i
}
}
console.log([...range(0, 10, 3)])
const it = range(0, 2)
console.log(it.next())
console.log(it.next())
console.log(it.next())
Output:
ORD-1
ORD-2
ORD-3
[ 0, 3, 6, 9 ]
{ value: 0, done: false }
{ value: 1, done: false }
{ value: undefined, done: true }
Generators work with for...of and spread because they follow the iterator protocol. Async generators (async function*) combine this with Promises, which is great for paginated APIs:
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function* fetchPages(totalPages) {
for (let page = 1; page <= totalPages; page++) {
await wait(100) // pretend this is a network request
yield { page, items: [`item-${page}a`, `item-${page}b`] }
}
}
for await (const { page, items } of fetchPages(3)) {
console.log(`Page ${page}:`, items.join(", "))
}
Output:
Page 1: item-1a, item-1b
Page 2: item-2a, item-2b
Page 3: item-3a, item-3b
Memoization
Memoization caches the result of a function call, so calling it again with the same input returns the cached value instead of recomputing. It only makes sense for pure functions: same input, same output, no side effects.
function memoize(fn) {
const cache = new Map()
return (arg) => {
if (cache.has(arg)) return cache.get(arg)
const result = fn(arg)
cache.set(arg, result)
return result
}
}
let realCalls = 0
function slowSquare(n) {
realCalls++
return n * n
}
const fastSquare = memoize(slowSquare)
console.log(fastSquare(9), fastSquare(9), fastSquare(4), fastSquare(9))
console.log("Real calls:", realCalls)
Output:
81 81 16 81
Real calls: 2
The effect is dramatic for recursive algorithms that solve the same sub-problem many times, like Fibonacci:
let naiveCalls = 0
function fib(n) {
naiveCalls++
return n < 2 ? n : fib(n - 1) + fib(n - 2)
}
const memo = new Map()
let memoCalls = 0
function fibMemo(n) {
memoCalls++
if (n < 2) return n
if (memo.has(n)) return memo.get(n)
const result = fibMemo(n - 1) + fibMemo(n - 2)
memo.set(n, result)
return result
}
console.log(fib(25), "naive calls:", naiveCalls)
console.log(fibMemo(25), "memoized calls:", memoCalls)
Output:
75025 naive calls: 242785
75025 memoized calls: 49
Same answer, with 49 calls instead of 242,785: almost 5,000 times fewer. For functions with several arguments, build a key such as JSON.stringify(args). Remember that caches use memory, so limit their size (for example, an LRU cache) in long-running apps.
Tips and common mistakes
- Assuming
setTimeout(fn, 0)runs immediately. It runs after all synchronous code and microtasks. - Swallowing errors. An empty
catch {}hides bugs. Handle the errors you expect and rethrow the rest. - Creating a new debounced function on every render or event. Create it once and reuse it, or the timer is never shared and nothing is debounced.
- Memoizing impure functions. If the result depends on time, random numbers or external state, the cache returns stale data.
- Reusing a finished generator. Once it returns
done: true, it stays done. Call the generator function again for a fresh iterator.
Exercise
Build a "smart search" module: (1) a searchApi(query) function that simulates a request with a random delay and fails 30% of the time; (2) wrap it with retry (max 3 attempts); (3) memoize successful results by query so repeated searches are instant; (4) debounce the public search(query) function by 300 ms; (5) throw a custom ValidationError for queries shorter than 2 characters. Then predict, and verify, the order of logs when you call search five times in quick succession.
Summary
- The event loop runs synchronous code first, then all microtasks (Promises), then one macrotask (timers, events) at a time.
- Never block the main thread with long synchronous work.
- Use custom error classes,
cause, retries with backoff and global handlers for robust error handling. - Debounce waits for a pause; throttle limits the rate. Both rely on closures and timers.
- Generators produce values lazily with
yield; async generators pair nicely withfor await...of. - Memoization trades memory for speed, but only for pure functions.
Congratulations on finishing JavaScript Zero to Advanced! You now have a solid foundation. Good next steps are TypeScript for type safety, a front-end framework like React or Next.js, and building back-end APIs with Node.js. Most importantly: keep building small projects, because that is where everything you learned really sticks.
Related articles
PostgreSQL Zero to Advanced #8: Indexes & Performance
Read query plans with EXPLAIN ANALYZE and speed up a 500,000-row table with B-tree, composite, partial and GIN indexes.
PHP Zero to Advanced #8: Composer, Namespaces, PSR-4 Autoloading & Exceptions
Structure real PHP projects: organize code with namespaces, manage dependencies with Composer, autoload classes with PSR-4, and handle errors cleanly with exceptions.
Ruby on Rails Zero to Advanced #8: Forms, Strong Parameters & Full CRUD
Build a complete CRUD feature by hand with form_with and strong parameters, then compare it with what rails generate scaffold gives you for free.