How to merge two arrays in JavaScript
To merge arrays in JavaScript, spread and concat both work. push(...arr) throws a RangeError at 200,000 items, which is why the choice matters.

Two ways, both fine:
const a = [1, 2];
const b = [3, 4];
[...a, ...b]; // [1, 2, 3, 4]
a.concat(b); // [1, 2, 3, 4]
Both return a new array and leave the originals untouched. Measured, a was still [1, 2] after each.
The third option looks equivalent and is not:
a.push(...b); // mutates a, and breaks on large arrays
Spread or concat
They do the same job, and the differences are small enough that either is defensible.
Spread reads better with more than two arrays and mixes literals in naturally:
[...a, ...b, ...c];
[...a, 99, ...b];
concat handles non-arrays without ceremony:
[1, 2].concat(5); // [1, 2, 5]
Measured, that appends 5 rather than erroring or spreading it. Spread would need [...a, 5], which is equally short.
Neither flattens nested arrays:
[...[1, [2]], ...[3]]; // [1, [2], 3]
The inner [2] stays an array. Both methods go exactly one level deep, which is what you want when the elements are themselves arrays and a surprise when you expected flattening.
For actual flattening:
[1, [2, [3]]].flat(); // [1, 2, [3]]
[1, [2, [3]]].flat(Infinity); // [1, 2, 3]
flat() defaults to one level. flat(Infinity) goes all the way down, and is the tidy way to collapse an arbitrarily nested structure.
The push trap
push(...b) appends b's items to a in place, which is genuinely useful when you want to mutate. It also has a hard limit:
const huge = new Array(200000).fill(0);
const dest = [];
dest.push(...huge);
RangeError: Maximum call stack size exceeded
Two hundred thousand items is enough to break it. Spreading into a function call passes every element as a separate argument, and engines cap how many arguments a call can take. It is the call stack that overflows, not memory.
The failure is abrupt and depends on data size, so it passes every test written against a small fixture and fails on a real dataset. If you need to append in place at scale, loop:
for (const item of huge) dest.push(item);
Or build a new array with spread, which has no such limit because it is not a function call:
const dest = [...dest, ...huge]; // fine at any size
That asymmetry is worth remembering: [...a, ...b] is safe at any size, a.push(...b) is not.
Merging without duplicates
A Set cannot hold the same value twice, so combining and deduplicating is one expression:
[...new Set([...a, ...b])]; // [1, 2, 3]
Measured on [1, 2] and [2, 3], that gives [1, 2, 3] with the shared 2 appearing once. Order is preserved, and the first occurrence is the one kept.
This only works on primitives. Objects compare by reference, so two identical-looking objects both survive, and the fix is a Map keyed on a field. The fix there is the Map approach in the next section.
Merging arrays of objects by a key
A common real task: two lists of the same records, and later data should win.
const merged = [...new Map(
[...a, ...b].map((item) => [item.id, item])
).values()];
Build a Map keyed on id, and later entries overwrite earlier ones because that is how Map.set behaves. Since b comes second in the spread, b's version of any shared id survives.
Swap the order to make a win. That ordering is the entire behaviour, and it is worth a comment in the code because it is not obvious to the next reader.
To merge the objects themselves rather than replacing them, spread both:
const byId = new Map(a.map((i) => [i.id, i]));
for (const item of b) {
byId.set(item.id, { ...byId.get(item.id), ...item });
}
Now shared ids keep fields from a that b does not have. That is usually what "merge" means when the two sources are partial records.
Interleaving, and other shapes
Occasionally you want something other than one list after the other.
// pairwise, using the shorter length
a.map((x, i) => [x, b[i]]);
// alternating
a.flatMap((x, i) => [x, b[i]]);
flatMap is map followed by a one-level flat, which makes alternating a one-liner. Both stop making sense when the arrays differ in length, so check that first if the inputs are not guaranteed to match.
Which to use
| Situation | Use |
|---|---|
| Combine two arrays | [...a, ...b] |
| Combine and keep the original | a.concat(b), same thing |
| Append in place, small arrays | a.push(...b) |
| Append in place, large arrays | a for...of loop |
| Combine and deduplicate primitives | [...new Set([...a, ...b])] |
| Combine objects by id | a Map keyed on the field |
| Flatten nested arrays | flat() or flat(Infinity) |
Merging objects is a different operation
The same spread syntax works on objects and means something else entirely:
{ ...a, ...b }
For arrays, spread appends. For objects, it overwrites by key, with later sources winning:
{ ...{ x: 1, y: 2 }, ...{ y: 9 } }; // { x: 1, y: 9 }
Two things to remember. The merge is shallow, so a nested object in b replaces the one in a outright rather than combining with it, which is the subject of shallow versus deep copying. And Object.assign(target, a, b) does the same thing while mutating target, in the same way push mutates an array.
The parallel is worth holding onto: spread on an array concatenates, spread on an object overwrites. Reaching for one when you meant the other produces a result that looks nearly right.
Which approach costs what
For ordinary array sizes none of this matters, and the differences show up on large data.
concat and spread both build a new array, so merging two arrays of a million items allocates two million slots. Repeatedly merging in a loop is the version to avoid:
// allocates a new array on every iteration
let all = [];
for (const chunk of chunks) all = [...all, ...chunk];
// one array, appended to
const all = [];
for (const chunk of chunks) for (const item of chunk) all.push(item);
The first grows quadratically with the number of chunks, because each iteration copies everything accumulated so far. On a handful of chunks it is fine and clearer; on hundreds it is the reason a page hangs.
flat() does the same job in one call and is both shorter and faster:
const all = chunks.flat();
That is the answer whenever you have an array of arrays and want one array, and it avoids both the loop and the quadratic version.
Merging in place versus building new
Worth stating plainly, because it is the decision underneath every option here.
Building a new array (spread, concat, flat) is the safe default. Nothing else holding a reference to the originals is affected, which matters when the arrays came from props, state, a cache, or a function argument.
Merging in place (push) is right when you own the array and are accumulating into it, and it avoids the repeated allocation above.
The failure mode is mutating something you did not own. A function that takes an array and pushes into it changes the caller's data, which is the same class of surprise as sort reordering its input. If a function takes an array and returns one, it should almost always leave the argument alone.
Inserting rather than appending
Merging at a position is the same tools with a slice:
[...a.slice(0, i), ...b, ...a.slice(i)];
That splits a at index i and puts b between the halves, returning a new array. The mutating equivalent is splice, which takes a start, a delete count, and the items to insert:
a.splice(i, 0, ...b);
The 0 means "delete nothing", which is the argument people forget, and the same spread-as-arguments limit from above applies to b.
Worth knowing that splice returns the removed items, not the resulting array, so assigning its result is the same mistake as assigning push. toSpliced is the non-mutating version and returns the new array, which is usually what you wanted.
Three common mistakes
Using push(...arr) on a large array. It throws a RangeError past a few hundred thousand items, and only on real data.
Expecting spread to flatten. It goes one level. Nested arrays stay nested; use flat().
Deduplicating objects with a Set. Two objects with the same contents are different values, so nothing is removed. Key on a field with a Map.
Merging results from several requests
The most common real merge is combining responses, and it has a decision most people skip.
const [users, admins] = await Promise.all([fetchUsers(), fetchAdmins()]);
const everyone = [...users, ...admins];
That works when the two lists are disjoint. When they overlap, which they usually do, you get duplicates of anyone who is both, and the Map approach from above is what you want.
The related question is what to do when one request fails. Promise.all rejects if any of them does, so you get nothing:
const results = await Promise.allSettled([fetchUsers(), fetchAdmins()]);
const everyone = results
.filter((r) => r.status === "fulfilled")
.flatMap((r) => r.value);
allSettled waits for all of them and reports each outcome separately, so a partial result is available. flatMap then merges the successful arrays in one step, which is neater than spreading them individually when the number of sources varies.
Which you want depends on whether a partial list is useful or misleading. A dashboard can show what loaded; a checkout total cannot be computed from some of the line items.
Preserving order
Neither spread nor concat sorts anything, so the result is the first array's items followed by the second's. That is usually fine and occasionally not:
[...a, ...b].sort((x, y) => x.date - y.date);
Merging two already-sorted lists and re-sorting the whole thing does more work than necessary, though on realistic sizes it is not worth optimising. The point is to remember that merged order is concatenation order, not anything smarter, and to sort explicitly when the combined list needs an order of its own.
Quick reference
[...a, ...b] // merge, new array
a.concat(b) // same
a.push(...b) // in place, breaks on large arrays
[...new Set([...a, ...b])] // merge and dedupe primitives
[1, [2, [3]]].flat(Infinity) // flatten fully
a.flatMap((x, i) => [x, b[i]]) // interleave
Want to break the stack yourself and see where it happens? Start with the JavaScript track.
More from the blog

How to loop through an object in JavaScript
To loop through an object in JavaScript, for...in walks inherited properties too. And integer-like keys come out first, whatever order you wrote.
Read more
Shallow vs deep copy in JavaScript: how to clone properly
To copy an object in JavaScript, spread copies the top level only, so nested objects stay shared. Here is what each method keeps and destroys.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.