Back to Knowledge Hub
Fingerprint

Sparse Array Holes and What Each Method Does With Them

One probe on a single hole: which methods skip it, which visit it, what it becomes when materialised, and why indexOf and includes disagree about it.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping a bounded record of what a page actually sent, but it cannot make a sparse array behave like a dense one, cannot stop JSON from turning a hole into null, and cannot make indexOf and includes agree about a missing index. This review runs one probe over a three-element array with a single hole and records which iteration methods visit the hole, which skip it, what the hole becomes when the array is copied, filled, sorted or serialized, and why two membership tests on the same array return opposite answers.

TL;DR

  • A hole is a missing index, not a stored undefined: on ['a', , 'c'] the probe prints length=3 has1=false ownKeys=0,2, while a dense ['a', undefined, 'c'] prints has1=true ownKeys=0,1,2.
  • forEach, map, filter, some and every skip holes entirely, so map over a hole leaves a hole in the result and filter returns a shorter dense array.
  • reduce with no initial value starts at the first present element, so summing the two present values gives a+c, while the same call with an initial 0 counts only the two visits.
  • Spread and Array.from both fill the hole with undefined, so the copy reports has1=true value1=undefined, but entries() shows the hole as 1:undefined because the iterator visits every index.
  • indexOf(undefined) returns -1 on a hole while includes(undefined) returns true, because the two use different equality checks and one skips a hole.
  • join and String() print the hole as an empty field, so ['a', , 'c'].join('-') is "a--c", while slice and reverse keep the hole and sort moves it to the last index.
  • A JSON.parse(JSON.stringify(...)) round trip turns the hole into null and makes it an own key, so the array comes back denser than it went in.

Contents

A first panel shows a three-element array with a hole at index one, labelled length=3 and has1=false. A second shows forEach and map skipping the hole while spread fills it with undefined. A third shows indexOf returning -1 next to includes returning true. A fourth shows a JSON round trip turning the hole into null and adding it as an own key, with a bottom band noting that a hole is a missing index, not a stored undefined.

How a hole differs from a stored undefined

Array accepts a length without requiring an element at every index, and the gap that leaves is a hole. The probe builds one through assignment rather than a literal, which is the way a hole appears in real code when a program computes indexes instead of writing them out:

node=v26.9.0
shape -> length=3 has1=false ownKeys=0,2 json=["a",null,"c"]
dense -> length=3 has1=true ownKeys=0,1,2 json=["a",null,"c"]
forEach -> visits=0:"a"|2:"c"
map -> result=["0:\"a\"",null,"2:\"c\""] has1=false length=3
filter -> result=["a","c"] length=2
some -> indexesSeen=0,2
reduce-no-init -> a+c
reduce-with-init -> count=2
spread -> length=3 has1=true value1=undefined
from -> length=3 has1=true value1=undefined
entries -> 0:"a"|1:undefined|2:"c"
indexOf -> -1 includes -> true
indexOf-c -> 2
join -> "a--c"
toString -> "a,,c"
reverse -> length=3 has1=false keys=0,2
sort -> length=4 has1=true has3=false keys=0,1,2
fill -> has1=true value1="x"
slice -> length=3 has1=false
copyWithin -> has1=true value1="c"
json-roundtrip -> has1=true value1=null

The first two lines are the whole article in miniature. Both arrays have length=3 and both serialize to ["a",null,"c"], so JSON.stringify cannot tell them apart. The difference is 1 in a and Object.keys. The holey array reports has1=false and lists only the keys 0,2; the dense array reports has1=true and lists 0,1,2. A hole is not a property whose value is undefined; it is the absence of the property, and every method that reads the array has to decide what to do about that absence.

This is why a check written as if (arr[i] === undefined) misclassifies both cases. On the holey array, reading a[1] gives undefined, exactly as it does on the dense array, because a missing index reads as undefined by the language rules. The only test that separates them is the presence test, 1 in a, or its equivalent, Object.prototype.hasOwnProperty.call(a, 1), and the second form is what you want when an array might have inherited a numeric property from a polluted prototype.

The distinction has a cost that shows up in request-size work. A page that builds a result by assigning to computed indexes can leave holes where a branch did not run, and the serialized form then contains null in each of those positions. A consumer reading that JSON cannot tell a deliberately recorded null from a hole that was never filled, so a schema that cares about the difference needs a sibling field or an explicit presence flag, because the array itself will not carry the distinction across JSON.stringify.

