← Data structures & algorithms
Graphs Visit everything reachable, in the order the question needs

Breadth-first and depth-first search

One layer at a time, or one branch at a time.

Your editor’s file explorer draws a folder’s contents under it before the next folder starts. The DOM specification defines tree order the same way, as a “preorder, depth-first traversal,” and du adds up a folder only after everything inside it. The other order, one layer at a time, is how Edward Moore found the shortest way out of a maze in 1959. You rely on both every day. Let’s search a project folder both ways and see which finds the nearest package.json, which adds up sizes, and why both have to remember where they have been.

TypeScriptGoOne folder walker, two implementations.

01 / The idea

Choose which entry to look at next.

A project folder holds files and more folders. A search starts at the top with one entry to look at and finds more as it goes: open app and there are node_modules, package.json, and src to look at next. The whole difference between the two searches is how each keeps track of what to look at next.

Breadth-first search keeps a list of entries it has found but not yet looked at, and takes the one it found earliest. That is a queue, first in, first out. It looks at every entry one step below the top, then every entry two steps below, so the first match it reaches is as shallow as any match can be. Depth-first search keeps only the folders on its current path, from the top down to where it is, each remembering which of its entries to try next. It always works in the folder it went into most recently. That is a stack, last in, first out. It goes into the first folder, and that folder’s first folder, and backs up only when a folder has nothing left.

Both are everywhere. The DOM specification says “tree order is preorder, depth-first traversal of a tree.” Go’s filepath.WalkDir “walks the file tree rooted at root,” and “the files are walked in lexical order,” each folder before its next sibling. du summarizes disk use “recursively for directories.” Breadth first was described by Konrad Zuse in 1945 and, as Moore’s maze search, reinvented in 1959.

02 / Name the rule

Take the oldest entry, or the newest.

Both searches start with the top folder and repeat: pick the next entry and look at it. Breadth first takes from the front of a queue and adds whatever the entry leads to at the back. Depth first takes the next untried entry of the folder on top of its stack and puts that entry on top; an entry with nothing left to try comes off.

Both also remember every entry they have seen. In a plain folder tree that memory never matters, because each entry has one parent. Symbolic links change that: a link can lead to a folder already walked, or back to a folder above it. Python’s os.walk warns that following links “can lead to infinite recursion if a link points to a parent directory of itself,” because it “does not keep track of the directories it visited already.”

Each order buys something. Breadth first finds the fewest steps from the start to everything, and so the nearest match. Depth first finishes a folder only after everything inside it, which adding up sizes needs, and it keeps only the current branch waiting.

Why is breadth first’s first match always the nearest?The queue comes out in order of depth

The queue starts with the top folder at depth 0. Taking an entry at depth d adds what it leads to at depth d + 1, to the back. So the queue only ever holds entries of two neighboring depths, the shallower ones in front, and entries leave it in order of depth. The first match to leave is as shallow as any match.

That makes breadth first a shortest-path search whenever every step costs the same: fewest clicks, fewest hops, fewest moves. When steps cost different amounts, such as walking minutes, the queue needs priorities instead, and that is Dijkstra’s algorithm.

Depth first makes no such promise. Its first match is the first one down the branches it tries first, and in a folder sorted by name, that depends on what the other folders happen to be called.

03 / Follow one operation

One project folder, four searches.

The project holds README.md, an app folder with node_modules, package.json, and src, a docs folder with a large diagram, and a link called latest that points to app. Inside app/src, a link called project points back to the top.

Before you watch, predict which package.json each search finds first, and how many entries each looks at. Then watch depth first add up folder sizes, and follow a link that loops. The animation replays what the TypeScript example recorded. Try it lets you add your own links.

Breadth-first and depth-first search

One layer at a time, or one branch at a time.

  1. ▸ project
  2. README.md
  3. ▸ app
  4. ▸ node_modules
  5. ▸ .bin
  6. tsc
  7. ▸ left-pad
  8. index.js
  9. package.json
  10. ▸ react
  11. index.js
  12. package.json
  13. package.json
  14. ▸ src
  15. main.ts
  16. project → project
  17. ▸ docs
  18. guide.md
  19. ▸ images
  20. diagram.png
  21. latest → app

Stack —

Examined 0 Waiting 0 Most waiting 0

