JavaScriptintermediate

Callbacks & Higher-Order Functions — The Complete Guide

Master callbacks, higher-order functions, function composition, currying, and practical patterns like debounce and throttle. Build cleaner, more reusable JavaScript.

13 min read·Published Mar 11, 2026
callbackshigher-order-functionsfunctional-programmingjavascript

Functions Are Values

In JavaScript, functions are first-class citizens. This means they can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures — just like numbers or strings.

// Assign to a variable
const greet = function (name) {
  return `Hello, ${name}!`;
};

// Store in an array
const operations = [
  (x) => x + 1,
  (x) => x * 2,
  (x) => x ** 2,
];

// Store in an object
const math = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};

// Pass as argument
console.log([3, 1, 2].sort((a, b) => a - b)); // [1, 2, 3]

This property of functions is what makes callbacks and higher-order functions possible.

What Is a Callback?

A callback is a function passed as an argument to another function, to be called (or "called back") at a later time.

function fetchData(url, onSuccess, onError) {
  // simulate async operation
  setTimeout(() => {
    if (url.startsWith('https')) {
      onSuccess({ data: 'some data' }); // callback on success
    } else {
      onError(new Error('Invalid URL'));  // callback on error
    }
  }, 1000);
}

// Passing callbacks
fetchData(
  'https://api.example.com',
  (result) => console.log('Got:', result),
  (error) => console.error('Failed:', error.message)
);

Synchronous Callbacks

Not all callbacks are async. Many are called immediately.

// Array methods use synchronous callbacks
const numbers = [1, 2, 3, 4, 5];

// forEach — callback runs for each element
numbers.forEach((num) => {
  console.log(num * 2);
});
// 2, 4, 6, 8, 10

// sort — callback compares elements
const sorted = [3, 1, 4, 1, 5].sort((a, b) => a - b);
console.log(sorted); // [1, 1, 3, 4, 5]

// String replace — callback transforms matches
'hello world'.replace(/\b\w/g, (char) => char.toUpperCase());
// 'Hello World'

Asynchronous Callbacks

Async callbacks run after some operation completes (network request, timer, file read, etc.).

// setTimeout — callback runs after delay
setTimeout(() => {
  console.log('Runs after 1 second');
}, 1000);

// Event listener — callback runs when event fires
document.addEventListener('click', (event) => {
  console.log(`Clicked at ${event.clientX}, ${event.clientY}`);
});

// Node.js fs — callback runs when file is read
const fs = require('fs');
fs.readFile('data.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Read failed:', err.message);
    return;
  }
  console.log('File contents:', data);
});

Callback Execution Timeline

Synchronous callback:

  main()  ─────> forEach() ─────> callback() ─────> next line
                                  runs immediately

Asynchronous callback:

  main()  ─────> setTimeout(callback, 1000) ─────> next line
                       |
                       └──── 1000ms later ──── callback() runs

Inversion of Control

When you pass a callback, you hand over control of when and how it gets called. This is called inversion of control — the calling code decides the execution details.

// You control when this runs
processData();

// The library controls when this runs
thirdPartyLib.onReady(() => {
  processData(); // you trust the library to call this correctly
});

The Trust Problem

With callbacks, you trust the receiver to:

  1. Call the callback the right number of times (once, not zero, not five)
  2. Pass the correct arguments
  3. Call it at the right time
  4. Handle errors properly
// What if the library calls your callback multiple times?
paymentService.charge(amount, () => {
  sendConfirmationEmail(); // might send 3 emails if called 3 times
  updateDatabase();        // might corrupt data
});

This trust problem is one reason Promises and async/await were created — they provide guarantees that callbacks cannot.

What Is a Higher-Order Function?

A higher-order function (HOF) is a function that either:

  1. Takes a function as an argument (accepts a callback), or
  2. Returns a function, or
  3. Both
// Takes a function as argument
function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    action(i);
  }
}

repeat(3, (i) => console.log(`Iteration ${i}`));
// Iteration 0
// Iteration 1
// Iteration 2

// Returns a function
function multiplier(factor) {
  return (number) => number * factor;
}

const double = multiplier(2);
const triple = multiplier(3);
console.log(double(5));  // 10
console.log(triple(5));  // 15