A hole also survives a hand-off that looks lossless. Passing the array to a function, storing it in an object field and returning it keeps the same object with the same holes, because nothing in that path copies it. The hole disappears only when a step materialises it, and which steps materialise it is the subject of the two sections that follow.

Three shapes produce a hole without anyone writing a comma, and each one is easy to miss in review. The first is the computed assignment the probe uses: a loop that writes result[index] = value only for the indexes that passed a filter leaves the rest absent. The second is new Array(n) followed by partial writes, because the constructor sets length and nothing else, so every index starts as a hole. The third is delete arr[i], which removes the own property and leaves length unchanged, so a delete that looks like a sparse removal is exactly a hole and behaves like one in every method the probe measured.

A fourth path is subtler and comes from an assignment past the end of an array. Writing arr[arr.length + 2] = 'x' sets length to the new index plus one and leaves the two indexes in between as holes, because the language extends the array and does not fill the gap. A page that appends with a computed offset rather than push therefore creates holes silently, and the count of present elements no longer equals length.

Detection across a boundary is where this becomes operational. A record that stores an array from a page can carry length and the serialized JSON, and both of those agree on a dense array with null and a sparse array with a hole. The probe's first two lines are exactly that case: ["a",null,"c"] for both. So a record that wants to preserve the distinction has to add the presence map, for example Object.keys(arr), and a consumer that only reads the serialized form has already lost the information before it starts.

The difference also matters for equality checks. Two arrays that both serialize to ["a",null,"c"] compare unequal under a deep-equality function that walks own keys, because one has index 1 and the other does not, and equal under a function that first serializes both to JSON. A test that compares a page's output against a fixture therefore depends on which of those two comparison styles it uses, and a fixture built by hand will silently match a dense array while the page produced a sparse one.

Which iteration methods skip a hole

The array iteration methods fall into two groups, and the split is not intuitive. The five that take a callback, forEach, map, filter, some and every, all skip a hole as though the index did not exist. The probe's forEach prints visits=0:"a"|2:"c", with index 1 absent from the list, and some prints indexesSeen=0,2 because it never calls the callback for the hole.

map is the one that surprises people, because its result keeps the hole. The probe prints result=["0:\"a\"",null,"2:\"c\""] has1=false, so the mapped array has the same length, a real value at index 0 and 2, and still nothing at index 1. A map that builds strings from each element produces a result where the hole is still a hole, and a later join on that result will show it as an empty field rather than as the string undefined.

filter is the opposite, because it cannot keep the hole: its result only contains the elements the predicate accepted, and a hole is never offered to the predicate. The probe passes an always-true predicate and gets result=["a","c"] length=2, a dense array one shorter than the source. That makes filter(() => true) a hole-removal tool, and a page that uses it as a way to copy an array is actually densifying the copy as a side effect.

reduce sits between the groups and its behaviour depends on whether an initial value is supplied. With no initial value, the probe's reduce starts at the first present element and returns a+c, so the hole never reaches the callback and the count of additions is one, not two. With an initial 0, the probe prints count=2, because the accumulator exists from the start and the callback runs once for each present element. The variant without an initial value also throws on an array with no present elements, which is the sparse equivalent of the empty-array case.

some and every short-circuit, and a hole does not count as a visit for either. That matters for a validation loop that expects every to have inspected each index: a holey array can pass an every check over a range the callback never saw. A predicate that would have rejected the missing value never runs, so an every over a partially filled array is weaker than it looks, and a page that needs to reject a hole has to check presence separately.

The iteration protocol itself does not skip holes. The probe's entries() prints 0:"a"|1:undefined|2:"c", so a for...of loop or a spread over entries() sees every index and reports the hole as undefined. The sequence a for...of yields is the same one a comparator orders, which array sort and comparators covers from the ordering side. That is the other way to visit the whole array: the callback methods skip holes, while the iterator protocol fills them in, which is why a page can get two different element counts from the same array depending on which loop it uses.

What a hole becomes when the array is copied

Copying an array does not have a single answer. Some operations preserve the hole, some replace it with undefined, and some move it. The probe exercises five of them and gets three different results from the same source.

Spread and Array.from both fill the hole. The probe prints spread -> length=3 has1=true value1=undefined and the same for from, so the copy is dense with an explicit undefined at the hole. That is the most common way a hole disappears without a warning: a [...arr] copy in a refactor turns a sparse array into a dense one, and any later code that relied on filter shedding the hole now has an undefined to handle instead.