01/ 05
The folder as a graph

Folders lead to what is inside them.

The project holds README.md, app, docs, and a link called latest. Each folder lists its entries in name order, the way Go’s filepath.WalkDir walks. Two entries are links: latest points to app, and app/src/project points back to the top.

Reduced motion: choose a scene to see its completed state.

Read this scene

The project holds README.md, app, docs, and a link called latest. Each folder lists its entries in name order, the way Go’s filepath.WalkDir walks. Two entries are links: latest points to app, and app/src/project points back to the top.

The project holds README.md, app, docs, and a link called latest. Each folder lists its entries in name order, the way Go’s filepath.WalkDir walks. Two entries are links: latest points to app, and app/src/project points back to the top.

Examined so far: 0. Waiting: nothing.

Watch restarts when you return. Step through keeps your selected step. Try it starts from the project folder each time you open it.

04 / Read the shape

Two searches, one folder.

Basic form is two functions over nodes that list where they lead: breadthFirst with a queue and depthFirst with an explicit stack. Both keep a seen set, can stop at the first node that passes a test, and record each step. In the wild wraps them in ProjectFolder, which checks a folder listing, keeps each folder’s entries in name order, finds entries by name, lists entries with or without following links, and adds up folder sizes.

The folder becomes a graph on each call: folders lead to their entries, and a link leads to its target only when links are followed. The searches never learn what a file is, and that one switch is where a loop becomes possible.

Breadth first with a queue and depth first with an explicit stack, over nodes that list where they lead. Both remember what they have seen, can stop at the first node that passes a test, record every step, and keep the way they reached each node.

TypeScriptReading
folders.ts
export type GraphNode = { id: string; next: readonly string[] };
export type Step = {
	/** discover: joined the queue. visit: taken from the queue, or pushed onto the stack. skip: already seen. finish: nothing left below it. */
	kind: 'discover' | 'visit' | 'skip' | 'finish';
	node: string;
	depth: number;
	/** The node it was reached from, or null for the start. */
	from: string | null;
};
export type Options = {
	steps?: Step[];
	/** Stop as soon as a visited node passes this test. */
	until?: (node: string) => boolean;
};
export type Search = {
	/** Nodes in the order they were visited. */
	order: string[];
	/** Depth first only: nodes in the order everything reachable from them was done. */
	finished: string[];
	/** Steps from the start along the way the search reached each node. */
	depth: Map<string, number>;
	parent: Map<string, string | null>;
	/** The node `until` accepted, or null when the search ran out of nodes. */
	stopped: string | null;
};

function index(nodes: readonly GraphNode[], start: string): Map<string, readonly string[]> {
	const next = new Map<string, readonly string[]>();
	nodes.forEach((node, i) => {
		if (next.has(node.id)) throw new RangeError(`node ${i} repeats the id ${node.id}`);
		next.set(node.id, node.next);
	});
	for (const node of nodes)
		for (const id of node.next)
			if (!next.has(id)) throw new RangeError(`${node.id} leads to unknown node ${id}`);
	if (!next.has(start)) throw new RangeError(`unknown start node ${start}`);
	return next;
}

function emptySearch(start: string): Search {
	return {
		order: [],
		finished: [],
		depth: new Map([[start, 0]]),
		parent: new Map([[start, null]]),
		stopped: null
	};
}

// Breadth first: visit the start, then everything one step away, then everything two steps
// away, using a first-in, first-out queue. A node is marked seen when it joins the queue, so
// it joins once, and its depth is the fewest steps from the start.
export function breadthFirst(
	nodes: readonly GraphNode[],
	start: string,
	{ steps, until }: Options = {}
): Search {
	const next = index(nodes, start);
	const search = emptySearch(start);
	steps?.push({ kind: 'discover', node: start, depth: 0, from: null });
	const queue = [start];
	for (let head = 0; head < queue.length; head++) {
		const node = queue[head];
		const depth = search.depth.get(node)!;
		search.order.push(node);
		steps?.push({ kind: 'visit', node, depth, from: search.parent.get(node) ?? null });
		if (until?.(node)) {
			search.stopped = node;
			break;
		}
		for (const neighbor of next.get(node)!) {
			if (search.depth.has(neighbor)) {
				steps?.push({ kind: 'skip', node: neighbor, depth: depth + 1, from: node });
				continue;
			}
			search.depth.set(neighbor, depth + 1);
			search.parent.set(neighbor, node);
			steps?.push({ kind: 'discover', node: neighbor, depth: depth + 1, from: node });
			queue.push(neighbor);
		}
	}
	return search;
}

