Dedupe by Key With Last-Write-Wins (JS)
dedupeBy(rows, 'id') is the function I copy into every ETL script. Last-write-wins is the right policy 90% of the time; this version also exposes a configurable resolver for the other 10%.
By @camilarao
April 15, 2026
·
Updated May 20, 2026
561 views
5
4.7 (8)
// Last-write-wins dedupe: a Map keyed by the dedupe field.
// Iteration order on a JS Map is insertion order, so this preserves arrival order
// while keeping the most recent value for each key.
function dedupeBy(rows, keyOf) {
const pickKey = typeof keyOf === 'function' ? keyOf : (r) => r[keyOf];
const out = new Map();
for (const row of rows) out.set(pickKey(row), row);
return [...out.values()];
}
const events = [
{ id: 'u1', name: 'Alice', ts: 100 },
{ id: 'u2', name: 'Bob', ts: 110 },
{ id: 'u1', name: 'Alicia', ts: 130 }, // newer write for u1
{ id: 'u3', name: 'Carol', ts: 140 },
];
console.log(dedupeBy(events, 'id'));
// [{id:'u1',name:'Alicia',ts:130}, {id:'u2',name:'Bob',ts:110}, {id:'u3',name:'Carol',ts:140}]Six lines, but the trick is the choice of Map over a plain object. Map.set overwrites in place yet preserves the original insertion key in iteration order, which is exactly the last-write-wins-but-keep-arrival-shape semantics that ETL scripts want. The keyOf argument accepts either a string ('id') or a function ((r) => r.user.id) so it handles nested keys without forcing the caller to pre-flatten. I have shipped this verbatim in three different services; the named-function version is enough for almost every dedupe case.
// When 'last write wins' is wrong: late events have only some fields.
// Pass a merge resolver to combine the existing row with the new one.
function dedupeBy(rows, keyOf, merge) {
const pickKey = typeof keyOf === 'function' ? keyOf : (r) => r[keyOf];
const out = new Map();
for (const row of rows) {
const k = pickKey(row);
const prev = out.get(k);
out.set(k, prev === undefined ? row : merge(prev, row));
}
return [...out.values()];
}
// Real use: a backfill emits partial profile updates. Drop nulls, prefer newer ts.
function mergeProfiles(prev, next) {
const merged = { ...prev };
for (const [k, v] of Object.entries(next)) {
if (v !== null && v !== undefined) merged[k] = v;
}
merged.ts = Math.max(prev.ts ?? 0, next.ts ?? 0);
return merged;
}
const events = [
{ id: 'u1', name: 'Alice', email: '[email protected]', ts: 100 },
{ id: 'u1', name: null, email: 'alice@x', ts: 130 }, // backfill: only email
{ id: 'u2', name: 'Bob', email: null, ts: 110 },
{ id: 'u2', name: 'Bob', email: '[email protected]', ts: 150 },
];
console.log(dedupeBy(events, 'id', mergeProfiles));The flaw of last-write-wins shows up the day a backfill emits sparse rows: a fresh event with name: null should NOT erase the existing name. The resolver makes the policy explicit instead of pretending naive overwriting is correct. The merge function I ship most often is the one above, where null and undefined are treated as 'no opinion' and the highest-ts wins per field. I keep merge functions tiny and named so the dedupe call site reads as dedupeBy(rows, 'id', mergeProfiles), which is far easier to grep than an inline arrow.
// When the input is millions of rows from a stream, you do not want to
// materialize the full array first. Yield as keys 'finalize'.
// Strategy: sort the stream by key (or assume already-sorted), then emit
// one record per run.
function* dedupeByStream(iter, keyOf, merge) {
const pickKey = typeof keyOf === 'function' ? keyOf : (r) => r[keyOf];
let currentKey;
let current;
let started = false;
for (const row of iter) {
const k = pickKey(row);
if (!started) {
currentKey = k;
current = row;
started = true;
continue;
}
if (k === currentKey) {
current = merge ? merge(current, row) : row;
} else {
yield current;
currentKey = k;
current = row;
}
}
if (started) yield current;
}
// Caller is responsible for sorting; here we mock a sorted stream.
function* sortedStream(rows, keyOf) {
const pick = (r) => r[keyOf];
const sorted = [...rows].sort((a, b) => (pick(a) < pick(b) ? -1 : pick(a) > pick(b) ? 1 : 0));
for (const r of sorted) yield r;
}
const events = [
{ id: 'u1', name: 'Alice', ts: 100 },
{ id: 'u3', name: 'Carol', ts: 140 },
{ id: 'u1', name: 'Alicia', ts: 130 },
{ id: 'u2', name: 'Bob', ts: 110 },
{ id: 'u1', name: 'Al', ts: 160 },
];
const sorted = sortedStream(events, 'id');
for (const row of dedupeByStream(sorted, 'id')) {
console.log(row);
}The Map-based version stores every distinct key in memory, which is fine until the input does not fit. The streaming version keeps only the current run, so memory is O(1) regardless of input size, but it requires the input to be sorted by key. In production I get sorted input by piping sort -k1,1 between stages or by reading from a key-ordered store; the dedupe step becomes pure transform. A common sharp edge is forgetting the trailing yield current after the loop ends, which silently drops the final group. Always test with a single-element input.
