TL;DR: JavaScript coding rounds test closures, async control flow and the event loop more than algorithm tricks. These 30 challenges include a Node.js section and six tasks on features from ES2024 to Temporal, and every solution was run on Node.js 26.10.
Node.js 26, released on May 5, 2026, turned on the Temporal date API by default. Candidates can now use Temporal in a Node.js coding round with no flag and no polyfill.
It also shipped Map.prototype.getOrInsertComputed(), one of several recent additions that replace hand-written helpers. The classic tasks still come first: debounce, deep equality, a promise pool and an LRU cache.
- 1Node.js 24 is the active LTS line. Node.js 26 becomes LTS on October 28, 2026, and Node.js 20 reached end of life on April 30, 2026.
- 2In a Node.js ES module,
process.nextTickruns after promise callbacks; in CommonJS it runs before them. - 3TC39 lists Temporal and explicit resource management (
using) for the 2027 edition of ECMAScript. - 4
"👋".split("")returns two broken halves, becausesplit("")works on UTF-16 code units.
Beginner Challenges
1. Reverse a string without breaking emoji.
"héllo 👋" should return "👋 olléh".
function reverseString(s) {
return [...s].reverse().join("");
}
The spread operator iterates by code point, so the emoji stays whole. The split("") version cuts it into two surrogate halves. Reversed, they form an invalid string. Combined characters, such as flags, need Intl.Segmenter to be exact.
2. Check whether a sentence is a palindrome, ignoring case and punctuation.
"A man, a plan, a canal: Panama" returns true; "race a car" returns false.
function isPalindrome(s) {
const clean = s.toLowerCase().replace(/[^a-z0-9]/g, "");
let i = 0, j = clean.length - 1;
while (i < j) {
if (clean[i++] !== clean[j--]) return false;
}
return true;
}
Two pointers compare from both ends and stop at the first mismatch. No reversed copy is made. It runs in O(n) time. The regex assumes ASCII; mention it before the interviewer asks.
3. Remove duplicates from an array and keep the first-seen order.
[3, 1, 3, 2, 1] returns [3, 1, 2].
function unique(items) {
return [...new Set(items)];
}
A Set keeps insertion order. It uses SameValueZero equality, so it also drops a second NaN and treats 0 and -0 as equal. The filter plus indexOf version is O(n²) and cannot find NaN.
4. Split an array into chunks of a given size.
chunk([1, 2, 3, 4, 5], 2) returns [[1, 2], [3, 4], [5]].
function chunk(items, size) {
if (size < 1) throw new RangeError("size must be at least 1");
const out = [];
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out;
}
A slice past the end simply returns fewer items, so the last chunk needs no special case. Guard against a size of 0, which would loop forever.
5. Find the missing number in an array holding 1 to n with one gap.
[1, 2, 4, 5, 6] returns 3.
function missingNumber(nums) {
const n = nums.length + 1;
const expected = (n * (n + 1)) / 2;
return expected - nums.reduce((sum, x) => sum + x, 0);
}
The sum of 1 to n is n(n + 1) / 2. The gap is that sum minus the actual sum. It is O(n) time and O(1) space. For very large n, XOR avoids any risk of passing Number.MAX_SAFE_INTEGER.
Intermediate Challenges
6. Predict the output order of this code.
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
queueMicrotask(() => console.log("D"));
console.log("E");
The output is A, E, C, D, B. Synchronous code runs first (A, E). Then the microtask queue drains in order: the promise callback (C), then the queueMicrotask callback (D). The timer (B) is a separate task.
It waits for every microtask, even with a delay of 0.

process.nextTick and the answer depends on the module type. We ran a timer, a promise and a nextTick call in Node.js 26: a CommonJS file printed the nextTick callback before the promise callback, and an ES module printed it after.7. Implement debounce.
Calls within wait milliseconds of each other should collapse into one call with the last arguments.
function debounce(fn, wait) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
The returned function is a regular function so this passes through, and apply forwards it. Each call cancels the previous timer, so only the last call in a burst runs. Follow-ups: a leading-edge option and a cancel() method.
8. Implement throttle.
The function should run at most once per wait milliseconds and drop the calls in between.
function throttle(fn, wait) {
let last = -Infinity;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
- Runs once, after the calls stop
- Test: calls 1, 2, 3 in a burst ran only 3
- Search boxes, autosave
- Runs at most once per window
- Test: calls 1, 2 in a burst ran only 1
- Scroll and resize handlers
Debounce waits for quiet; throttle guarantees a steady rate. Starting last at -Infinity makes the first call run at once. This version drops the calls in between. A trailing version keeps the last call and runs it when the window ends.
9. Flatten a nested array to a given depth without using flat().
For example, flatten([1, [2, [3, [4]]]], 1) returns [1, 2, [3, [4]]], the same as .flat(1).
function flatten(arr, depth = Infinity) {
return arr.reduce(
(out, item) =>
Array.isArray(item) && depth > 0
? out.concat(flatten(item, depth - 1))
: out.concat([item]),
[]
);
}
Each recursive call lowers the depth by one. Since Infinity - 1 is still Infinity, the default flattens everything. Wrapping the item in [item] matters: concat would otherwise spread an array that should stay nested.
10. Memoize a one-argument function.
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (!cache.has(arg)) cache.set(arg, fn(arg));
return cache.get(arg);
};
}
Use has(), not a truthiness check, or a cached result of 0 or "" is computed again. A Map keys objects by reference, so two equal but separate objects are cache misses.
For long-lived caches, add a size limit or use a WeakMap for object keys.
11. Implement curry.
Each of add3(1)(2)(3), add3(1, 2)(3) and add3(1)(2, 3) returns 6.
function curry(fn) {
return function curried(...args) {
return args.length >= fn.length
? fn.apply(this, args)
: (...more) => curried.apply(this, [...args, ...more]);
};
}
The fn.length property tells the curried function how many arguments to collect. It counts only the parameters before the first default or rest one. That is the usual trap in the follow-up.
12. Write a deep equality check for plain objects and arrays.
function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
}
Object.is makes NaN equal to itself. Comparing key counts catches { a: 1 } against { a: 1, b: undefined }. The Array.isArray check stops [1] from equalling { 0: 1 }.
Dates, Maps and cycles are fair follow-ups; this version does not handle them.
13. Implement Promise.all.
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = [];
let pending = promises.length;
if (pending === 0) return resolve(results);
promises.forEach((p, i) => {
Promise.resolve(p).then((value) => {
results[i] = value;
if (--pending === 0) resolve(results);
}, reject);
});
});
}
Results go in by index, not by finish order. The output order then matches the input. Promise.resolve(p) accepts plain values. An empty input must resolve at once, or the promise never settles.
14. Deep-copy an object that contains a Date and a Set.
const copy = structuredClone({ when: new Date(0), tags: new Set(["a"]) });
// copy.when is a Date and copy.tags is a Set: both survive.
const viaJson = JSON.parse(JSON.stringify({ when: new Date(0), tags: new Set(["a"]) }));
// viaJson.when is the string "1970-01-01T00:00:00.000Z"; viaJson.tags is {}.
The structuredClone() function keeps Dates, Maps, Sets and circular references. The JSON round trip turns a Date into a string and a Set into an empty object. It throws a DataCloneError on functions.
Class instances come back as plain objects.
Advanced Challenges
15. Build an LRU cache with O(1) get and put.
With capacity 2: put a, put b, get a, put c. Now b is gone and a and c remain.
class LRUCache {
#map = new Map();
constructor(capacity) {
this.capacity = capacity;
}
get(key) {
if (!this.#map.has(key)) return undefined;
const value = this.#map.get(key);
this.#map.delete(key);
this.#map.set(key, value); // now the most recent
return value;
}
put(key, value) {
this.#map.delete(key);
this.#map.set(key, value);
if (this.#map.size > this.capacity) {
this.#map.delete(this.#map.keys().next().value);
}
}
}
A Map iterates in insertion order, so deleting and setting a key moves it to the end. The first key from keys() is then the least recently used. The #map private field stops callers from changing the order by hand.
16. Run async tasks with a concurrency limit.
Map over five items with at most two tasks running at once, and return results in input order.
async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
Each worker takes the next index until none are left. JavaScript runs one callback at a time, so the counter needs no lock. Our test tracked active tasks, and the peak was 2.
If one task rejects, Promise.all rejects, but the other worker keeps running until its current task ends.
17. Write an event emitter with on, off and once.
class Emitter {
#handlers = new Map();
on(event, fn) {
if (!this.#handlers.has(event)) this.#handlers.set(event, new Set());
this.#handlers.get(event).add(fn);
return () => this.off(event, fn);
}
off(event, fn) {
this.#handlers.get(event)?.delete(fn);
}
once(event, fn) {
const off = this.on(event, (...args) => {
off();
fn(...args);
});
return off;
}
emit(event, ...args) {
for (const fn of [...(this.#handlers.get(event) ?? [])]) fn(...args);
}
}
Two details separate strong answers. First, emit copies the handler set before looping. A once handler that removes itself then cannot skip the next handler. Second, on returns an unsubscribe function.
That is easier than calling off with the same function reference.
18. Retry an async function with exponential backoff and support cancellation.
async function retry(fn, { attempts = 3, baseMs = 100, signal } = {}) {
for (let i = 1; ; i++) {
signal?.throwIfAborted();
try {
return await fn(i);
} catch (err) {
if (i >= attempts) throw err;
const delay = baseMs * 2 ** (i - 1);
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, delay);
signal?.addEventListener("abort", () => {
clearTimeout(t);
reject(signal.reason);
}, { once: true });
});
}
}
}
The delay doubles on each attempt, and the last error is rethrown unchanged. The AbortSignal check runs before each attempt and during each wait. A caller can then stop a retry loop that would otherwise wait for seconds.
Production code adds random jitter to the delay.
19. Write a pipe function and use it to build a slugify function.
" Node.js 26: What's New? " becomes "node-js-26-what-s-new".
const pipe = (...fns) => (input) => fns.reduce((acc, fn) => fn(acc), input);
const slugify = pipe(
(s) => s.trim().toLowerCase(),
(s) => s.replace(/[^a-z0-9]+/g, "-"),
(s) => s.replace(/^-|-$/g, "")
);
Here reduce passes each function's output to the next, left to right. A compose helper is the same with reduceRight. The JavaScript pipeline operator is still only a TC39 proposal. So a helper like this is what candidates should write.
Node.js Challenges
20. Turn a callback-style function into one that returns a promise.
function promisify(fn) {
return (...args) =>
new Promise((resolve, reject) => {
fn(...args, (err, value) => (err ? reject(err) : resolve(value)));
});
}
Node callbacks put the error first. So the wrapper rejects when err is set. The built-in util.promisify does the same. Most core modules already have promise versions, such as node:fs/promises.
21. Sum one column of a large CSV file without reading it all into memory.
For a file with columns id,amount and amounts 19.5, 30 and 0.5, the result is 50.
import fs from "node:fs";
import { createInterface } from "node:readline";
async function sumColumn(path, column) {
const lines = createInterface({ input: fs.createReadStream(path), crlfDelay: Infinity });
let header, total = 0;
for await (const line of lines) {
const cells = line.split(",");
if (!header) {
header = cells;
continue;
}
total += Number(cells[header.indexOf(column)]);
}
return total;
}
The readline interface with for await reads one line at a time, so memory stays flat. crlfDelay: Infinity treats \r\n as one line break. That matters for files saved on Windows.
Splitting on commas breaks on quoted fields, so real CSV needs a parser. Say that out loud.
22. Uppercase a file with a Transform stream and handle errors correctly.
import { createReadStream, createWriteStream } from "node:fs";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
async function uppercaseFile(src, dest) {
const upper = new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
await pipeline(createReadStream(src), upper, createWriteStream(dest));
}
The pipeline helper from node:stream/promises destroys every stream if one fails. It also rejects the promise. A chain of .pipe() calls does neither, so a missing input file leaves the output stream open.
Our test passed a missing file and got an ENOENT rejection.
23. Write an HTTP endpoint with node:http that sums numbers from a JSON body.
POST /sum with {"numbers": [1, 2, 3.5]} returns {"sum": 6.5}. Bad JSON returns 400; any other route returns 404.
import { createServer } from "node:http";
const server = createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/sum") {
let body = "";
for await (const chunk of req) body += chunk;
try {
const { numbers } = JSON.parse(body);
if (!Array.isArray(numbers)) throw new Error("numbers must be an array");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ sum: numbers.reduce((a, b) => a + b, 0) }));
} catch (err) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
res.writeHead(404).end();
});
The request is a readable stream, so the body is collected with for await. Invalid JSON must produce a 400, not an unhandled exception. A strong candidate also caps the body size. This version reads a request of any size into memory.
24. Attach a request ID to every log line without passing it through every function.
import { AsyncLocalStorage } from "node:async_hooks";
const requestContext = new AsyncLocalStorage();
function logLine(message) {
const id = requestContext.getStore()?.requestId ?? "-";
return `[${id}] ${message}`;
}
async function handle(requestId) {
return requestContext.run({ requestId }, async () => {
await new Promise((r) => setTimeout(r, Math.random() * 10));
return logLine("done");
});
}
An AsyncLocalStorage instance keeps a value for everything that runs inside run(), including code after an await. Three overlapping requests each logged their own ID in our test. Outside any request, getStore() returns undefined.
See the async context docs.
What Changed Recently
Years above are the expected publication years in TC39's finished proposals list. Check support before you use a feature in an interview. The grid below shows what each Node.js line runs.

25. Group orders by status and total the paid ones with Object.groupBy.
const orders = [
{ id: 1, status: "paid", total: 40 },
{ id: 2, status: "refunded", total: 15 },
{ id: 3, status: "paid", total: 25 },
];
const byStatus = Object.groupBy(orders, (o) => o.status);
// { paid: [order 1, order 3], refunded: [order 2] }
const paidTotal = byStatus.paid.reduce((sum, o) => sum + o.total, 0); // 65
The Object.groupBy function (ES2024, from the array grouping proposal) replaces the usual reduce into an object. The result has a null prototype, so byStatus.hasOwnProperty does not exist; use Object.hasOwn.
Map.groupBy is the version for non-string keys.
26. Find returning, churned and new users with the ES2025 Set methods.
const lastWeek = new Set(["ana", "bo", "cy", "dee"]);
const thisWeek = new Set(["bo", "dee", "eli"]);
const returning = lastWeek.intersection(thisWeek); // Set {"bo", "dee"}
const churned = lastWeek.difference(thisWeek); // Set {"ana", "cy"}
const newUsers = thisWeek.difference(lastWeek); // Set {"eli"}
The methods intersection, difference, union, symmetricDifference and isSubsetOf come from the Set methods proposal. Each returns a new Set and leaves the original alone. Before, this took a filter with has() on a spread array.
27. Take the first three even squares from an infinite generator.
function* naturals() {
for (let n = 1; ; n++) yield n;
}
const firstThree = naturals()
.map((n) => n * n)
.filter((n) => n % 2 === 0)
.take(3)
.toArray(); // [4, 16, 36]
Iterator helpers (ES2025, proposal) are lazy. The take(3) call stops the generator after the third match, so the endless loop is safe. Array methods would need the whole sequence first. The result is [4, 16, 36].
28. Highlight a search term that may contain regex characters.
function highlight(text, term) {
const pattern = new RegExp(RegExp.escape(term), "gi");
return text.replace(pattern, (m) => `<mark>${m}</mark>`);
}
highlight("Costs $5.00 (or $5.00 net)", "$5.00");
// "Costs <mark>$5.00</mark> (or <mark>$5.00</mark> net)"
Without escaping, $5.00 is a pattern where . matches any character, so it would also match 5x00. RegExp.escape (ES2025, proposal) escapes every special character. It needs Node.js 24 or later.
29. Group words by first letter with Map.prototype.getOrInsertComputed.
const words = ["apple", "avocado", "banana", "blueberry", "cherry"];
const byLetter = new Map();
for (const w of words) {
byLetter.getOrInsertComputed(w[0], () => []).push(w);
}
// Map { "a" => ["apple", "avocado"], "b" => ["banana", "blueberry"], "c" => ["cherry"] }
The upsert proposal adds getOrInsert(key, value) and getOrInsertComputed(key, fn). The computed version calls the function only when the key is missing. No empty array is built for keys that exist.
It replaces the if (!map.has(k)) map.set(k, []) pattern. The Node.js 26 release notes list it under V8 14.6.
30. Add one month to January 31 and count the days to a deadline with Temporal.
const start = Temporal.PlainDate.from("2026-01-31");
const oneMonthLater = start.add({ months: 1 }); // 2026-02-28, clamped
const deadline = Temporal.PlainDate.from("2026-12-25");
const today = Temporal.PlainDate.from("2026-09-24");
const daysLeft = today.until(deadline, { largestUnit: "days" }).days; // 92
A Temporal.PlainDate is a date with no time or time zone. No clock change can shift the answer. Adding a month to January 31 clamps to February 28 by default. A Date would roll over into March.
The until() method returns a Duration, here 92 days. See the Temporal proposal.
Signs of a Strong Answer
- They predict event loop order correctly and know why Node.js modules change where
process.nextTickruns. - They handle the empty input, the duplicate and the
NaNbefore the interviewer asks. - They keep
thisand arguments intact in wrappers such as debounce and memoize. - They use
pipeline, not.pipe(), and cap request body size in Node.js code. - They reach for
Map,SetandstructuredClonewhere plain objects and JSON would lose data. - They know which recent features their target runtime supports, for example that Temporal needs Node.js 26.
Hiring JavaScript Developers
For most roles, three tasks give a fair signal in an hour. Pick one intermediate task, one async task and one Node.js or browser task.
Second Talent matches companies with pre-vetted Node.js developers and front-end developers from Asia, screened with tasks like these. See typical rates on our Node.js developer cost page.
Tell us the stack and we send a shortlist within 24 hours. Start hiring, or pair these with our Node.js interview questions and front-end coding challenges.