// Depth first: follow the first unseen neighbor as far as it goes, and back up only when a
// node has nothing left. A stack of (node, next neighbor) replaces recursion, so a deep graph
// cannot overflow the call stack, and the order is the same as the recursive version's.
export function depthFirst(
	nodes: readonly GraphNode[],
	start: string,
	{ steps, until }: Options = {}
): Search {
	const next = index(nodes, start);
	const search = emptySearch(start);
	search.order.push(start);
	steps?.push({ kind: 'visit', node: start, depth: 0, from: null });
	if (until?.(start)) {
		search.stopped = start;
		return search;
	}
	const stack = [{ node: start, at: 0 }];
	while (stack.length > 0) {
		const top = stack[stack.length - 1];
		const neighbors = next.get(top.node)!;
		const depth = stack.length;
		if (top.at === neighbors.length) {
			stack.pop();
			search.finished.push(top.node);
			const from = search.parent.get(top.node) ?? null;
			steps?.push({ kind: 'finish', node: top.node, depth: depth - 1, from });
			continue;
		}
		const neighbor = neighbors[top.at++];
		if (search.depth.has(neighbor)) {
			steps?.push({ kind: 'skip', node: neighbor, depth, from: top.node });
			continue;
		}
		search.depth.set(neighbor, depth);
		search.parent.set(neighbor, top.node);
		search.order.push(neighbor);
		steps?.push({ kind: 'visit', node: neighbor, depth, from: top.node });
		if (until?.(neighbor)) {
			search.stopped = neighbor;
			break;
		}
		stack.push({ node: neighbor, at: 0 });
	}
	return search;
}

/** The way a search reached `target`, from the start, or null if it never reached it. */
export function pathTo(search: Search, target: string): string[] | null {
	if (!search.parent.has(target)) return null;
	const path = [target];
	for (let node = search.parent.get(target); node != null; node = search.parent.get(node))
		path.push(node);
	return path.reverse();
}
GoAlongside
folders.go
// Node is a graph node and the nodes it leads to, in order.
type Node struct {
	ID   string   `json:"id"`
	Next []string `json:"next"`
}

// Step is one move of a search. Kind is "discover" (joined the queue), "visit" (taken
// from the queue, or pushed onto the stack), "skip" (already seen), or "finish" (nothing left below it).
// From is the node it was reached from, empty for the start.
type Step struct {
	Kind  string `json:"kind"`
	Node  string `json:"node"`
	Depth int    `json:"depth"`
	From  string `json:"from"`
}

// Options records steps when Steps is not nil, and stops as soon as a visited node
// passes Until when Until is not nil.
type Options struct {
	Steps *[]Step
	Until func(node string) bool
}

// Search is what a search found. Order lists nodes as they were visited; Finished, for
// depth first only, as everything reachable from them was done. Depth and Parent describe
// the way the search reached each node. Stopped is the node Until accepted, if Found.
type Search struct {
	Start    string
	Order    []string
	Finished []string
	Depth    map[string]int
	Parent   map[string]string
	Stopped  string
	Found    bool
}

func index(nodes []Node, start string) (map[string][]string, error) {
	next := map[string][]string{}
	for i, node := range nodes {
		if _, taken := next[node.ID]; taken {
			return nil, fmt.Errorf("node %d repeats the id %s", i, node.ID)
		}
		next[node.ID] = node.Next
	}
	for _, node := range nodes {
		for _, id := range node.Next {
			if _, known := next[id]; !known {
				return nil, fmt.Errorf("%s leads to unknown node %s", node.ID, id)
			}
		}
	}
	if _, known := next[start]; !known {
		return nil, fmt.Errorf("unknown start node %s", start)
	}
	return next, nil
}

func newSearch(start string) *Search {
	return &Search{Start: start, Order: []string{}, Finished: []string{},
		Depth: map[string]int{start: 0}, Parent: map[string]string{}}
}

