Ch. 1 · JavaScript

Remove Duplicates from a JavaScript Array with Set (and Objects)

Why new Set removes duplicate primitives but not duplicate objects, how SameValueZero treats NaN and -0, and how to dedupe objects by key.

~6 min readbeginnerupdated Oct 6, 2026

“How do you remove duplicates from an array in JavaScript?” is one of the first questions in many junior and mid-level interviews. Most candidates answer [...new Set(arr)], which is correct for numbers and strings. The interviewer then hands over an array of user objects, or an array containing NaN, and asks why duplicates are still there. The follow-up is the real question: it checks whether you understand how JavaScript decides two values are “the same,” and whether you can design a deduplication rule for real records.

Strong answers cover the one-liner, its complexity and ordering, the equality algorithm Set uses, and how to dedupe objects by a key you choose.

Before you start

You should know array spread syntax, the difference between primitives and objects (objects are compared by reference), and basic Big-O notation. It helps to have seen === comparison quirks such as NaN === NaN being false.

The short answer

[...new Set(array)] removes duplicate primitive values in linear time and keeps the first occurrence of each value in its original order. Set decides equality with SameValueZero, which is like === except that NaN is equal to NaN; 0 and -0 are treated as equal. Objects and arrays are compared by reference, so two separate objects with identical contents are both kept. To dedupe records, pick a key such as id and keep one entry per key with a Map.

How it works

A Set stores each value at most once. Adding a value that is already present does nothing, so the set keeps the first insertion’s position. Spreading the set back into an array produces values in insertion order.

const tags = ['js', 'css', 'js', 'html', 'css'];
console.log([...new Set(tags)]); // [ 'js', 'css', 'html' ]
console.log(Array.from(new Set(tags)).length); // 3
JavaScript

The equality rule matters for edge cases. === says NaN is not equal to itself, which is why older deduplication tricks such as filter with indexOf drop every NaN (because indexOf(NaN) is always -1). Set uses SameValueZero and keeps exactly one.

const values = [NaN, NaN, 0, -0, '1', 1];
console.log([...new Set(values)]); // [ NaN, 0, '1', 1 ]
console.log(values.filter((v, i) => values.indexOf(v) === i)); // [ 0, '1', 1 ]
console.log(values.indexOf(NaN), values.includes(NaN)); // -1 true
JavaScript

Note that '1' and 1 stay distinct: there is no type coercion. And -0 collapses into 0.

Objects are a different story. Each object literal creates a new reference, so a set of two { id: 1 } literals has size 2.

const a = { id: 1 };
const users = [a, { id: 1 }, a];
const unique = new Set(users);
console.log(unique.size); // 2
console.log(unique.has(a), unique.has({ id: 1 })); // true false
JavaScript

Step-by-step walkthrough

Step 1: Define what “duplicate” means

For primitives, the language’s equality is usually right. For records, “duplicate” is a business rule: same id? Same email ignoring case? Same SKU and size? Say the rule out loud before coding. It decides the key, and it decides which occurrence to keep when duplicates differ in other fields.

Step 2: Dedupe records by key with a Map

A Map from key to record keeps one entry per key in linear time. Checking has before set keeps the first occurrence; calling set unconditionally keeps the last.

const people = [
  { id: 1, name: 'Ada', email: 'ADA@x.io' },
  { id: 2, name: 'Bo', email: 'bo@x.io' },
  { id: 1, name: 'Ada L.', email: 'ada@x.io' },
];

function uniqueBy(items, keyOf, keep = 'first') {
  const byKey = new Map();
  for (const item of items) {
    const key = keyOf(item);
    if (keep === 'last' || !byKey.has(key)) byKey.set(key, item);
  }
  return [...byKey.values()];
}

console.log(uniqueBy(people, (p) => p.id).map((p) => p.name)); // [ 'Ada', 'Bo' ]
console.log(uniqueBy(people, (p) => p.id, 'last').map((p) => p.name)); // [ 'Ada L.', 'Bo' ]
console.log(uniqueBy(people, (p) => p.email.toLowerCase()).length); // 2
JavaScript

With keep: 'last', the entry keeps its original position (the first occurrence’s slot) but takes the latest value, because Map.set on an existing key does not move it.

Step 3: Handle composite keys

When identity depends on several fields, build a primitive key. Joining with a separator works if the separator cannot appear in the values; JSON.stringify of an array is safer.

const cart = [
  { sku: 'tee', size: 'M' },
  { sku: 'tee', size: 'L' },
  { sku: 'tee', size: 'M' },
];
const seen = new Set();
const lines = cart.filter((item) => {
  const key = JSON.stringify([item.sku, item.size]);
  if (seen.has(key)) return false;
  seen.add(key);
  return true;
});
console.log(lines.length); // 2
JavaScript

Avoid JSON.stringify(item) on whole objects as a key: property order changes the string, so { a: 1, b: 2 } and { b: 2, a: 1 } would be treated as different.

Step 4: Compare complexity

The Set approach is O(n): each add and has is effectively constant time. The filter plus indexOf approach is O(n²) because indexOf scans from the start for every element. On a few dozen items you will not notice; on 100,000 items the difference is seconds.

