Article title: Functional JavaScript vs. functional Elixir: where it’s slow, and where it isn’t
— Article body (markdown) —
Both JavaScript and Elixir support functional code: map, filter,
reduce, immutability, pure functions, and recursion. That does not mean
“functional” works or performs the same way in both languages. It doesn’t. A
recursive walk or a reducer that builds a new map at every step can be cheap on
the BEAM and expensive under V8. The same code can be fast on one Node core
and beside the point under Elixir. The result depends on what the runtime was
built to optimize for. Most of that choice sits below the language.
This article focuses on the BEAM’s performance story: where JavaScript’s functional style has a real cost, why Elixir avoids that cost in the same places, and where the difference is a myth. Do not let that myth change how you write code.
The same word, two different runtimes
“Functional JavaScript” runs on V8 (or SpiderMonkey, or JavaScriptCore), a
runtime built to run browser and server code quickly on one core through a
single-threaded event loop. V8 is an astonishing piece of engineering. It
traces hot loops, inlines functions, and generates tight machine code for the
object shapes it sees at runtime. For a tight for loop over an array of
numbers, very little on Earth beats it.
“Functional Elixir” runs on the BEAM, a virtual machine designed in the late 1980s at Ericsson for telephone switches that had to stay up for years, handle millions of concurrent calls, and survive the failure of any single component. The BEAM optimizes for something else: fairness and concurrency, not single-thread throughput. Its scheduler preempts a process every ~2,000 reductions, so one process cannot starve the rest. That is why “don’t block the event loop” is a JavaScript mantra and not an Elixir one. On the BEAM, a slow process cannot block anyone else.
The distinction is simple. JavaScript functional code runs on a runtime that rewards keeping work on one fast thread. Elixir functional code runs on a runtime that rewards spreading work across many cheap processes. The runtimes were built to win different races.
Where functional JavaScript pays a real tax
Functional style costs measurable time in JavaScript in three places. Know them. They are the places where someone says “functional is slow” and is actually right.
Recursion. This is the classic case. Recursively walk a list of ten
thousand items in JavaScript and you will hit RangeError: Maximum call stack size exceeded.
The language specification mandates Proper Tail Calls (PTC), so a
tail-recursive function should use constant stack space. Only Safari’s
JavaScriptCore actually ships it, though. V8 implemented PTC and then quietly
removed it. The usual workaround is a hand-rolled trampoline: a
higher-order function that runs the recursion in a loop and returns thunks
instead of making the call directly, so the stack never grows. It works. You
should not have to write it. Elixir programmers do not think about it because
tail calls on the BEAM are optimized into a jump, full stop.
Immutable data the naive way. The second tax is subtler and more dangerous because it does not crash. It silently makes your code . Consider a reducer that builds a lookup by “immutably” updating a plain object on every step:
const lookup = items.reduce((acc, item) => {
return { ...acc, [item.id]: item }; // copies the whole object each time
}, {});
Each ...acc spreads the entire accumulator into a new object. For a
hundred items, that means not a hundred copies but the sum of 1 + 2 + … + 100,
roughly five thousand copies. Every step copies what the earlier steps already
copied. This is the immutable-data trap in miniature. It is why libraries like
Immutable.js exist: they use structural sharing, so an “update” returns a
new reference that reuses most of the old structure’s memory instead of
copying it.
Memoization and reference identity. The third tax follows from the second.
Every immutable “update” produces a new object reference, so memoizing a
function on its arguments does not work the way you expect. Two
structurally-identical-but-reference-distinct inputs are different cache keys.
Object.freeze does not fix that; frozen objects are still distinct references.
React’s reconciliation model partly works around this: the virtual DOM is an
immutable tree that you can rebuild cheaply because unchanged subtrees are
shared, and reconciliation is the diff.
The common thread is that V8 gives you no help with these features. There is no VM-level immutability, no structural sharing built into the data structures, and no tail-call guarantee. Functional JavaScript is a discipline you impose on a runtime designed for mutable objects and loops. The runtime is fast. You are working against its grain, and when that costs you, the cost is real and measurable.
Why the BEAM doesn’t pay those taxes
Here is the part that surprises people: the BEAM is slower than V8 at instruction-level work, and it does not care. Its functional idioms are cheap because the VM was designed around them, not because it is faster.
Immutability is a VM property, not a discipline. Elixir has no const
(binding-only), no shallow Object.freeze, no readonly type that gets erased
at runtime, and no spread that copies. Data really is immutable at the runtime
level. When you “update” a map or a list, the BEAM returns a new structure that
structurally shares unchanged parts with the old one. That is exactly what
Immutable.js has to bolt on after the fact in JavaScript. So the
reducer above, written idiomatically in Elixir -
lookup =
items
|> Enum.reduce(%{}, fn item, acc -> Map.put(acc, item.id, item) end)
-
isn’t .
Map.put/3shares structure. The discipline and the mechanism are the same thing.
Tail calls are real. Tail recursion on the BEAM compiles to a loop with no stack growth. The seed solution for reversing a list on this site is textbook tail recursion with an accumulator:
def reverse_string(s), do: do_reverse(s, [])
defp do_reverse([], acc), do: acc
defp do_reverse([h | t], acc), do: do_reverse(t, [h | acc])
Run that on a million-element list and it works. Run the naive recursive JavaScript equivalent and it blows the stack. You do not need a trampoline in Elixir because there is nothing to trampoline around.
Each process gets its own heap, and its own garbage collection. This matters most at scale, though you cannot see it in a single function. V8 uses a generational garbage collector with a heap limit (some gigabytes) for the entire process. When GC runs, it can pause everything. The pause grows with the size of the live heap. Under load, that appears as tail latency spikes: most requests are fast, while an occasional request hangs for tens of milliseconds as the collector catches up.
The BEAM gives every process its own tiny heap. When one process garbage collects, it collects only its own few kilobytes. That takes a microsecond and no other process notices. There is no global stop-the-world. Idle processes can even hibernate and free their memory entirely. The trade-off is that message passing copies data between process heaps, so do not pass giant structures carelessly. The payoff is latency that stays flat no matter how busy the system gets.
When it actually matters - and when it doesn’t
When should this change your decisions? Less often than the rhetoric suggests.
It matters under concurrency and tail latency. Consider a hundred thousand connections where each one makes a quick database call and replies. Node handles this fine up to a point. Then one slow request or a GC pause stalls everything in flight on that thread, and p99 latency spikes because only one thread is doing the work. The BEAM spreads those connections across its schedulers and preempts each one fairly, so latency stays nearly flat as load climbs. If you are building a real-time system - chat, presence, streaming, or anything with many mostly-idle connections (Phoenix has held millions of WebSockets on a single box) - this is where Elixir genuinely wins. That is a runtime win, not a language win.
It matters for long-running recursion. If your workload genuinely recurses deeply, JavaScript’s missing tail-call optimization is a real wall. A trampoline fixes correctness but adds overhead and ugliness. On the BEAM, it is a non-event.
It matters for fault isolation. An exercise that recurses infinitely will, on the BEAM, get killed cleanly by the scheduler budget after a couple of seconds. The process dies, its supervisor could restart it, and the rest of the system remains untouched. The same infinite loop in Node can pin a core and, depending on how it is run, take the whole process with it. (This site’s own code runners enforce timeouts precisely because the languages it runs don’t all self-limit the way the BEAM does.)
It does not matter for algorithm puzzles. This is the part nobody on
either side likes to admit. For a Two Sum, a Valid Parentheses, or a binary
search - the kind of thing you practice here - the difference is noise. The
seed solution for Two Sum on this site uses Enum.reduce_while/3 to walk the
list once and halt early when the pair is found:
def two_sum(nums, target) do
nums
|> Enum.with_index()
|> Enum.reduce_while(%{}, fn {n, i}, seen ->
case Map.fetch(seen, target - n) do
{:ok, j} -> {:halt, [j, i]}
:error -> {:cont, Map.put(seen, n, i)}
end
end)
end
The TypeScript version - .reduce or a plain for with a Map and an
early return - runs in the same O(n) time and, on one machine handling one
call, runs faster. V8’s optimizing compiler is better at tight loops than the
BEAM. For one request on one core doing one algorithm, JavaScript is often the
faster language. Pretending otherwise is wishful. Elixir’s advantage is that
the thousandth concurrent request stays as fast as the first, not that the
first request finishes sooner.
Choose V8 and a for loop when one hot loop must do one thing on one machine as
fast as possible; functional JavaScript written carefully is perfectly fast.
Choose the BEAM when a service must stay responsive for many simultaneous users
and survive parts of it failing. That is where the BEAM’s model pays off, beyond
what JavaScript discipline can provide.
The honest summary
JavaScript functional programming is not slow because it is functional. It is slow in the specific places where the runtime refuses to help functional code: recursion without tail calls, immutability without structural sharing, and memoization without value equality. A careful JavaScript programmer works around each problem with libraries, trampolines, and discipline. Elixir functional programming is not fast because Elixir is magical. It is fast in the places where the BEAM was built around functional data: structural sharing as a VM primitive, tail calls that actually optimize, and per-process garbage collection. It is genuinely slower than V8 at raw single-threaded instruction throughput. Elixir gives that up deliberately for fairness and concurrency.
The functional style is the right instinct in both languages. The runtime
underneath decides where it costs you and how much, not the words map and
reduce.
Where to go next
- What O(n) actually promises - the “ without crashing” tax, named properly: what the notation promises, and what it hides.
-
Real numbers, and the lies programming languages tell about them
- the other place the two runtimes disagree about what a value is.
- Sorting: the wall at n log n - the same algorithm in both runtimes: merge sort in Elixir and TypeScript, where the constant factor decides the winner.
-
Hash tables: the O(1) that has fine print
- the “immutably updating a plain object” trap, with the structure that makes the update cheap.
-
Reading Elixir as a JS developer
- the mapping table this article’s performance story sits underneath.