func record(options Options, kind, node string, depth int, from string) {
	if options.Steps != nil {
		*options.Steps = append(*options.Steps, Step{kind, node, depth, from})
	}
}

// BreadthFirst visits the start, then everything one step away, then everything two
// steps away, using a first-in, first-out queue. A node is marked seen when it joins the
// queue, so it joins once, and its depth is the fewest steps from the start.
func BreadthFirst(nodes []Node, start string, options Options) (*Search, error) {
	next, err := index(nodes, start)
	if err != nil {
		return nil, err
	}
	search := newSearch(start)
	record(options, "discover", start, 0, "")
	queue := []string{start}
	for head := 0; head < len(queue); head++ {
		node := queue[head]
		depth := search.Depth[node]
		search.Order = append(search.Order, node)
		record(options, "visit", node, depth, search.Parent[node])
		if options.Until != nil && options.Until(node) {
			search.Stopped, search.Found = node, true
			break
		}
		for _, neighbor := range next[node] {
			if _, seen := search.Depth[neighbor]; seen {
				record(options, "skip", neighbor, depth+1, node)
				continue
			}
			search.Depth[neighbor] = depth + 1
			search.Parent[neighbor] = node
			record(options, "discover", neighbor, depth+1, node)
			queue = append(queue, neighbor)
		}
	}
	return search, nil
}

// DepthFirst follows the first unseen neighbor as far as it goes, and backs up only when
// a node has nothing left. A stack of (node, next neighbor) replaces recursion, so a deep
// graph cannot overflow the call stack, and the order is the same as the recursive version's.
func DepthFirst(nodes []Node, start string, options Options) (*Search, error) {
	next, err := index(nodes, start)
	if err != nil {
		return nil, err
	}
	search := newSearch(start)
	search.Order = append(search.Order, start)
	record(options, "visit", start, 0, "")
	if options.Until != nil && options.Until(start) {
		search.Stopped, search.Found = start, true
		return search, nil
	}
	type frame struct {
		node string
		at   int
	}
	stack := []frame{{start, 0}}
	for len(stack) > 0 {
		top := &stack[len(stack)-1]
		neighbors := next[top.node]
		depth := len(stack)
		if top.at == len(neighbors) {
			node := top.node
			stack = stack[:len(stack)-1]
			search.Finished = append(search.Finished, node)
			record(options, "finish", node, depth-1, search.Parent[node])
			continue
		}
		neighbor := neighbors[top.at]
		top.at++
		if _, seen := search.Depth[neighbor]; seen {
			record(options, "skip", neighbor, depth, top.node)
			continue
		}
		search.Depth[neighbor] = depth
		search.Parent[neighbor] = top.node
		search.Order = append(search.Order, neighbor)
		record(options, "visit", neighbor, depth, top.node)
		if options.Until != nil && options.Until(neighbor) {
			search.Stopped, search.Found = neighbor, true
			break
		}
		stack = append(stack, frame{neighbor, 0})
	}
	return search, nil
}

// PathTo returns the way a search reached target, from the start, or nil if it never did.
func PathTo(search *Search, target string) []string {
	if _, reached := search.Depth[target]; !reached {
		return nil
	}
	path := []string{target}
	for node := target; node != search.Start; {
		node = search.Parent[node]
		path = append(path, node)
	}
	slices.Reverse(path)
	return path
}
Reading the TypeScriptA head index and stack frames

The queue is an array read by a head index, so taking from the front never shifts the array. The depth map doubles as the seen set: a node counts as seen once it has a depth.

depthFirst keeps frames of { node, at }, where at is the next neighbor to try. That gives exactly the recursive order, finishing a node after all of its neighbors, without the call stack. The tests walk a chain of 200,000 nodes, far deeper than recursion can go.

Reading the GoA frame pointer and a Found flag

DepthFirst takes top := &stack[len(stack)-1], so top.at++ moves the frame on in place. With a copy, the loop would try the same neighbor forever.

Search reports Stopped with a Found flag, because an empty string could be a real id. ProjectFolder keeps an order slice beside its maps, so listings and errors come out the same way every run.

What would I normally use in application code?The walker your language ships

For real folders, use the standard walker, and read what it does with links. Go’s filepath.WalkDir walks depth first in lexical order and “does not follow symbolic links,” so it cannot loop. Python’s os.walk walks top down or bottom up, and follows links only when asked.

For any other graph, a queue or a stack and a set are a dozen lines. Write them, keep the seen set, and put a limit on depth or on results when the graph is large or arrives over a network.

05 / Try a decision

Find the shallowest package.json.

An editor command reaches a deeper package.json first. Decide which fix works for every project before the feedback tells you.

An editor command, Find the shallowest package.json in the project, walks depth first and opens app/node_modules/left-pad/package.json, though app/package.json is right there. What is the fix that works for every project?

06 / Follow the cost

33 entries, not 2,482.

Here is every operation at a glance, with V entries and E ways from one entry to another. The rest of this section measures them on a real checkout.

Folder searches: time and extra space
OperationTimeExtra spaceWhat it assumes
Visit everything reachableO(V + E)O(V)Each entry is taken once, and each way into an entry is looked at once. The seen set holds every entry reached.
Nearest match, breadth firstO(entries down to its depth)O(widest layer)Stops at the first match, after every entry shallower than it. The queue can hold a whole layer at once.
First match, depth firstO(entries before it in walk order)O(deepest branch)Can walk whole unrelated folders first. Only the current branch waits on the stack, besides the seen set.
Add up folder sizesO(V + E)O(deepest branch)Depth first finishes a folder after everything inside it, so its total is a sum of totals already counted.
Follow links without a seen setNever endsGrows without limitA link to a folder above it sends the walk round the same folders forever.

In the animation, breadth first examined 7 entries to find app/package.json, and depth first 9 to reach left-pad’s deeper one.

On this site’s own repository checkout, scanned into the lesson’s TypeScript ProjectFolder on 13 September 2026, there were 17,271 entries. The nearest package.json is at the top, and breadth first reached it after 33 entries. Depth first, in name order, went into the folders whose names sort first, build output and caches among them, and stopped at node_modules/.vite/deps/package.json after 2,482. For README.md, it was 28 against 2,434.

Depth first wins on memory. Walking the whole checkout, the queue held as many as 6,104 entries at once; the stack never held more than 12. And when the target is deep, breadth first pays for every layer above it: finding folders.ts, six folders down, took 15,488 entries breadth first and 14,208 depth first.

These are counts from the lesson’s code on one snapshot of a checkout that changes every day, not timings. The scan left out 246 entries: names with other characters, and links to files or to folders it left out.

07 / Give it a real job

Remember every folder you have seen.

A real walk meets links. This project has two: latest points to app, a second way into a folder the walk reaches anyway, and app/src/project points to the top, a loop. With links followed, each search skips a link whose target it has already seen.

Real tools each make a choice. POSIX requires find to “detect infinite loops”: “entering a previously visited directory that is an ancestor of the last file encountered.” A seen set keyed by path works here, where every folder has one path. On a real disk, links give one folder several paths, so a seen set should use the folder’s real identity rather than the path the walk came in by.

The other real limit is size. Breadth first keeps a whole layer waiting, 6,104 entries on the checkout. A search over a network, or through a folder you do not control, needs a depth limit, a result limit, and a way to cancel.

Build UIs?See the walks your UI already does, and the day you search a tree you load lazily.

Where it already is in your components

Rendering a tree is a depth-first walk. A file explorer, a nested comment thread, or a table of contents draws an item and then its children before the next sibling, and a recursive component is that walk on the framework’s call stack. The DOM itself is kept in tree order.

Deeply nested data is where recursion stops being free. A recursive component or a recursive JSON walk uses one call per level, so input you do not control needs a depth limit or an explicit stack.

When you have to own it

Picture a file tree with a search box. Reveal nearest should open the folders on the way to the shallowest match: search breadth first, keep the path, and open each folder on it.

Then it gets real: the tree lives on a server, and each folder is a request. Walk layer by layer, request a few folders at a time, skip node_modules and .git, stop at a depth or a number of results, and cancel when the query changes, so an old search cannot finish after a new one.

A file tree with Reveal nearest: a breadth-first search keeps the shallowest path and opens each folder on it, while the tree itself renders depth first.

ReactAlready in your code
FileTree.tsx
import { useState } from 'react';