// Both: takes and returns a function
function logging(fn) {
  return function (...args) {
    console.log(`Calling with args: ${args}`);
    const result = fn(...args);
    console.log(`Result: ${result}`);
    return result;
  };
}

const loggedAdd = logging((a, b) => a + b);
loggedAdd(2, 3);
// Calling with args: 2,3
// Result: 5

map, filter, reduce — The Core HOFs

These three array methods are the foundation of functional programming in JavaScript.

const users = [
  { name: 'Alice', age: 28, active: true },
  { name: 'Bob', age: 35, active: false },
  { name: 'Charlie', age: 22, active: true },
  { name: 'Diana', age: 31, active: true },
];

// map — transform each element
const names = users.map((user) => user.name);
console.log(names); // ['Alice', 'Bob', 'Charlie', 'Diana']

// filter — keep elements that match
const activeUsers = users.filter((user) => user.active);
console.log(activeUsers.length); // 3

// reduce — combine elements into single value
const totalAge = users.reduce((sum, user) => sum + user.age, 0);
console.log(totalAge); // 116

// Chain them together
const activeNames = users
  .filter((user) => user.active)
  .map((user) => user.name)
  .join(', ');
console.log(activeNames); // 'Alice, Charlie, Diana'

Building Your Own HOFs

// Custom filter
function myFilter(array, predicate) {
  const result = [];
  for (const item of array) {
    if (predicate(item)) {
      result.push(item);
    }
  }
  return result;
}

const evens = myFilter([1, 2, 3, 4, 5], (n) => n % 2 === 0);
console.log(evens); // [2, 4]

// Custom map
function myMap(array, transform) {
  const result = [];
  for (const item of array) {
    result.push(transform(item));
  }
  return result;
}

const doubled = myMap([1, 2, 3], (n) => n * 2);
console.log(doubled); // [2, 4, 6]

// Custom reduce
function myReduce(array, reducer, initialValue) {
  let accumulator = initialValue;
  for (const item of array) {
    accumulator = reducer(accumulator, item);
  }
  return accumulator;
}

const sum = myReduce([1, 2, 3, 4], (acc, n) => acc + n, 0);
console.log(sum); // 10

Function Composition

Function composition combines simple functions to build more complex ones. The output of one function becomes the input of the next.

// Without composition — nested calls
const result = toUpperCase(trim(removeSpecialChars(input)));

// With composition — readable pipeline
const sanitize = compose(toUpperCase, trim, removeSpecialChars);
const result = sanitize(input);

compose and pipe

// compose — right to left (mathematical convention)
function compose(...fns) {
  return (value) => fns.reduceRight((acc, fn) => fn(acc), value);
}

// pipe — left to right (reading order)
function pipe(...fns) {
  return (value) => fns.reduce((acc, fn) => fn(acc), value);
}

// Example functions
const addOne = (x) => x + 1;
const double = (x) => x * 2;
const square = (x) => x * x;

// compose: square(double(addOne(3))) = square(double(4)) = square(8) = 64
const composed = compose(square, double, addOne);
console.log(composed(3)); // 64

// pipe: addOne(3) = 4, double(4) = 8, square(8) = 64
const piped = pipe(addOne, double, square);
console.log(piped(3)); // 64

Practical Composition

const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);

// String processing pipeline
const slugify = pipe(
  (str) => str.toLowerCase(),
  (str) => str.trim(),
  (str) => str.replace(/[^\w\s-]/g, ''),
  (str) => str.replace(/\s+/g, '-'),
  (str) => str.replace(/-+/g, '-'),
);

console.log(slugify('  Hello World! This is a Test  '));
// 'hello-world-this-is-a-test'

// Data processing pipeline
const processUsers = pipe(
  (users) => users.filter((u) => u.active),
  (users) => users.map((u) => ({ ...u, name: u.name.toUpperCase() })),
  (users) => users.sort((a, b) => a.age - b.age),
);

const result = processUsers([
  { name: 'Charlie', age: 22, active: true },
  { name: 'Alice', age: 28, active: true },
  { name: 'Bob', age: 35, active: false },
]);
// [{ name: 'CHARLIE', age: 22 }, { name: 'ALICE', age: 28 }]

Currying

Currying transforms a function that takes multiple arguments into a series of functions that each take one argument.

// Normal function
function add(a, b) {
  return a + b;
}
add(2, 3); // 5

