Skip to content
Node.js interview 5 min read

Node.js Coding Challenges

Whiteboard and take-home interviews love a handful of “implement this utility from scratch” challenges because they reveal whether you truly understand closures, the event loop, and Promise mechanics — not just whether you can call a library. This page walks through five classics: a promisify helper, debounce and throttle, a minimal EventEmitter, an async concurrency pool, and a token-bucket rate limiter. Every solution is real, runnable modern Node.js (20/22 LTS) using ES modules, with the reasoning an interviewer wants to hear.

Implement promisify

Node’s callback convention is (err, result) => {} — error first, result second. promisify wraps such a function so it returns a Promise instead. The trick is to return a new function that forwards the original arguments, then appends a callback that resolves or rejects.

function promisify(fn) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      fn.call(this, ...args, (err, result) => {
        if (err) reject(err);
        else resolve(result);
      });
    });
  };
}

import { readFile } from 'node:fs';

const readFileAsync = promisify(readFile);
const text = await readFileAsync(import.meta.filename, 'utf8');
console.log(`Read ${text.length} characters`);

Output:

Read 612 characters

Node ships a battle-tested util.promisify for production. Reach for it via import { promisify } from 'node:util' — implement your own only when asked to in an interview.

Implement debounce and throttle

Both limit how often a function runs, but with opposite intent. Debounce waits until activity stops — it resets a timer on every call and only fires after a quiet period (great for search-as-you-type). Throttle guarantees the function runs at most once per interval, ignoring extra calls in between (great for scroll or resize handlers).

function debounce(fn, wait) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}
AspectDebounceThrottle
Fires whenCalls stop for wait msAt most once per interval
Resets timerOn every callNever
Typical useSearch input, autosaveScroll, resize, mousemove

The closure over timer (or last) is the whole point — each returned function keeps private state across invocations, which is exactly what interviewers probe for.

Implement a simple EventEmitter

A pub/sub emitter stores arrays of listeners keyed by event name. on subscribes, emit invokes every listener with the supplied arguments, and off removes one. once wraps a listener so it auto-removes after firing.

class EventEmitter {
  #events = new Map();

  on(event, listener) {
    if (!this.#events.has(event)) this.#events.set(event, []);
    this.#events.get(event).push(listener);
    return this;
  }

  off(event, listener) {
    const listeners = this.#events.get(event);
    if (listeners) this.#events.set(event, listeners.filter((l) => l !== listener));
    return this;
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener(...args);
    };
    return this.on(event, wrapper);
  }

  emit(event, ...args) {
    const listeners = this.#events.get(event) ?? [];
    listeners.forEach((l) => l(...args));
    return listeners.length > 0;
  }
}

const bus = new EventEmitter();
bus.once('login', (user) => console.log(`Welcome, ${user}`));
bus.emit('login', 'ada');
bus.emit('login', 'grace'); // ignored — once already fired

Output:

Welcome, ada

Mention that Node’s built-in events.EventEmitter is the real thing and powers streams, HTTP servers, and process signals.

Implement an async pool

Running Promise.all over 10,000 tasks fires them all at once and can exhaust memory, sockets, or API rate limits. An async pool caps concurrency: it keeps at most limit Promises in flight, starting a new task only as one finishes.

async function asyncPool(limit, items, iteratorFn) {
  const results = [];
  const executing = new Set();

  for (const item of items) {
    const p = Promise.resolve().then(() => iteratorFn(item));
    results.push(p);
    executing.add(p);
    p.finally(() => executing.delete(p));

    if (executing.size >= limit) {
      await Promise.race(executing);
    }
  }
  return Promise.all(results);
}

const delay = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
const out = await asyncPool(2, [1, 2, 3, 4], (n) => delay(100, n * 10));
console.log(out);

Output:

[ 10, 20, 30, 40 ]

The Promise.race(executing) line is the heart of it — it pauses the loop until any in-flight task settles, freeing a slot before the next task launches.

Implement a rate limiter

A token bucket allows bursts up to a capacity while enforcing an average rate. Tokens refill over time; each request consumes one. If the bucket is empty, the request waits. This is the algorithm behind most production API throttling.

class TokenBucket {
  constructor(capacity, refillPerSecond) {
    this.capacity = capacity;
    this.tokens = capacity;
    this.refillPerSecond = refillPerSecond;
    this.last = Date.now();
  }

  #refill() {
    const now = Date.now();
    const added = ((now - this.last) / 1000) * this.refillPerSecond;
    this.tokens = Math.min(this.capacity, this.tokens + added);
    this.last = now;
  }

  async acquire() {
    this.#refill();
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return;
    }
    const waitMs = ((1 - this.tokens) / this.refillPerSecond) * 1000;
    await new Promise((r) => setTimeout(r, waitMs));
    return this.acquire();
  }
}

const limiter = new TokenBucket(2, 1); // burst 2, then 1/sec
for (let i = 1; i <= 3; i++) {
  await limiter.acquire();
  console.log(`request ${i} at ${Date.now()}`);
}

The first two requests pass instantly (the initial burst), and the third waits roughly a second for a token to refill — demonstrating both burst tolerance and steady-state rate control.

Best Practices

  • State across calls belongs in a closure or private class field, never a module-level global that leaks between callers.
  • Always preserve this and forward all arguments with apply/spread so wrappers stay drop-in replacements.
  • In production, prefer the standard library: util.promisify, events.EventEmitter, and vetted packages over hand-rolled versions.
  • Cap concurrency for bulk async work — unbounded Promise.all is a common cause of socket and memory exhaustion.
  • Clean up timers and listeners (clearTimeout, off) to avoid leaks in long-running processes.
  • Talk through edge cases out loud: empty inputs, rejected Promises, and re-entrancy — interviewers grade reasoning as much as code.
Last updated June 14, 2026
Was this helpful?