const big = Array.from({ length: 30000 }, (_, i) => i % 1000);
let t = performance.now();
const viaSet = [...new Set(big)];
const setMs = performance.now() - t;
t = performance.now();
const viaIndexOf = big.filter((v, i) => big.indexOf(v) === i);
const indexOfMs = performance.now() - t;
console.log(viaSet.length, viaIndexOf.length); // 1000 1000
console.log(indexOfMs > setMs); // true
JavaScript

Worked scenario

A notifications panel merged results from two API pages that overlapped by a few items. The developer used new Set to remove the overlap, but duplicates still appeared, because each page response produced new objects even for the same notification.

const page1 = JSON.parse('[{"id":"n1","text":"Hi"},{"id":"n2","text":"Sale"}]');
const page2 = JSON.parse('[{"id":"n2","text":"Sale"},{"id":"n3","text":"Bye"}]');
const broken = [...new Set([...page1, ...page2])];
console.log(broken.map((n) => n.id)); // [ 'n1', 'n2', 'n2', 'n3' ]
JavaScript

Parsed JSON always creates fresh objects, so reference equality can never match across responses. The fix dedupes by the notification’s stable id, keeping the newest version in case the text was edited between pages.

const page1 = JSON.parse('[{"id":"n1","text":"Hi"},{"id":"n2","text":"Sale"}]');
const page2 = JSON.parse('[{"id":"n2","text":"Sale ends today"},{"id":"n3","text":"Bye"}]');
const byId = new Map();
for (const n of [...page1, ...page2]) byId.set(n.id, n);
const merged = [...byId.values()];
console.log(merged.map((n) => `${n.id}:${n.text}`));
// [ 'n1:Hi', 'n2:Sale ends today', 'n3:Bye' ]
JavaScript

Common mistake

  • “Set removes duplicate objects.” Only if they are the same reference.
  • “Set uses ===.” It uses SameValueZero, so NaN matches NaN.
  • “Set loses the order.” It preserves first-insertion order.
  • Using filter with indexOf on large arrays. It is quadratic and drops NaN values entirely.
  • Stringifying whole objects as keys. Key order and nested formatting make equal records produce different strings, and it is slow on large objects.
  • Forgetting case and whitespace rules. 'Ada@x.io' and 'ada@x.io ' are different strings; normalise before deduping when the business rule says they are the same.

Verify the behavior

import assert from 'node:assert/strict';

assert.deepEqual([...new Set([3, 1, 3, 2, 1])], [3, 1, 2]);
assert.equal(new Set([NaN, NaN]).size, 1);
assert.equal(new Set([0, -0]).size, 1);
assert.equal(new Set([1, '1']).size, 2);
assert.equal(new Set([{}, {}]).size, 2);

const rows = [{ id: 1 }, { id: 1 }, { id: 2 }];
const unique = [...new Map(rows.map((r) => [r.id, r])).values()];
assert.equal(unique.length, 2);
console.log('Set dedup rules verified');
// Set dedup rules verified
JavaScript

Run it with node dedupe.mjs. The last check uses the compact new Map(entries) form, which keeps the last record for each id.

Follow-up questions

How do you find which values were duplicated? Track a seen set and a dupes set: if a value is already in seen, add it to dupes.

Are there built-in set operations? Modern runtimes, including Node 22, support union, intersection, difference, symmetricDifference and isSubsetOf on Set.prototype.

Can you dedupe without extra memory? Sort first and skip neighbours that are equal. That is O(n log n), changes the order, and still needs care with NaN.

How do you dedupe an array of arrays? Convert each inner array to a stable key, such as JSON.stringify(inner) for primitive contents, and use the key approach above.

Interview exercise

Write mergeContacts(listA, listB) that returns contacts with no duplicate emails, comparing emails case-insensitively and ignoring surrounding spaces. When duplicates appear, keep the record with the most recent updatedAt. Preserve the order in which each email first appeared.

Answer and reasoning

function mergeContacts(listA, listB) {
  const byEmail = new Map();
  for (const contact of [...listA, ...listB]) {
    const key = contact.email.trim().toLowerCase();
    const existing = byEmail.get(key);
    if (!existing || contact.updatedAt > existing.updatedAt) byEmail.set(key, contact);
  }
  return [...byEmail.values()];
}
const a = [{ email: 'Ada@x.io ', name: 'Ada', updatedAt: 1 }, { email: 'bo@x.io', name: 'Bo', updatedAt: 5 }];
const b = [{ email: 'ada@x.io', name: 'Ada Lovelace', updatedAt: 3 }, { email: 'cy@x.io', name: 'Cy', updatedAt: 2 }];
console.log(mergeContacts(a, b).map((c) => c.name)); // [ 'Ada Lovelace', 'Bo', 'Cy' ]
JavaScript

A plain Set cannot solve this, because the records are different objects and the rule is not plain equality. Normalising the email gives a primitive key that encodes the business rule. A Map keeps one record per key in linear time, and replacing a value with set keeps the key’s original position, which satisfies the ordering requirement. Comparing updatedAt before replacing implements “most recent wins.” Mentioning what to do on equal timestamps (keep the first, or prefer a particular source) shows you have thought about ties.

Continue learning

Practise with more JavaScript interview questions and the JavaScript MCQs. Related notes: Map versus object and equality and type coercion. Official reference: Set on MDN.

More in JavaScript

read ✓JavaScript · easy

JavaScript Array Sort Comparators

Sort arrays in place with a correct comparator, understand default string ordering and sort stability, and avoid the numeric and string traps.

~3 min readread →
esc