Arrays were one of the first data structures I learned in JavaScript, and yet I kept discovering new depth in them years into my career — from how they’re actually stored in memory to subtle bugs around sparse arrays and array-like objects. In this article, I want to cover arrays from the ground up: creating them, accessing and modifying elements, common patterns, and what’s actually happening internally when I work with them.
Creating Arrays
const fruits = ["apple", "banana", "cherry"]; // array literal — what I use almost always
const numbers = new Array(1, 2, 3); // constructor form, rarely needed
const empty = new Array(5); // creates a sparse array with length 5, no actual elements!
console.log(empty.length); // 5
console.log(empty); // [ <5 empty items> ]
I almost always use array literals ([]) instead of the Array constructor, because new Array(5) behaves unexpectedly — it creates a sparse array of a given length, not an array containing the number 5, which trips people up constantly.
const filled = Array.from({ length: 5 }, (_, i) => i * 2);
console.log(filled); // [0, 2, 4, 6, 8]
const filled2 = Array(5).fill(0);
console.log(filled2); // [0, 0, 0, 0, 0]
Array.from() with a mapping function is my preferred way to generate a sequence of computed values.
Accessing and Modifying Elements
const colors = ["red", "green", "blue"];
console.log(colors[0]); // red
console.log(colors[colors.length - 1]); // blue — last element
console.log(colors.at(-1)); // blue — modern, cleaner way to access from the end
colors[1] = "yellow";
console.log(colors); // ["red", "yellow", "blue"]
.at(-1) is a relatively recent addition I use constantly now instead of arr[arr.length - 1] — it’s simply more readable, and it also works cleanly with negative indices for any position from the end.
Adding and Removing Elements
const stack = [1, 2, 3];
stack.push(4); // add to end -> [1, 2, 3, 4]
stack.pop(); // remove from end -> [1, 2, 3]
stack.unshift(0); // add to start -> [0, 1, 2, 3]
stack.shift(); // remove from start -> [1, 2, 3]
console.log(stack); // [1, 2, 3]
| Method | Adds/Removes | Position | Mutates? |
|---|---|---|---|
push() | Adds | End | Yes |
pop() | Removes | End | Yes |
unshift() | Adds | Start | Yes |
shift() | Removes | Start | Yes |
splice() | Adds/Removes | Anywhere | Yes |
push/pop operate at the end of the array and are O(1) — fast, regardless of array size. unshift/shift operate at the start and are O(n) — every existing element has to be re-indexed, which can matter for performance on very large arrays.
splice() for Precise Insertion and Removal
const items = ["a", "b", "c", "d"];
items.splice(1, 2); // remove 2 items starting at index 1
console.log(items); // ["a", "d"]
items.splice(1, 0, "x", "y"); // insert without removing
console.log(items); // ["a", "x", "y", "d"]
const removed = items.splice(0, 1, "z"); // replace one item, capture what was removed
console.log(items, removed); // ["z", "x", "y", "d"], ["a"]
I use splice() whenever I need to insert, remove, or replace elements at a specific index — it’s the most versatile (and most commonly misused, due to its mutating nature) array method.
Searching Arrays
const nums = [5, 12, 8, 130, 44];
console.log(nums.indexOf(8)); // 2
console.log(nums.indexOf(999)); // -1 — not found
console.log(nums.includes(130)); // true
console.log(nums.find((n) => n > 100)); // 130
console.log(nums.findLast((n) => n < 50)); // 44 — searches from the end
Iterating Over Arrays
const letters = ["a", "b", "c"];
for (let i = 0; i < letters.length; i++) {
console.log(i, letters[i]);
}
for (const letter of letters) {
console.log(letter);
}
for (const [index, letter] of letters.entries()) {
console.log(index, letter);
}
letters.forEach((letter, index) => console.log(index, letter));
I use for...of for simple iteration where I don’t need the index, .entries() when I need both index and value together in a for...of, and forEach() when I want a method-chaining style with a callback.
Multi-dimensional Arrays
const grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
console.log(grid[1][2]); // 6
for (const row of grid) {
console.log(row.join(" "));
}
// 1 2 3
// 4 5 6
// 7 8 9
Array-Like Objects vs. Real Arrays
This distinction confused me for a long time. Things like arguments, NodeList (from querySelectorAll), and HTMLCollection (from getElementsByClassName) look like arrays — they have a .length and indexed elements — but they don’t have array methods like .map() or .filter() by default.
function example() {
console.log(arguments.length); // works
// arguments.map(x => x * 2); // TypeError: arguments.map is not a function
}
const realArray = Array.from(arguments); // convert to a real array first
const nodeList = document.querySelectorAll("div");
const realArray2 = [...nodeList]; // spread also converts array-likes (that are iterable) into real arrays
Array.from() works on any array-like or iterable object; spread (...) only works on iterables specifically — NodeList happens to be iterable, but not every array-like object is, so Array.from() is the more universally reliable conversion tool.
Sparse Arrays
const sparse = [1, , 3]; // note the empty slot at index 1
console.log(sparse.length); // 3
console.log(sparse[1]); // undefined
sparse.forEach((item) => console.log(item)); // skips the empty slot entirely! Only logs 1 and 3
console.log(sparse.map((x) => x * 2)); // [2, <1 empty item>, 6] — also skips it
I avoid creating sparse arrays intentionally, since methods like forEach, map, and filter all silently skip empty slots, which can hide bugs if I’m not careful.
Typed Arrays (Brief Overview)
For performance-sensitive numeric data (like working with binary data, WebGL, or audio buffers), JavaScript provides typed arrays, which store a fixed-type sequence of numbers far more efficiently than regular arrays.
const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
int32View[0] = 42;
console.log(int32View[0]); // 42
console.log(int32View.length); // 4 (16 bytes / 4 bytes per Int32)
I don’t use typed arrays in everyday app code, but they’re essential for anything involving binary data or heavy numeric computation, like image processing or WebAssembly interop.
Internal Working: How Arrays Are Actually Stored
This surprised me when I first learned it: JavaScript arrays are not true fixed-type, contiguous-memory arrays the way they are in languages like C. Under the hood, engines like V8 use different internal representations depending on how the array is used:
- Packed SMI (small integer) arrays: fastest, used when all elements are small integers.
- Packed double arrays: used when elements are floating-point numbers.
- Packed/holey element arrays: used for mixed types or objects.
- Dictionary mode: the slowest representation, used when an array becomes sparse or has too many non-index properties attached.
This is why performance-sensitive code benefits from keeping arrays homogeneous (same type throughout) and dense (no holes) — V8 can use faster internal representations, whereas mixing types or creating sparse arrays forces a fallback to slower, more general storage.
const fast = [1, 2, 3, 4, 5]; // likely a packed SMI array internally
const slower = [1, "two", 3, {}]; // mixed types force a more general representation
I don’t obsess over this in everyday code, but for hot loops processing large datasets, keeping arrays homogeneous is a genuinely useful optimization to know about.
Practical, Real-World Patterns
Chunking an Array into Smaller Groups
function chunk(array, size) {
const result = [];
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size));
}
return result;
}
console.log(chunk([1, 2, 3, 4, 5, 6, 7], 3));
// [[1, 2, 3], [4, 5, 6], [7]]
Removing Falsy Values
const mixed = [0, "hello", "", null, 42, undefined, false, "world"];
const truthy = mixed.filter(Boolean);
console.log(truthy); // ["hello", 42, "world"]
Building a Simple Pagination Helper
function paginate(items, page, perPage) {
const start = (page - 1) * perPage;
return items.slice(start, start + perPage);
}
const allItems = Array.from({ length: 25 }, (_, i) => i + 1);
console.log(paginate(allItems, 2, 10)); // [11, 12, ..., 20]
Best Practices
- I use array literals over the
Arrayconstructor to avoid the single-argument-length gotcha. - I use
.at(-1)instead ofarr[arr.length - 1]for cleaner end-of-array access. - I use
Array.from()to reliably convert array-like or iterable objects into real arrays. - I avoid creating sparse arrays, since built-in iteration methods silently skip empty slots.
- I keep arrays homogeneous in performance-critical code paths.
Common Mistakes to Avoid
- Using
new Array(5)expecting an array containing5, instead of a sparse array of length 5. - Calling array methods directly on array-like objects like
NodeListorargumentswithout converting them first (though modernNodeListdoes supportforEachnatively, it still lacksmap/filter). - Forgetting
unshift/shiftare O(n) operations, which can cause performance issues on very large arrays used as queues. - Not realizing sparse arrays silently skip holes in
forEach,map, andfilter.
Debugging Tips
- I use
console.table(array)for arrays of objects to get an instantly readable, sortable table view. - I check
Array.isArray(value)before calling array methods on a value I’m not 100% sure is an actual array. - For unexpected
undefinedresults, I check whether I’ve accidentally created a sparse array with a stray comma or an out-of-bounds index assignment.
FAQs
Q: How do I check if a variable is actually an array? A: Array.isArray(value) — this is more reliable than typeof value === "object", since arrays report typeof "object" too.
Q: What’s the fastest way to remove duplicates from an array? A: [...new Set(array)] — clean, concise, and handles primitive duplicates efficiently.
Q: Why does [1, 2, 3].length = 1 truncate the array? A: length is a writable property directly tied to the array’s internal element count — setting it to a smaller value actually deletes the trailing elements.
Q: Are arrays passed by value or by reference in function calls? A: By reference (technically, the reference itself is passed by value) — modifying an array’s contents inside a function affects the original array outside it, unless you explicitly copy it first.
Summary and Key Takeaways
- Prefer array literals over the
Arrayconstructor to avoid confusing edge cases. push/popare fast (O(1));unshift/shiftare slower (O(n)) since they re-index the whole array.splice()is the versatile tool for inserting, removing, or replacing elements at specific positions.- Array-like objects (
arguments,NodeList) need conversion viaArray.from()or spread before using array methods. - Avoid sparse arrays — built-in iteration methods silently skip holes, which can hide bugs.
- Engines optimize homogeneous, dense arrays far better than mixed-type or sparse ones.
Arrays look simple on the surface, but understanding their edge cases and internal representation has repeatedly helped me avoid subtle bugs and write more performant code when working with large or complex datasets.