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.
"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.
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.
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:
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();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.
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.