The fastest answer: to merge two arrays into a new array, use spread, const merged = [...a, ...b]. To merge b into an existing array a without allocating a new one, use a.push(...b). To get a copy without touching the originals and without the spread caveat, use a.concat(b). All three are correct in 2026; the right pick depends on whether you want to mutate, how big the arrays are, and how much memory you can spend.
const a = [1, 2, 3];
const b = [4, 5, 6];
const merged = [...a, ...b]; // [1, 2, 3, 4, 5, 6], new array, a and b untouched
a.push(...b); // a is now [1, 2, 3, 4, 5, 6], b untouched
const copy = a.concat(b); // new array, a and b untouched (a is still the pushed version above)Copy-merge with spread: [...a, ...b]
This is the one I reach for by default. [...a, ...b] builds a brand new array by expanding each source into a fresh array literal. The originals are never modified, the order is exactly a then b, and it reads cleanly at a glance.
const front = ["a", "b"];
const back = ["c", "d"];
const all = [...front, ...back]; // ["a", "b", "c", "d"]It also composes naturally with more than two sources and with single values mixed in: [...front, "x", ...back]. The cost is that it allocates a new array of a.length + b.length, so for a moment you are holding the originals plus the merged result in memory. For ordinary arrays that is a non-issue. For very large arrays it matters, and there is a hard limit worth knowing about (below).
Merge in place with a.push(...b)
When you already own a and want b's elements appended to it, a.push(...b) mutates a directly and returns the new length. No second array is allocated for the result, which is the memory win.
const log = ["start"];
const newEntries = ["step 1", "step 2"];
log.push(...newEntries); // log is now ["start", "step 1", "step 2"]This is the modern replacement for the old Array.prototype.push.apply(a, b) trick you still see in pre-2015 code; spread does the same thing and reads better. Use it only when mutating a is actually what you want. If a is shared (state someone else holds a reference to, a prop, a frozen-by-convention constant), mutating it in place is a bug magnet, and you should copy-merge instead.
Copy with a.concat(b)
concat returns a new array and leaves both originals alone, same end result as spread but a different shape. Its one genuine behavioral wrinkle is that it flattens one level of any array argument: array args get spread in, non-array args get appended as-is.
[1, 2].concat([3, 4]); // [1, 2, 3, 4] array arg is flattened one level
[1, 2].concat(3, 4); // [1, 2, 3, 4] plain values appended
[1, 2].concat([[3, 4]]); // [1, 2, [3, 4]] only ONE level is flattenedThat one-level behavior is exactly why concat is the safe choice for very large arrays: it does not pass b's elements as function arguments the way spread does, so it sidesteps the arg-count limit described next.
The spread arg-count gotcha (very large arrays)
a.push(...b) and f(...b) expand b into individual function arguments. JavaScript engines cap how many arguments a single call can take, because the spread elements land on the call stack. In V8 (Chrome, Node.js) that ceiling sits around 125,000 arguments, though it varies with the engine, version, and available stack; once b is large enough, push(...b) throws a RangeError: Maximum call stack size exceeded instead of merging. Treat anything in the tens of thousands as the danger zone rather than waiting for the exact limit.
const huge = new Array(200_000).fill(0);
const target = [];
target.push(...huge); // RangeError on V8: too many spread arguments overflow the call stackThe fix is to not spread at all for large arrays. Either loop, or use concat, neither of which routes the elements through an argument list:
// Loop append, in place, no arg-count risk:
function te_extend(target, source) {
for (let i = 0; i < source.length; i++) target.push(source[i]);
return target;
}
// Or copy-merge without spread:
const merged = a.concat(b); // safe at any sizeI treat "the arrays might be tens of thousands of elements or larger" as the trigger to drop spread and reach for concat (copy) or a loop (in place). For everyday arrays, spread is fine and clearer.
Which one should I use?
| Technique | Mutates? | Allocates new array? | Safe for very large arrays? | Reach for it when |
|---|---|---|---|---|
[...a, ...b] | No | Yes | No (arg-count limit) | You want a new merged array; readability matters |
a.push(...b) | Yes (a) | No | No (arg-count limit) | You own a and want it extended in place |
a.concat(b) | No | Yes | Yes | You want a copy, or the arrays are very large |
loop push | Yes (a) | No | Yes | In-place merge of very large arrays |
The decision is really two questions. First, do you want to mutate an existing array or produce a new one? Mutating points at push; a new array points at spread or concat. Second, are the arrays large enough to risk the arg-count limit? If yes, avoid anything that spreads the source into a call: use concat for a copy or a loop for in place.
One thing none of these do is deduplicate. Merging [1, 2] and [2, 3] gives [1, 2, 2, 3], not [1, 2, 3]. If you want unique values after merging, see removing duplicates from an array. And if you are reaching for the full set of modern array methods (mutating versus copying variants, flat, at), merging is one corner of a larger toolkit. For objects rather than arrays, the spread idea carries over but the rules differ; see merging objects in JavaScript.
See also
- Modern JavaScript array methods: the mutating-versus-copying map (
reverse/toReversed,sort/toSorted),flat,at, and grouping. - Remove duplicate values from an array: dedupe after merging, with the primitives-versus-objects gotcha.
- Merge objects in JavaScript: the same spread idea for objects, plus the shallow-merge and last-wins rules.
Sources
Authoritative references this article was fact-checked against.
- Spread syntax (...): JavaScript reference (MDN)developer.mozilla.org
- Array.prototype.concat(): JavaScript reference (MDN)developer.mozilla.org
- Array.prototype.push(): JavaScript reference (MDN)developer.mozilla.org





