Cache eviction algorithms (LRU, SIEVE, FIFO) need to find the 'nth oldest' entry to evict when the cache reaches capacity.
Here's a simplified LRU (Least Recently Used) cache using a Map:
class LRUCache {
constructor(limit) {
this.limit = limit;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return null;
const value = this.cache.get(key);
// Refresh: delete and re-insert to update order
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.limit) {
// Evict the FIRST (oldest) entry
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}
Currently, getting the 'first' key is easy (.next().value). But getting the 'nth' key (e.g., for SIEVE, which needs to traverse from a 'hand' position) requires either:
[...cache.keys()][n] (O(n) memory)
Or manual iteration with a counter (verbose)
map.at(n) would make this cleaner and more memory-efficient, especially for caches with millions of entries where spreading to an array is prohibitive.
ok, but the only reason to use a Map there - instead of a normal object - is if you can't represent the keys as strings. I've never seen a cache that doesn't use strings as keys - even if only for logging/debugging.
Insertion order is guaranteed (objects have edge cases with numeric keys)
.size is directly available
Iteration is more ergonomic
Deletion is faster
No risk of prototype pollution (__proto__ keys)
That's why many modern cache implementations (including LRU caches in popular libraries) use Map even when keys are strings. It's not about key type — it's about the guaranteed order, cleaner API, and better performance for deletions.
If Maps were only for non-string keys, the committee wouldn't have added guaranteed insertion order for all keys. The fact that they did suggests Maps are intended as a general-purpose ordered collection — not just a 'non-string object replacement.'
Anytime I've had to implement an LRU or similar cache, I've just kept another structure for the order. Removing and re-inserting into a map/set is just too expensive. And as noted a lot of cache cases also need weak keys, which means using Weak collections that aren't iterable.
I honestly don't see many use cases for indexing map/set.
Thanks for sharing your experience. A few responses:
Separate order structure — That works, but it doubles memory (Map + array). For large caches, that's a real cost. Map already tracks insertion order internally — .at(n) just exposes it without the memory overhead.
Re-insertion cost — Map delete + set is O(1) and very fast in practice. Many popular LRU implementations (like lru-cache ) use exactly this pattern. If re-insertion were 'too expensive,' they wouldn't exist.
Weak collections — You're right, WeakMap/WeakSet aren't iterable and this proposal doesn't apply to them. That's fine — .at(n) is only for iterable collections (Map, Set).
The use cases exist even if they don't match your specific needs. The SIEVE algorithm example earlier in this thread is one real implementation that would benefit from .nth() access.
Are there examples in other languages with Set/Map that have an API like this? I had a quick look at Kotlin, Python, and C++ and didn't see one though may have missed it. C++ sets do have a reverse iterator, which might help for the .at(-1) use cases.
Are there also cases where the code is after the nth key as opposed to the value, if so this suggests the possibility of also wanting a keyAt(index) method too.
Prior art: You're right — most languages don't have direct indexed access on Map/Set. C++'s std::next(map.begin(), n) is probably the closest, but it's iterator-based, not a method. Python and Kotlin developers commonly use list(dict.keys())[n] — same workaround we have in JS. JavaScript already set a precedent with Array.prototype.at ; extending that ergonomics to ordered Maps/Sets seems consistent, even if other languages haven't done it.
Key vs. value: Great observation. The SIEVE algorithm example earlier needs the full entry ([key, value] ) to check a visited flag and then delete by key. That's why I proposed .entryAt(n) — it covers both in one method. A separate .keyAt(n) could also be useful, but I'd argue .entryAt(n).key is clear enough without adding API surface. The goal is to solve the real use cases without over-engineering.
Looking at some browser's current implementations it looks like SpiderMonkey and V8 do store the keys as a flat array so would be able to efficiently index into it, but JavaScriptCore use a linked list to store key order for the iterator - so either that would need to change or the API remains linear and it would still be faster for the JS code to store its own array to improve chance of constant time lookup.
There would be a similar (small) complication for polyfills where it might be better for code to avoid using the polyfill and keep using their own array until the method is available natively. Though this problem can arise for any proposal that falls into this pattern so not unique. Just a consideration.
In my opinion introducing these would do more harm than good, precisely because it would give the false impression that indexed access on map/set is just as cheap as on array.
I don't find any of the presented use cases compelling.
Get last 5 notifications without O(n) memory explosion
If your application frequently needs N most recent elements of a map, you need a reverse iterator, not indexed access. I don't know how feasible that would be for existing engines.
Pagination with Sets (unique active users)
Just use iterator.drop, it will traverse the set at most once. Imagine extracting the 100th page with .at(), you'd be skipping the preceding 99 pages itemsPerPage-times.
SIEVE
If you want an efficient cache eviction algorithm, you cannot implement it in such a way that reading the hand is O(n). Good news is, you can implement SIEVE with just a Map and an iterator for hand, there's no need for indexed access.
Thanks for the engine-level analysis — this is really valuable.
A few thoughts:
O(n) is fine : Array.prototype.at is O(1) because arrays are contiguous. For Maps/Sets, O(n) is acceptable as long as it's documented. Developers already pay O(n) when they do [...map.keys()][n] : but they also pay O(n) memory. .at(n) would be O(n) time but O(1) memory, which is still a win.
JSC could optimize later : Even if it's O(n) today, nothing prevents JSC from changing to a flat array in the future. The API doesn't mandate performance, just behavior.
Polyfill concern : You're right, but that's true for many proposals. Developers can feature-detect and use native when available, fall back to their own array otherwise. The existence of a polyfill doesn't block the proposal.
Really appreciate you digging into the engine details. This is exactly the kind of discussion I was hoping for.
Thanks for the detailed critique — this is exactly the kind of technical discussion that strengthens proposals.
On engine internals: You're right that deletions leave holes. But even a sparse array traversal is O(n) — same as spread, but without the O(n) memory allocation. The win is memory, not time. I'd never claim O(1); documentation can clarify the performance characteristics.
On false impression: Developers already do [...map.keys()][n] today. That gives the same false impression and allocates an array. .at(n) at least removes the allocation. The footgun already exists — this makes it less dangerous, not more.
On specific use cases:
Reverse iterator — Great idea, but separate from .at(-1) which is simpler for common "get last" needs.
Pagination — For random page jumps, .at(page * pageSize) is O(1) memory and same O(n) time as .drop() . But .drop() re-traverses from start each time; .at(n) could be used with a stored index.
SIEVE — The delegate who posted the SIEVE example explicitly said .nth() would be 'handy.' Could it work without it? Yes. But .entryAt(n) would make the implementation cleaner and more memory-efficient.
I appreciate the pushback, it's helping refine the proposal. The core value remains: O(n) time with O(1) memory, replacing O(n) time with O(n) memory. That's a real improvement for large Maps, even if not everyone needs it.
Thanks everyone for the detailed discussion — this has been incredibly educational.
After reading through the engine-level concerns (especially around JSC's linked list implementation, sparse array holes, and the risk of giving a false impression of O(1) performance), I understand why this proposal faces an uphill battle.
The core trade-off — O(n) time with O(1) memory — is technically valid, but JavaScript's design philosophy prioritizes other concerns for Map and Set. The existing workarounds (iterator helpers, manual arrays, or accepting the allocation) are likely "good enough" for the vast majority of use cases.
Rather than continue pushing into diminishing returns, I'm going to step back. This thread has given me a much better understanding of how TC39 evaluates proposals — and I'm grateful for everyone's time and expertise.
If someone else wants to champion this in the future, I'd be happy to support. But for now, I think it's best to let this rest.
Thanks again — especially to Jordan, aclaymore, and lightmare for the thoughtful pushback. This was a great learning experience.