// Curried version
function curriedAdd(a) {
  return function (b) {
    return a + b;
  };
}
curriedAdd(2)(3); // 5

// Arrow function syntax
const curriedAdd = (a) => (b) => a + b;
curriedAdd(2)(3); // 5

Why Curry?

Currying lets you create specialized functions from general ones.

// General formatter
const format = (prefix) => (suffix) => (value) =>
  `${prefix}${value}${suffix}`;

// Specialized formatters
const wrapInParens = format('(')(')');
const wrapInBrackets = format('[')(']');
const addDollar = format('$')('');

console.log(wrapInParens('hello'));   // (hello)
console.log(wrapInBrackets('hello')); // [hello]
console.log(addDollar('9.99'));       // $9.99

// Combine with map
const prices = [9.99, 24.50, 3.75];
console.log(prices.map(addDollar));
// ['$9.99', '$24.5', '$3.75']

Partial Application

Partial application is related to currying but is more flexible — you fix some arguments and leave the rest open.

// Manual partial application
function partial(fn, ...fixedArgs) {
  return function (...remainingArgs) {
    return fn(...fixedArgs, ...remainingArgs);
  };
}

function greet(greeting, name) {
  return `${greeting}, ${name}!`;
}

const sayHello = partial(greet, 'Hello');
const sayHi = partial(greet, 'Hi');

console.log(sayHello('Alice')); // Hello, Alice!
console.log(sayHi('Bob'));      // Hi, Bob!

// Using bind for partial application
const sayHey = greet.bind(null, 'Hey');
console.log(sayHey('Charlie')); // Hey, Charlie!

Generic Curry Utility

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    }
    return function (...moreArgs) {
      return curried(...args, ...moreArgs);
    };
  };
}

// Usage
const add = curry((a, b, c) => a + b + c);

// All these work
add(1, 2, 3);    // 6
add(1)(2)(3);     // 6
add(1, 2)(3);     // 6
add(1)(2, 3);     // 6

// Create specialized versions
const add10 = add(10);
const add10and20 = add(10, 20);

console.log(add10(5, 3));     // 18
console.log(add10and20(30));  // 60

Currying vs Partial Application

Technique              What It Does                      Example
─────────────────────────────────────────────────────────────────────────────
Currying               Converts f(a, b, c) into          f(a)(b)(c)
                       f(a)(b)(c)

Partial Application    Fixes some arguments,             f(a, b) -> g(c)
                       returns function for rest         where g(c) = f(a, b, c)

Practical Patterns

Debounce

Debounce delays execution until a pause in calls. Useful for search inputs, window resize, etc.

function debounce(fn, delay) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

// Usage: search input
const searchInput = document.querySelector('#search');
const search = debounce((query) => {
  console.log(`Searching for: ${query}`);
  // fetch(`/api/search?q=${query}`)...
}, 300);

searchInput.addEventListener('input', (e) => {
  search(e.target.value);
});

How debounce works:

User types: H  e  l  l  o
             |  |  |  |  |
             v  v  v  v  v
Debounce:   [--X--X--X--X--wait 300ms--] -> search("Hello")

Without debounce: 5 API calls (H, He, Hel, Hell, Hello)
With debounce:    1 API call  (Hello)

Throttle

Throttle ensures a function runs at most once per time interval. Useful for scroll handlers, mouse move, etc.

function throttle(fn, interval) {
  let lastCallTime = 0;

  return function (...args) {
    const now = Date.now();
    if (now - lastCallTime >= interval) {
      lastCallTime = now;
      fn.apply(this, args);
    }
  };
}

// Usage: scroll handler
const onScroll = throttle(() => {
  const scrollY = window.scrollY;
  console.log(`Scroll position: ${scrollY}`);
  updateProgressBar(scrollY);
}, 100);

window.addEventListener('scroll', onScroll);

How throttle works:

Events:    * * * * * * * * * * * * (rapid scroll events)
           |                       |
Throttle:  [execute]---100ms---[execute]---100ms---[execute]

Without throttle: 12 handler calls
With throttle:    3 handler calls (one per 100ms)

Debounce vs Throttle

Pattern      When It Fires             Best For
──────────────────────────────────────────────────────────────
Debounce     After activity stops      Search input, form validation,
             (waits for pause)         window resize end

