← Concepts & practices
Concept Language and runtime models

The call stack and execution

What does each open call keep?

You already write functions that call themselves for nested data. Let’s follow a course outline’s total call by call, until moving one variable makes it return 8 and an imported outline stops it with “Maximum call stack size exceeded”.

TypeScriptGo One course outline, two implementations.

01 / The idea

Recursion is a fair way to total an outline.

You’re building the course editor for a learning platform. Every section shows its reading time, including everything nested inside it. totalMinutes adds a section’s own minutes to each child’s total, and gets each child’s total by calling itself. The code has the same shape as the outline, and for courses people write by hand it’s the right code.

Read the first totalTypeScript · the version this lesson starts from
outline.ts
// The first version: a section's total is its own minutes plus each child's total.
export function totalMinutes(section: Section, trace?: CallTrace): number {
	trace?.enter(section);
	let total = section.minutes;
	for (const child of section.children) {
		const childTotal = totalMinutes(child, trace);
		total += childTotal;
	}
	trace?.leave(section, total);
	return total;
}

The optional trace lets this page record each call; it doesn’t change the total. Go’s version is the same recursion without it. Both languages meet again at the frames version in section 02.

Then two things happen. Someone moves let total to the top of the module so a label can show progress, and the course that totaled 12 now totals 8. And a customer imports a wiki export nested 100,000 sections deep, and the editor throws RangeError: Maximum call stack size exceeded.

Every call gets a frame of its own: its own local variables, and the place it resumes when the call it made returns. Frames pile up as calls nest and come off in reverse order, so the number open at once follows how deep the calls nest, not how many calls there are. That pile is finite, and when the data decides the depth, you can keep the frames yourself. MDN defines the unit plainly: “An execution context, also known generally as a stack frame, is the smallest unit of execution.”

Section 05 builds a recursive outline component and an imported outline of any depth, in React and Svelte.

02 / See the shape

Write the frames down when the data decides the depth.

The basic form keeps the frames in an array: each one holds a section’s total and the next child to visit. In the wild uses the same frames to fill in every section’s subtotal. At the call site runs both versions on the course and the frames version on a 100,000-deep chain.

Both languages produce the same results.

The frames written down. Each frame keeps what a recursive call keeps: its own total, and the next child to visit when it resumes.

TypeScriptReading
outline.ts
// The same work with the frames written down. Each frame keeps what a recursive call keeps:
// its own total, and where it resumes (the next child to visit).
type Frame = { section: Section; total: number; next: number };

export function totalWithFrames(root: Section): number {
	const frames: Frame[] = [{ section: root, total: root.minutes, next: 0 }];
	let result = 0;
	while (frames.length > 0) {
		const frame = frames[frames.length - 1];
		const child = frame.section.children[frame.next];
		if (child) {
			frame.next += 1;
			frames.push({ section: child, total: child.minutes, next: 0 });
		} else {
			frames.pop();
			const caller = frames.at(-1);
			if (caller) caller.total += frame.total;
			else result = frame.total;
		}
	}
	return result;
}
GoAlongside
outline.go
// frame keeps what a recursive call keeps: its own total, and where it resumes.
type frame struct {
	section *Section
	total   int
	next    int
}

// TotalWithFrames does the same work with the frames written down.
func TotalWithFrames(root *Section) int {
	frames := []frame{{section: root, total: root.Minutes}}
	result := 0
	for len(frames) > 0 {
		top := &frames[len(frames)-1]
		if top.next < len(top.section.Children) {
			child := &top.section.Children[top.next]
			top.next++
			frames = append(frames, frame{section: child, total: child.Minutes})
			continue
		}
		done := frames[len(frames)-1]
		frames = frames[:len(frames)-1]
		if len(frames) > 0 {
			frames[len(frames)-1].total += done.total
		} else {
			result = done.total
		}
	}
	return result
}
Reading the TypeScriptAn array as the stack

frames[frames.length - 1] is the open call. Pushing a frame is making a call; popping one and adding its total to frames.at(-1) is returning to the caller. next is the resume point that a recursive call keeps for you.

MDN’s execution model describes the real thing the same way: calling a function creates a new frame for its parameters and locals, and returning pops it so execution continues in the previous frame.

Reading the GoGoroutine stacks grow, up to a limit

The frames hold *Section pointers, so pushing a frame doesn’t copy a subtree. OutlineRows declares its frame type inside the function because nothing else needs it.

Go grows a goroutine’s stack as calls nest, which is why its recursive version finishes the 100,000-deep chain in the lesson’s tests. It still has a ceiling: runtime/debug.SetMaxStack starts at 1 GB on 64-bit systems, and “if any goroutine exceeds this limit while growing its stack, the program crashes.”

03 / Follow the calls

Watch the calls open and close.

Five steps, recorded from the recursive total. Each box is an open call with its own total, newest on top, beside the outline it’s working through. Before each step, guess which call is running and what each total holds.

In Try it, pick a wide, nested, or chained outline and step through every call.

Call stack

Which calls are still open?

Each call gets its own total. Open calls, top first: Setup with total 3, running; Course with total 2, waiting on Setup. Course calls Setup, which starts its own total at 3. Course started at 2 and is waiting on Setup. Setup is a separate call with its own total, 3.

01/ 05
Call totalMinutes on Setup

Each call gets its own total.

totalMinutes(Course) calls totalMinutes(Setup). Course keeps 2 and waits; Setup starts its own total at 3.

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

Read this scene

totalMinutes(Course) calls totalMinutes(Setup). Course keeps 2 and waits; Setup starts its own total at 3.

Each call gets its own total. Open calls, top first: Setup with total 3, running; Course with total 2, waiting on Setup. Course calls Setup, which starts its own total at 3. Course started at 2 and is waiting on Setup. Setup is a separate call with its own total, 3.

Watch restarts when you return. Step through keeps your selected step. Try it starts at the end of the nested outline each time you open it.

What frames buy you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

Each call’s own state
Course, Practice, and Check hold 5, 5, and 2 at the same moment.
Resuming where it left off
After Setup returns 3, Course carries on from the line after the call.
Code shaped like the data
totalMinutes calls itself once per child, just as the outline nests.
Depth you can predict
Wide, nested, and chained outlines open at most 2, 3, and 4 calls, all totaling 12.
Depth you control
totalWithFrames finishes a 100,000-deep chain, because its depth is an array’s length.

The review words are call stack, stack frame, base case for the section with no children, unwinding for the returns, and stack overflow for running out. Section 08 covers what they cost.

04 / Try a decision

A total that moved out of its function.

A progress label needed to read the running total, so total moved to module scope. The code is in shared-total.ts, and the lesson’s tests pin what happens.

What does totalMinutes(course) return now?

To show a running label, someone moved let total = 0 out of totalMinutes, to the top of the module. The function body is unchanged: total = section.minutes, then total += childTotal for each child. The course is Course 2, with Setup 3 and Practice 5, and Check 2 inside Practice.

05 / Give it a real job

An outline whose depth someone else decided.

In the real editor, courses can be imported from other tools, and generated exports can nest far deeper than anyone would type. The imported outline view shows every section with its subtotal, lets people collapse sections, and has to work however deep the file goes.

Imported outline

Depth set by the file

Nothing in the editor limits how deep it nests.

Rows

One flat list

Each row carries its depth, so the markup doesn’t nest either.

Collapsed sections

Hidden, still counted

Their children get no rows but stay in the subtotal.

The example leaves out importing the file, cycles in the data, and virtualizing a list with tens of thousands of rows, which a real import would also need.

Build UIs?Every component that renders its own children is recursion, and one day the data it renders comes from somewhere you don’t control.

Where it already is in your components

The textbook outline is a component that renders itself for each child. In React, OutlineItem returns more OutlineItems. In Svelte 5 the component imports itself; the docs call <svelte:self> obsolete, “as components can import themselves.” Either way, one level of nesting is one more component inside the last.

When you have to own it

Now it’s the imported outline. outlineRows keeps its own frames and returns a flat list, and the component renders that list with an indent per depth. Collapsing a section changes which rows appear, not the totals.

The walk still runs synchronously, and MDN notes that while a job runs “the web application is unable to process user interactions like click or scroll.” Both versions only recompute when the outline or the collapsed set changes.

outline-rows.ts
export interface OutlineSection {
	id: string;
	title: string;
	minutes: number;
	children: readonly OutlineSection[];
}

export type OutlineRow = {
	id: string;
	title: string;
	depth: number;
	total: number;
	hasChildren: boolean;
};

// Rows for every visible section, with subtotals that still count collapsed children.
// It keeps its own frames instead of recursing, so an imported outline of any depth fits.
export function outlineRows(
	root: OutlineSection,
	collapsed: ReadonlySet<string> = new Set()
): OutlineRow[] {
	const rows: OutlineRow[] = [rowFor(root, 0)];
	const frames = [
		{ section: root, row: 0, total: root.minutes, next: 0, showsChildren: !collapsed.has(root.id) }
	];
	while (frames.length > 0) {
		const frame = frames[frames.length - 1];
		const child = frame.section.children[frame.next];
		if (child) {
			frame.next += 1;
			// A hidden child still adds to the total; it just gets no row.
			const row = frame.showsChildren ? rows.push(rowFor(child, frames.length)) - 1 : -1;
			frames.push({
				section: child,
				row,
				total: child.minutes,
				next: 0,
				showsChildren: row >= 0 && !collapsed.has(child.id)
			});
		} else {
			frames.pop();
			if (frame.row >= 0) rows[frame.row].total = frame.total;
			const caller = frames.at(-1);
			if (caller) caller.total += frame.total;
		}
	}
	return rows;
}

function rowFor(section: OutlineSection, depth: number): OutlineRow {
	return {
		id: section.id,
		title: section.title,
		depth,
		total: section.minutes,
		hasChildren: section.children.length > 0
	};
}

A recursive outline item that renders itself for each child section: a React component returning itself, and a Svelte component importing itself.

ReactAlready in your code
OutlineItem.tsx
import type { OutlineSection } from './outline-rows';

// A component that renders itself for each child: one render call per level of nesting.
export function OutlineItem({ section }: { section: OutlineSection }) {
	return (
		<li>
			{section.title} <small>{section.minutes} min</small>
			{section.children.length > 0 && (
				<ul>
					{section.children.map((child) => (
						<OutlineItem key={child.id} section={child} />
					))}
				</ul>
			)}
		</li>
	);
}

06 / Recognize it elsewhere

Anywhere work waits on work it started.

You’ve met all of these. For each one, find what a frame holds and what decides the depth.

Familiar call stacks, what each frame holds, and what decides depth
Where you’ve seen itWhat each frame holdsWhat decides the depth
A recursive tree componentOne section’s propsHow deep the data nests
A stack trace in an errorOne line per open callHow deep the calls were when it threw
A folder walk that recurses into subfoldersOne folder’s listing and its place in itHow deep the folders go
Middleware where each layer calls next()A layer waiting for the rest to finishHow many layers there are
A parser for nested bracketsThe bracket it’s insideHow deeply the input nests

Before trusting a recursive function, find who decides its depth. If it’s the input, decide what happens when the input goes deeper than you expected.

07 / Already in your toolbox

Your platform already documents its frames and their limits.

Three places to look. For each one, find what a frame holds and what happens at the limit.

MDN · JavaScript execution model

Frames, what a frame tracks, the stack they form, and run-to-completion, with a worked example of calls pushing and returns popping.

Read the reference ↗

MDN · Too much recursion

The error each engine throws when the stack runs out: RangeError: Maximum call stack size exceeded in Chrome and Safari, InternalError: too much recursion in Firefox.

Read the reference ↗

Go · runtime/debug.SetMaxStack

How large a goroutine’s stack may grow, the 1 GB and 250 MB defaults, and the crash when a goroutine goes past it.

Read the reference ↗
A useful counterexample: a course your own team writesWhen recursion is the better code

A course authored in the editor, a handful of levels deep, doesn’t need frames written down. The recursive total says what it means in six lines.

08 / The parts to watch

Frames are cheap until the data decides how many.

These are the places it still goes wrong.

A variable outside the function isn’t per call

Move a local to module scope and every call shares it. The course returns 8, and a section with no children still looks right.

Imported data decides your depth

A recursive walk over a file, a response, or a user’s tree nests as deep as that data does. Either write the frames down or reject input past a depth you choose.

The limit depends on the engine

In Node 22 the recursive totalMinutes runs out of stack after a few thousand levels, about 4,300 when we checked, so an imported outline doesn’t have to be strange to reach it. Browsers, platforms, and the size of each frame all change where the limit is, and even the error’s name differs.

Go’s larger stack is still a limit

A goroutine that passes its maximum stack crashes the program. There’s no error value to handle.

Frames don’t fix cycles

An outline that contains itself loops forever in both versions. Track visited sections if the data can repeat.

No recursion isn’t the same as not blocking

outlineRows can’t overflow, but a walk over a huge outline still holds up clicks and scrolling until it finishes.

09 / Make the call

What would you have to change tomorrow?

Give both totals a plausible change and follow the work it creates.

How a change affects a recursive total and an explicit-frames total
The changeRecursionExplicit frames
Courses written in the editorShort and clear.More code for the same result.
Import outlines from other toolsCan run out of stack.Finishes at any depth.
Show every section’s subtotalReturn values carry it.Each frame remembers its row.
Debug a wrong totalThe stack trace shows the path.Inspect the frames array.
The data can contain a cycleNever finishes.Never finishes.

Write the frames down when input you don’t control decides the depth. An import is the moment.

Keep recursion when people decide the depth and it stays small.

The question I’d leave beside the code is: what does each open call keep, and what decides how many are open at once?

10 / Take the idea with you

Explain the 8 without saying “stack frame.”

“Every call was writing to the same total, so each section’s children overwrote it before the section added them. Keeping the total inside the function gave every call its own again.” In a review, the words are stack frame, call stack, recursion, and stack overflow.

Before moving on, jot down why the course totaled 8, why the import threw, and one recursive function in your own code whose depth comes from its input.

Connections to follow nextRelated lessons

Take the outline into your editor. Give totalMinutes a depth limit that throws a clear error past 1,000 levels, and decide where the frames version would check the same thing.

Back to Concepts & practices →