slice preserves the hole. The probe prints slice -> length=3 has1=false, so slicing the whole array reproduces the same shape, because slice copies own keys and does not invent the missing one. The keys a slice copies are the ones an own-key walk reports, and object key order explains why they come back sorted rather than in insertion order. This is the copy that behaves like a shallow clone of the storage rather than of the values, and it is the one to reach for when the hole is meaningful.

fill writes to every index it covers, including a hole. The probe fills index 1 and prints fill -> has1=true value1="x", so a fill over a range that includes the hole makes it a real element. copyWithin does the same through a move: copyWithin -> has1=true value1="c" shows the value copied back over the hole. reverse preserves the hole. The probe prints reverse -> length=3 has1=false keys=0,2, so after reversing the three-element array the hole is still the only missing index, which makes sense because reversing maps index 1 onto itself and carries the two present values past each other.

The sort result is worth reading twice. The probe appends b with concat and then sorts the four-element result; the sort step prints sort -> length=4 has1=true has3=false keys=0,1,2, so sorting moved the hole from index 1 to the last index and filled index 1 with a real value. A page that sorts a partially filled array and then checks length still gets 4, but the hole is now at the end instead of the middle, so a loop that stops at the first undefined reads a different element than it did before the sort. The shape did not change; only its position did, and the presence check at index 1 flipped from false to true while the one at index 3 flipped the other way.

join and String() agree with each other and disagree with everything else. The probe prints join -> "a--c" and toString -> "a,,c", so both render the hole as an empty field rather than as the word undefined. That is the same output a dense array with an actual empty string would produce, which means a joined string cannot be split back into the original array without knowing how many holes there were. A round trip through join and split therefore loses the presence information that in was carrying.

Why two membership tests disagree

The sharpest consequence of a hole is that two membership tests on the same array give opposite answers. The probe prints indexOf -> -1 includes -> true for a search for undefined on the holey array, and it also prints indexOf-c -> 2 to show that a search for a present value is normal.

The reason is that indexOf uses strict equality and skips holes, while includes uses the SameValueZero comparison and treats a hole as undefined. A hole reads as undefined, SameValueZero says undefined matches undefined, and includes returns true. indexOf never visits the hole, so it returns -1. A page that guards a value with if (arr.indexOf(x) === -1) and a page that guards it with if (!arr.includes(x)) therefore disagree whenever x is undefined and the array has a hole, and the disagreement is silent.

That difference is not a bug in either method; it is the documented split, and it exists because includes was added later with the NaN case in mind, so it had to use SameValueZero to make [NaN].includes(NaN) true. The same comparison makes a hole match undefined, which is a side effect the language kept because removing it would have made includes inconsistent with itself. A page that needs to know whether a value is present and whether an index is filled therefore needs both tests, one for the value and one for the presence, and the two questions have different answers on a sparse array.

The JSON round trip closes the gap in a way that hides the problem. The probe prints json-roundtrip -> has1=true value1=null, so JSON.parse(JSON.stringify(holey)) returns an array where index 1 is present and its value is null. The hole became an own key with a null value, which means a JSON-based clone silently densifies the array and replaces the absence with a value the schema may not expect. That is the same risk as a spread copy, but harder to notice, because the code that does it often looks like it preserved the data.

Record a sparse-array reading

A sparse-array reading supports a narrow claim: this runtime, this shape, these methods, produced these results. Four fields keep it checkable:

  1. The presence map, because length and the serialized form cannot distinguish a hole from a stored undefined.
  2. The operation and whether it visits, skips or materialises the hole, because the answer is different for each one.
  3. The result's own keys, because a map keeps a hole, a filter drops it, and a sort moves it to the end.
  4. Whether the value went through JSON, because a round trip turns every hole into null and makes it an own key.

Public sources

Practical conclusion

Treat a sparse array as a different type from a dense one: test presence with in or hasOwnProperty instead of reading the value, expect the callback methods to skip a hole while spread and Array.from fill it, and remember that a sort parks the hole at the last index where it used to sit in the middle. When a value has to survive a copy, choose the copy by what it does to the hole: slice preserves it, spread densifies it, and a JSON round trip turns it into null. For the ordering a fill-in value takes once it exists, read array sort and comparators; for what a JSON copy does to the rest of the structure, read structuredClone versus JSON.

#Array#Sparse Arrays#JSON#Iteration#ECMAScript

This article is part of the guide: What Is a Browser Fingerprint?

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.