skip to content
luminary.blog
by Oz Akan
trampoline sketch

Avoiding Stack Overflows with Trampoline Functions

When you can't tail call, you need to trampoline your way out in TypeScript to handle deep recursion without hitting stack overflow errors.

/ 6 min read

Table of Contents

Teaching recursion revealed that seemingly simple problems could harbor surprisingly elegant and optimized solutions. This led me to experiment by asking different LLMs some of these questions. Most initially overlooked the most efficient approaches, only finding them when explicitly asked. However, one LLM stood out: it recommended using a trampoline function to mitigate stack overflow risks – a technique I didn’t know. That unexpected insight sparked the exploration that follows.

Recursion is a powerful programming technique where a function calls itself to solve smaller instances of the same problem. However, deep recursion can lead to a common issue: the “Maximum call stack size exceeded” error, or stack overflow. Let’s explore this with an example and see how trampoline functions can help us overcome this limitation in environments like Node.js and Deno.

A Simple Recursive Function

Consider a function that prints all even numbers within a given range:

function printEvenNumbers(start: number = 0, end: number = 100): void {
// Base case: stop when start exceeds end
if (start > end) {
return;
}
// Print if the number is even
if (start % 2 === 0) {
console.log(start);
}
// Recursive call for the next number
printEvenNumbers(start + 1, end);
}
// Usage:
printEvenNumbers();
// Output: 0, 2, 4, ..., 100

This function works well for small ranges.

The Stack Overflow Problem

Each time printEvenNumbers calls itself, a new frame is added to the call stack. This frame stores information about the function call, like its parameters and local variables. If the range (and thus the recursion depth) is very large, the call stack can run out of space, leading to a stack overflow error.

Theoretical Solution: Tail Call Optimization (TCO)

Tail Call Optimization (TCO) is a feature where a compiler or interpreter can optimize certain types of recursive calls, known as “tail calls”. A tail call occurs when a function’s very last action is to call itself (or another function) and return its result immediately. With TCO, the engine can reuse the current stack frame instead of creating a new one, effectively turning recursion into iteration under the hood and preventing stack overflows.

We can try to make our function tail-recursive:

function printEvenNumbersTCO(start: number = 0, end: number = 100): void {
if (start > end) {
return;
}
if (start % 2 === 0) {
console.log(start);
}
// This is now a tail call
return printEvenNumbersTCO(start + 1, end);
}
// Usage:
// printEvenNumbersTCO(0, 100000); // Might still cause stack overflow!

We can even make it more efficient by only processing even numbers:

function printEvenNumbersTCOOptimized(start: number = 0, end: number = 100): void {
if (start > end) {
return;
}
// start is guaranteed to be even (or the first odd number if initial start was odd)
if (start % 2 === 0) {
console.log(start);
// Tail call jumping by 2
return printEvenNumbersTCOOptimized(start + 2, end);
} else {
// Adjust if starting odd, then jump by 2
console.log(start + 1); // Print the next even number
return printEvenNumbersTCOOptimized(start + 3, end);
}
}
// Start with an even number for simplicity:
// printEvenNumbersTCOOptimized(0, 100000); // Might still cause stack overflow!

The Catch: While TCO is a great concept, most JavaScript environments (including Node.js, Deno, and major browsers except Safari) do not implement Tail Call Optimization. This means that even if your code is written in a tail-recursive style, you’ll still encounter stack overflows with deep recursion in these environments.

Practical Solution: Trampoline Functions

Since we can’t rely on TCO, we need another way to handle deep recursion. This is where the trampoline pattern comes in. Instead of making a direct recursive call, our function will return a thunk—a zero-argument function that, when called, performs the next step of the computation. A central “trampoline” function then repeatedly calls these thunks until a final result (not a thunk) is returned.

This turns recursion into iteration, keeping the stack flat.

Here’s how we implement it:

1. The Generic Trampoline Function:

// Type for the thunk or the final result
type TrampolineResult<T> = T | (() => TrampolineResult<T>);
// The trampoline function itself
function trampoline<T>(firstThunk: () => TrampolineResult<T>): T {
let currentThunk = firstThunk;
// Keep executing thunks as long as a function is returned
while (typeof currentThunk === 'function') {
currentThunk = currentThunk() as (() => TrampolineResult<T>);
}
// When a non-function value is returned, it's the final result
return currentThunk as T;
}

2. Modify the Recursive Function to Return Thunks:

We adapt our printEvenNumbers logic to return either void (in the base case) or a thunk representing the next step.

function printEvenNumbersThunk(start: number = 0, end: number = 100): TrampolineResult<void> {
// Base case: Return the final result (void)
if (start > end) {
return undefined; // Or simply return;
}
if (start % 2 === 0) {
console.log(start);
}
// Return a thunk for the next step
return () => printEvenNumbersThunk(start + 1, end);
}

3. Combine and Run:

We use the trampoline function to execute our thunk-returning function.

// Start the process by calling the trampoline with the initial thunk
trampoline(() => printEvenNumbersThunk(0, 100));
// Output: 0, 2, 4, ..., 100
// Now, even with a large range, it won't overflow the stack:
// trampoline(() => printEvenNumbersThunk(0, 100000)); // Works!

4. More Efficient Trampoline Version:

We can apply the optimization of skipping odd numbers to the trampoline version as well:

function printEvenNumbersThunkOptimized(start: number, end: number): TrampolineResult<void> {
// Base case
if (start > end) {
return undefined;
}
// Ensure start is even (only needs check on first call if starting potentially odd)
// This simple version assumes start is initially even.
console.log(start);
// Return a thunk that jumps by 2
return () => printEvenNumbersThunkOptimized(start + 2, end);
}
// Make sure the initial call starts even if needed
const initialStart = 0;
const initialEnd = 100;
const firstStart = initialStart % 2 === 0 ? initialStart : initialStart + 1;
// Run the optimized version
// trampoline(() => printEvenNumbersThunkOptimized(firstStart, initialEnd)); // Works!

Conclusion

While recursive functions offer elegance, they risk stack overflows in JavaScript environments lacking TCO. Tail-recursive style alone doesn’t prevent this issue in Node.js or Deno. The trampoline pattern provides a practical (mind-bending) solution by transforming deep recursion into iteration, allowing you to leverage a recursive programming style without blowing the stack.

Clever.