Throttle     At regular intervals      Scroll handlers, mouse move,
             during activity           game loops, rate limiting

Memoize

Cache function results based on arguments. Covered in depth in the closures article, but here is the HOF perspective:

function memoize(fn) {
  const cache = new Map();

  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

// Fibonacci — exponential time without memoization
const fib = memoize(function (n) {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});

console.log(fib(50)); // 12586269025 (instant with memoization)

once

Ensure a function can only be called one time.

function once(fn) {
  let called = false;
  let result;

  return function (...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}

const initialize = once(() => {
  console.log('Initializing...');
  return { ready: true };
});

initialize(); // Initializing... -> { ready: true }
initialize(); // { ready: true } (no log, returns cached result)
initialize(); // { ready: true }

after

Create a function that only runs after being called n times.

function after(n, fn) {
  let count = 0;
  return function (...args) {
    count++;
    if (count >= n) {
      return fn.apply(this, args);
    }
  };
}

// Run callback only after all 3 API calls complete
const allDone = after(3, () => {
  console.log('All data loaded');
  renderDashboard();
});

fetchUsers().then(allDone);
fetchOrders().then(allDone);
fetchProducts().then(allDone);
// "All data loaded" prints only after all three resolve

Callback Hell and Solutions

When callbacks depend on each other, you get deeply nested code known as "callback hell" or the "pyramid of doom."

// Callback hell
getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      getShippingInfo(details.shippingId, (shipping) => {
        displayDashboard(user, orders, details, shipping);
      });
    });
  });
});

Solution 1: Named Functions

function displayUserDashboard(userId) {
  getUser(userId, handleUser);
}

function handleUser(user) {
  getOrders(user.id, (orders) => handleOrders(user, orders));
}

function handleOrders(user, orders) {
  getOrderDetails(orders[0].id, (details) =>
    handleDetails(user, orders, details)
  );
}

function handleDetails(user, orders, details) {
  getShippingInfo(details.shippingId, (shipping) =>
    displayDashboard(user, orders, details, shipping)
  );
}

Solution 2: Promises (Modern Standard)

getUser(userId)
  .then((user) => getOrders(user.id).then((orders) => ({ user, orders })))
  .then(({ user, orders }) =>
    getOrderDetails(orders[0].id).then((details) => ({ user, orders, details }))
  )
  .then(({ user, orders, details }) =>
    getShippingInfo(details.shippingId).then((shipping) =>
      displayDashboard(user, orders, details, shipping)
    )
  );

Solution 3: async/await (Best)

async function displayUserDashboard(userId) {
  const user = await getUser(userId);
  const orders = await getOrders(user.id);
  const details = await getOrderDetails(orders[0].id);
  const shipping = await getShippingInfo(details.shippingId);

  displayDashboard(user, orders, details, shipping);
}

Higher-Order Functions at a Glance

HOF                 Takes fn?   Returns fn?   Purpose
──────────────────────────────────────────────────────────────
Array.map           yes         no            transform elements
Array.filter        yes         no            select elements
Array.reduce        yes         no            combine elements
Array.sort          yes         no            order elements
Array.forEach       yes         no            side effects per element
setTimeout          yes         no            delayed execution
addEventListener    yes         no            event handling
compose/pipe        yes         yes           chain transformations
curry               yes         yes           partial argument binding
debounce            yes         yes           delay until pause
throttle            yes         yes           limit call frequency
memoize             yes         yes           cache results
once                yes         yes           single execution

Key Takeaways

  • Functions are first-class values in JavaScript — they can be passed, returned, and stored like any other value
  • A callback is a function passed to another function, to be invoked later
  • A higher-order function takes functions as arguments, returns functions, or both
  • Composition (pipe/compose) lets you build complex transformations from simple functions
  • Currying converts multi-argument functions into chains of single-argument functions, enabling specialization
  • Partial application fixes some arguments upfront, creating a more specific function
  • Debounce waits for activity to stop before executing — use for search inputs
  • Throttle limits execution to at most once per interval — use for scroll and resize handlers
  • Memoize caches results to avoid redundant computation
  • Callback hell is solved by named functions, Promises, or async/await — prefer the latter in modern code
  • Understanding HOFs unlocks the functional programming paradigm in JavaScript

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles