When you have a deeply nested object like a config tree, an API response, or document model, and need to do something at every level of it, writing the recursion yourself gets tedious. It gets harder still when the work at each node is asynchronous: a database lookup, a file read, a network call.
traverse-async handles the recursion for you. Give it any object or array and a callback, and it walks every node from the root down. The callback receives a context object describing the current node's position in the tree. Your callback can be async — traverse() returns a Promise that resolves when every node has been visited.
pnpm add traverse-async
# or
npm install traverse-asyncimport traverse from 'traverse-async'; // ESM
const traverse = require('traverse-async'); // CJSconst data = { a: { b: { c: 1 } }, d: 2 };
await traverse(data, ({ node, path }) => {
console.log(path.join('.') || '(root)', '->', node);
});
// (root) -> { a: { b: { c: 1 } }, d: 2 }
// a -> { b: { c: 1 } }
// d -> 2
// a.b -> { c: 1 }
// a.b.c -> 1traverse() returns a Promise that resolves with the original data once all nodes have been visited. Use await to know when it's done:
const result = await traverse(data, ({ node, key }) => {
// visit every node
});
// execution continues here once all nodes have been visited
console.log(result === data); // true — resolves with the original valueThe callback can be async. traverse() awaits each callback's returned Promise before processing that node's children:
await traverse(data, async ({ node }) => {
if (node?.url) {
node.data = await fetch(node.url).then(r => r.json());
}
});If the callback throws synchronously, or returns a Promise that rejects, the traversal is aborted and the traverse() Promise rejects with that error. No further nodes are visited.
try {
await traverse(data, async ({ node }) => {
const res = await fetch(node.url); // may throw
if (!res.ok) throw new Error(`HTTP ${res.status}`);
node.data = await res.json();
});
} catch (err) {
console.error('traversal failed:', err);
}Returning a non-undefined value from the callback replaces that node in the tree. The traversal then recurses into the replacement (unless skip() is also called).
const obj = { a: 1, b: 2 };
await traverse(obj, ({ node, key }) => {
if (key) return node * 10;
});
console.log(obj); // { a: 10, b: 20 }This is shorthand for assigning to parent[key] directly. Both are equivalent:
// explicit
({ node, key, parent }) => { if (key) parent[key] = node * 10; }
// via return value
({ node, key }) => { if (key) return node * 10; }Returning undefined (or not returning) leaves the node unchanged. This means replacing a node with undefined isn't possible via return value — use parent[key] = undefined directly for that case.
Return values for the root node are ignored since there is no parent to assign into.
For an immutable workflow, clone the data first and traverse the copy:
const result = await traverse(structuredClone(data), ({ node, key }) => {
if (typeof node === 'string') return node.toUpperCase();
});
// data is unchanged, result is transformedCall skip() from the context to visit the current node but skip all of its descendants. The rest of the traversal carries on normally.
await traverse({ a: { b: { c: 1 } }, d: 2 }, ({ path, key, skip }) => {
console.log(path.join('.') || '(root)');
if (key === 'a') skip(); // skip everything inside a
});
// (root)
// a ← visited, but b and c are never reached
// dUse return skip() to exit the callback early at the same time:
await traverse(data, ({ key, skip }) => {
if (key === '_private') return skip();
// … handle everything else
});Call stop() to resolve the traverse() Promise immediately. No further nodes are visited.
let found = null;
await traverse(tree, ({ node, key, stop }) => {
if (key === 'targetId' && node === 'abc123') {
found = node;
return stop(); // stop immediately, Promise resolves
}
});
console.log(found); // 'abc123', or null if not foundstop() resolves the Promise (intentional early exit). To cancel from outside the traversal and have the Promise reject, use an AbortController instead — see Aborting from outside.
Walks every node in data breadth-first, calling callback once per node.
| Parameter | Type | Description |
|---|---|---|
data |
any | The root value to traverse. Objects and arrays are recursed into; primitives are visited as leaves. |
callback(context) |
function | Called for each node. Receives a context object. May be async. Call skip() to prune children, stop() to halt traversal, or do nothing to recurse. |
options |
object (optional) | { parallel, signal } — see below. |
Returns a Promise that resolves with data once every node has been visited, or rejects if the callback throws or an abort signal fires.
The single argument passed to callback is a plain object describing the current node.
The current value being visited.
await traverse({ a: 1 }, ({ node }) => {
console.log(node); // { a: 1 }, then 1
});The string key or array index under which this node lives in its parent. undefined for the root.
// data = { users: [{ name: 'alice' }] }
// visiting 'alice': key === 'name'
// visiting the array: key === 'users'
// visiting the root: key === undefinedThe object or array that contains this node. undefined for the root.
// Delete the current node from its parent
await traverse(obj, ({ key, parent }) => {
if (key === 'secret') delete parent[key];
});Array of keys from the root down to the current node. Empty array [] for the root.
// data = { a: { b: { c: 1 } } }
// visiting c: path === ['a', 'b', 'c']
// visiting b: path === ['a', 'b']
// visiting root: path === []
await traverse(data, ({ node, path }) => {
console.log(path.join('.')); // 'a', 'a.b', 'a.b.c', …
});true only for the root node. Useful for skipping it in callbacks that only care about children.
await traverse(data, ({ node, isRoot }) => {
if (isRoot) return; // skip the root itself
console.log(node);
});Call skip() to visit this node but skip all of its children. The traversal continues with siblings and the rest of the tree.
await traverse(data, ({ key, skip }) => {
if (key === '_private') return skip(); // prune this subtree
});Calling skip() does not stop the callback — code after the call still runs. Use return skip() to exit early.
Call stop() to halt traversal immediately. The traverse() Promise resolves with the original data.
let found = null;
await traverse(data, ({ node, stop }) => {
if (node?.id === target) { found = node; return stop(); }
});Unlike an AbortController abort, stop() resolves the Promise rather than rejecting it.
Injects an additional node into the traversal queue. The injected node and its children are traversed normally.
await traverse(data, ({ node, path, push }) => {
if (node?.type === 'ref') {
push({ node: resolveRef(node), path: [...path, 'resolved'], parent: node });
}
});Pass an options object as the third argument to traverse().
Number of nodes processed concurrently. Defaults to 1 (serial).
await traverse(assetTree, async ({ node }) => {
if (node?.type === 'image') {
node.data = await fetchImage(node.src);
}
}, { parallel: 4 });Each traverse() call uses its own concurrency setting — there is no global state.
Parallel caveats:
**context.nodeis a snapshot.** Each node's value is captured when it is added to the queue. If a concurrent sibling mutates the same parent key,context.nodewill be stale — useparent[key]to read the live value.- Last write wins. With
parallel > 1, two sibling callbacks may both produce a return value or mutate the same key. The one whosefinish()runs last sets the final value. Avoid cross-sibling mutations when using parallelism. - Children come from the replacement. If a callback returns a new object, the traversal recurses into that replacement — not the original node. Siblings running concurrently are not affected since their children haven't been queued yet.
**stop()drops in-flight return values.** Once traversal is halted, any sibling callbacks still running to completion will have their return values silently discarded.
An AbortSignal for cancelling the traversal from outside. The traverse() Promise rejects with an AbortError when the signal fires.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // cancel after 5s
try {
await traverse(data, async ({ node }) => { /* … */ }, { signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') console.log('traversal was cancelled');
else throw err;
}AbortSignal.timeout(ms) works too:
await traverse(data, handler, { signal: AbortSignal.timeout(5000) });Use an AbortController when you need to cancel traversal from code outside the callback (a timeout, a user action, a race with another Promise). The traverse() Promise rejects with an AbortError.
const controller = new AbortController();
const p = traverse(data, handler, { signal: controller.signal });
// somewhere else:
controller.abort();
await p; // throws AbortError- Order: breadth-first (BFS). All children of the current depth are queued before descending.
- Objects and arrays are recursed into. Their keys/indices are visited as child nodes.
- Primitives (
string,number,boolean,null,undefined) are visited as leaves with no children. - Promises (any object with a
.thenmethod) are visited as leaves — their internals are not traversed. - Only own enumerable string keys are visited. Inherited, non-enumerable, and symbol-keyed properties are skipped.
- Sparse array holes are skipped (
[1,,3]visits indices0and2only). - Class instances are recursed into (own enumerable properties only).
- Date, RegExp, Map, Set are treated as leaves and not recursed into.
- Circular references are detected via a per-traversal
WeakSet. A node whose object reference has already been recursed into is visited (the callback fires) but its children are not queued again, preventing infinite loops. - Shared references (same object reachable via multiple keys) follow the same rule: the callback fires once per key, but children are only queued the first time the object is encountered.
- Mutation: assigning to
parent[key]before the callback returns causes the traversal to recurse into the new value instead of the original. - Concurrency: default is serial (
parallel: 1). Pass{ parallel: N }totraverse()to process up to N nodes at once.
Call skip() when the node is an array to prevent the traversal from descending into it. Useful when arrays contain homogeneous data you want to treat as a unit rather than walk element by element.
await traverse(data, ({ node, skip }) => {
if (Array.isArray(node)) return skip();
// only object nodes and primitives reach here
});To process the array itself but skip its contents conditionally:
await traverse(data, ({ node, key, skip }) => {
if (Array.isArray(node) && node.length > 100) {
console.log(`skipping large array at ${key} (${node.length} items)`);
return skip();
}
});Check key against a string, set, or regex to target only the nodes you care about and ignore the rest.
await traverse(config, ({ key, node, parent }) => {
if (/_url$/i.test(key)) {
parent[key] = node.replace('http://', 'https://');
}
});Strip passwords, tokens, and secrets from a payload at any nesting depth before it reaches a logger or external service.
const SENSITIVE = new Set(['password', 'token', 'secret', 'apiKey']);
await traverse(payload, ({ key, parent, skip }) => {
if (SENSITIVE.has(key)) {
parent[key] = '[redacted]';
return skip(); // no need to recurse into the redacted value
}
});
logger.info(payload);Walk a loaded config and replace ${VAR} placeholders with real values before the app starts.
const ENV_PLACEHOLDER = /\$\{([^}]+)\}/g;
await traverse(config, ({ node }) => {
if (typeof node === 'string') {
return node.replace(ENV_PLACEHOLDER, (_, name) => process.env[name] ?? '');
}
});
startApp(config);Assign a new value to parent[key] before the callback returns. The traversal will recurse into the replacement, not the original.
const templates = {
greeting: { text: 'Hello!', style: 'bold' },
separator: { text: '---', style: 'plain' },
};
await traverse(data, ({ node, key, parent }) => {
if (node?.type === 'template') {
parent[key] = templates[node.name] ?? { text: '', style: 'plain' };
// traversal will recurse into the replacement
}
});This works for any replacement — objects, arrays, or primitives. If the replacement is a primitive or has no children, the traversal simply advances without recursing.
Load file contents into a nested structure in one pass.
import { readFile } from 'fs/promises';
import traverse from 'traverse-async';
const tree = {
config: { path: './config.json' },
i18n: {
en: { path: './locales/en.json' },
fr: { path: './locales/fr.json' },
},
};
await traverse(tree, async ({ node }) => {
if (node?.path) {
node.content = JSON.parse(await readFile(node.path, 'utf8'));
}
}, { parallel: 4 });
// tree.config.content, tree.i18n.en.content, etc. are now populatedAn API response contains { $ref: 'users/42' } nodes scattered at arbitrary depths. Replace each one with the real record in place.
await traverse(document, async ({ node }) => {
if (node?.$ref) {
return await db.get(node.$ref); // replaces the $ref node; traversal recurses into the result
}
});
render(document);Walk any structure that may contain URL strings at arbitrary depth and verify each one responds successfully.
const broken = [];
await traverse(document, async ({ node, path }) => {
if (typeof node === 'string' && node.startsWith('https://')) {
const res = await fetch(node, { method: 'HEAD' });
if (!res.ok) broken.push({ url: node, status: res.status, path: path.join('.') });
}
}, { parallel: 5 });
console.log('broken links:', broken);Reduce a nested object to a plain { 'a.b.c': value } map — useful for diffing, serialising form state, or generating dot-notation keys.
const flat = {};
await traverse({ a: { b: 1 }, c: [2, 3] }, ({ node, path }) => {
if (typeof node !== 'object' || node === null) {
flat[path.join('.')] = node;
}
});
console.log(flat);
// { 'a.b': 1, 'c.0': 2, 'c.1': 3 }Walk a structure that may contain promises at arbitrary locations and await each one in place.
await traverse(data, async ({ node }) => {
if (node != null && typeof node.then === 'function') {
return await node; // replaces the promise with its resolved value; traversal recurses into it
}
});
console.log('all promises resolved', data);Remove keys from an object while traversing — for example, stripping all null values.
await traverse(obj, ({ node, key, parent }) => {
if (node === null) delete parent[key]; // return value can't express deletion, so mutate directly
});Use AbortController to cancel from outside the callback — for example, on a timeout or user action.
const controller = new AbortController();
// Cancel if the user navigates away
window.addEventListener('beforeunload', () => controller.abort());
try {
await traverse(largeTree, async ({ node }) => {
await processNode(node);
}, { signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') console.log('cancelled');
else throw err;
}traverse-async operates directly on the object you pass in. When you need to preserve the original, clone it first and traverse the copy:
import traverse from 'traverse-async';
import { klona } from 'klona';
const result = klona(original);
await traverse(result, ({ node, key, parent }) => {
if (typeof node === 'string') {
parent[key] = node.toUpperCase();
}
});
// original is untouched, result is transformed
console.log(original); // { name: 'alice', address: { city: 'london' } }
console.log(result); // { name: 'ALICE', address: { city: 'LONDON' } }klona handles plain objects, arrays, Date, RegExp, Map, Set, and typed arrays. For plain JSON-like data, the built-in structuredClone works without any dependency.
The API has been redesigned around Promises. If you are upgrading from 0.x, here is what changed:
Callback signature. The callback no longer receives (node, next, stop) with this as context. It now receives a single context object: ({ node, key, parent, path, isRoot, skip, stop, push }).
No next(). You no longer call next() to advance the traversal. Just return from the callback (or return a Promise). Traversal proceeds automatically when the callback returns or its Promise resolves.
stop() vs skip(). In 0.x, calling stop() skipped the current node's children. That behaviour is now skip(). The new stop() halts the entire traversal immediately and resolves the Promise.
Return value replaces the node. Returning a non-undefined value from the callback now replaces that node in the tree. Previously the return value was ignored.
traverse() returns a Promise. In 0.x it returned the internal queue object. The done(err, data) third argument is gone — use await or .then() instead.
config() is gone. Pass { parallel: N } as the third argument to traverse() instead.
break() is gone. Use stop() to resolve early, or an AbortController to reject from outside the callback.
Unlicense — public domain.