The Runtime Theory
RuntimeInternalsmemory

What happens when you concatenate strings?

A step-by-step walk of string concatenation: the immutable buffer, the memcpy, the quadratic loop trap, and the builder patterns that dodge it.

The Runtime Theory Team1 min read05 steps

layer stack

Runtime

HWHardware
KKernel
RTRuntime
APPApplication
SYSSystem
CLIClient
NETNetwork
TLSCrypto
SRVServer

trace spine

  1. 01 + allocates a new buffer
  2. 02 Both halves are copied in
  3. 03 Loop concatenation goes quadratic
  4. 04 Builders amortize the copies
  5. 05 Interning and ropes dodge work

In Java, C#, JavaScript, Python, and Rust, strings are immutable: "a" + "b" never mutates either operand — it allocates a brand-new buffer and copies both halves into it. That one design decision is the entire performance story of string concatenation, and it turns a tiny operation into a subtle one.

trace stepRuntime

"prefix-" + id compiles to a call that computes prefix.len + id.len (plus, in Java, a hidden StringBuilder for multi-operand expressions, and in JS a rope node in V8's cons-string representation) and allocates a new character buffer of exactly that size — a malloc fast-path hit via the tcache we traced in malloc-trace, ~50 ns. The old strings are untouched; if no one references them, they become garbage for the next GC cycle.

trace stepHardware

Both operands are copied into the new buffer: two memcpy-style loops, ~1 byte per cycle per core on modern x86 with SIMD (AVX-2 copies 32 bytes per instruction). A 100-byte concat is ~100–200 ns of copy — plus the allocation, the old buffers' eventual free, and the cache traffic of touching twice the data you kept. The copy is the tax; the allocation is the surcharge.

trace stepRuntime

The trap: for (i…) { s += item[i] }. Each iteration allocates and copies the entire accumulated string again. After N iterations the machine has copied 1 + 2 + … + N ≈ N²/2 characters — quadratic. For 100,000 items that's ~5 billion byte-copies — seconds of work hiding behind a one-line loop. The profiler shows memcpy at the top; the fix is structural, not micro-optimization:

java
String s = "";
for (int i = 0; i < 100_000; i++) s += i;   // O(n^2) copies
 
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) sb.append(i);  // O(n) total
String s2 = sb.toString();
trace stepRuntime

The builder pattern (StringBuilder, Python's ''.join, JS array join, Rust's String with push_str) changes the shape: a growable buffer that appends in place — amortized doubling means each byte is copied O(1) times on average, and the final toString is the only unavoidable copy. For N concatenations the builder does ~N + 2N copies total vs the operator's ~N²/2 — a 4-order-of-magnitude difference at N=100k. The runtime often optimizes the obvious cases (V8's string concat optimization, C#'s const-folding of "a" + "b"), but loop accumulation defeats every heuristic.

trace stepRuntime

Two structural escapes exist beyond builders. Interning (Java's String.intern, JS symbol-ish tables): deduplicate repeated identical strings so concat of interned values can sometimes short-circuit — at the cost of a global table that never shrinks. Ropes (V8's cons-strings, some compilers): concatenation becomes O(1) — build a tree node referencing both operands — with the flattening cost deferred until the string is actually needed as a contiguous buffer. Ropes are why JS string concat in a loop is less catastrophic than the naive model predicts — V8 only flattens when forced.

What the machine actually does on "a" + "b" is allocate, copy, copy, and eventually reclaim — three memory operations wrapped around two SIMD passes. The operation is cheap exactly once and expensive exactly N times, and every optimization in the ecosystem is a way to make N not happen.