export type TreeNode = { name: string; children?: TreeNode[] };

// The shallowest path to an entry with this name, one layer at a time. A folder tree without
// links has one way to reach each entry, so no seen set is needed here.
function nearest(root: TreeNode, name: string): string[] | null {
	const queue: { node: TreeNode; path: string[] }[] = [{ node: root, path: [] }];
	for (let head = 0; head < queue.length; head++) {
		const { node, path } = queue[head];
		if (path.length > 0 && node.name === name) return path;
		for (const child of node.children ?? [])
			queue.push({ node: child, path: [...path, child.name] });
	}
	return null;
}

export function FileTree({ root }: { root: TreeNode }) {
	const [open, setOpen] = useState(() => new Set<string>());
	const [selected, setSelected] = useState<string | null>(null);
	const [query, setQuery] = useState('package.json');

	function reveal() {
		const path = nearest(root, query.trim());
		if (!path) return setSelected(null);
		// Open every folder on the way down, so the match is on screen.
		const folders = path.slice(0, -1).map((_, i) => path.slice(0, i + 1).join('/'));
		setOpen((current) => new Set([...current, ...folders]));
		setSelected(path.join('/'));
	}

	function toggle(path: string) {
		setOpen((current) => {
			const next = new Set(current);
			if (!next.delete(path)) next.add(path);
			return next;
		});
	}

	// Drawing the tree is itself a depth-first walk: a folder's row, then its open children.
	function branch(nodes: TreeNode[], prefix: string) {
		return (
			<ul>
				{nodes.map((node) => {
					const path = prefix ? `${prefix}/${node.name}` : node.name;
					const isFolder = node.children !== undefined;
					return (
						<li key={path} aria-current={selected === path ? 'true' : undefined}>
							{isFolder ? (
								<button type="button" aria-expanded={open.has(path)} onClick={() => toggle(path)}>
									{node.name}
								</button>
							) : (
								<span>{node.name}</span>
							)}
							{isFolder && open.has(path) && branch(node.children!, path)}
						</li>
					);
				})}
			</ul>
		);
	}

	return (
		<nav aria-label="Files">
			<form
				onSubmit={(e) => {
					e.preventDefault();
					reveal();
				}}
			>
				<input value={query} onChange={(e) => setQuery(e.target.value)} aria-label="File name" />
				<button type="submit">Reveal nearest</button>
			</form>
			{branch(root.children ?? [], '')}
		</nav>
	);
}

08 / Make the call

Ask what the order has to give you.

Reach for breadth first when you need the nearest: the fewest clicks, hops, or moves, the shallowest match, or everything within two steps. Reach for depth first when a node must be finished after what it leads to, when the graph is deep and memory is tight, or when you want to try one whole path before the next, as backtracking does.

Look elsewhere when the question changes. If steps have costs, like minutes, use Dijkstra’s shortest path. If you need an order that respects dependencies, topological sort is a queue of ready nodes, or a depth-first finishing order reversed. If you ask again and again whether two things are connected while connections keep arriving, union-find keeps groups without searching. And store the graph as an adjacency list, so each step reads one node’s neighbors.

09 / Take the idea with you

Explain it without the names.

“I keep a list of places I have found but not visited, and a note of everywhere I have been. I take the oldest place when I want the nearest thing, and the newest when I want to finish one path before starting another. I never go back to a place I have noted.” That describes the mechanism. Breadth first and depth first are what you call it in a review.

Before moving on, explain three things without the names: why the first match taken from a queue is the nearest, why folder sizes need everything inside finished first, and what goes wrong following a link to a parent folder with no note of where you have been. Then find a recursive walk in your own code and ask what it does when the input has a cycle.

Connections to follow nextRelated lessons
  • Graph overview names nodes, edges, direction, and weight, and sends each question to the lesson that answers it.
  • Adjacency list is what these searches read, one node’s neighbors at a time.
  • Queue and deque is breadth first’s waiting list.
  • Stack is depth first’s, and explains why an explicit stack survives where recursion runs out.
  • Topological sort orders work from the same kind of walk.

Take the walker into your editor. Give it a folder with a link to its parent, remove the seen set, and watch it never finish. Put the set back, and compare how many entries each order keeps waiting.

Back to data structures & algorithms →