The .NET doubly linked list: node API, O(1) operations given a node, memory and cache costs, and the LRU cache pattern.
LinkedList<T> is a doubly linked list of LinkedListNode<T> objects, each with Value, Next, Previous and List. Given a node, AddBefore, AddAfter, Remove(node), AddFirst and AddLast are O(1). But finding a node by value (Find, Remove(value), Contains) is O(n), there is no indexer, every element is a separate heap object (about 48 bytes for an int on 64-bit), and walking it misses the CPU cache. So List<T> is faster for almost everything. Use LinkedList<T> when you already hold node references and need O(1) reordering, the classic case being an LRU cache: Dictionary<TKey, LinkedListNode<Entry>> plus a LinkedList<Entry> ordered by recent use.
LinkedList<T> is a doubly linked, circular list of nodes. Its one real superpower is O(1) insertion and removal when you already hold a LinkedListNode<T>. Everything else — indexing, searching, iterating — is slower than List<T> in practice because every element is a separate heap object. So I use it rarely, and the classic legitimate use is an LRU cache combined with a Dictionary.
LinkedListNode<T> object with Value, Next, Previous and a List back-reference.First node and a Count; internally it's circular (the head's previous is the tail), and Last is head.prev.var list = new LinkedList<string>();
LinkedListNode<string> a = list.AddLast("A");
LinkedListNode<string> c = list.AddLast("C");
list.AddBefore(c, "B"); // O(1) given the node
list.AddAfter(c, "D"); // O(1)
list.AddFirst("Start"); // O(1)
list.Remove(a); // O(1) - remove BY NODE
list.Remove("D"); // O(n) - remove BY VALUE (searches first)
list.RemoveFirst(); list.RemoveLast(); // O(1)
LinkedListNode<string>? found = list.Find("B"); // O(n)
AddFirst, AddLast, AddBefore, AddAfter) let you keep references for later O(1) operations.InvalidOperationException.list.Remove(node); list.AddFirst(node); — no allocation.List<T>| Operation | LinkedList<T> |
List<T> |
|---|---|---|
| Add/remove at the end | O(1) + node allocation | Amortised O(1), no per-item allocation |
| Add/remove at the front | O(1) | O(n) shift |
| Insert/remove in the middle with a node reference | O(1) | O(n) shift |
| Insert/remove in the middle by index or value | O(n) to find + O(1) | O(n) |
| Access by index | O(n) (no indexer) | O(1) |
| Search | O(n), pointer chasing | O(n), contiguous, vectorised for primitives |
| Iterate | O(n), cache-unfriendly | O(n), cache-friendly |
| Memory per element | ~48 bytes node object (64-bit) + the value | Just the slot (4–8 bytes for small types) |
In benchmarks, iterating a List<int> is often 5–10× faster than a LinkedList<int> of the same size because of cache locality, and even List<T>.Insert in the middle beats LinkedList for small-to-medium sizes when you have to find the position first. Big-O hides constant factors and memory behaviour.
Queue<T> or deque doesn't fit (though a circular buffer is usually better).node.Next first).Otherwise: List<T>, Queue<T>, Stack<T>, or SortedSet<T>.
A Least Recently Used cache needs O(1) lookup and O(1) "move to most-recent" and "evict least-recent":
public sealed class LruCache<TKey, TValue>(int capacity) where TKey : notnull
{
private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new(capacity);
private readonly LinkedList<(TKey Key, TValue Value)> _order = new(); // First = most recent
private readonly Lock _lock = new(); // .NET 9 Lock type
public bool TryGet(TKey key, out TValue value)
{
lock (_lock)
{
if (_map.TryGetValue(key, out var node))
{
_order.Remove(node); // O(1)
_order.AddFirst(node); // O(1) - mark as most recent, no allocation
value = node.Value.Value;
return true;
}
value = default!;
return false;
}
}
public void Set(TKey key, TValue value)
{
lock (_lock)
{
if (_map.TryGetValue(key, out var existing))
{
existing.Value = (key, value);
_order.Remove(existing);
_order.AddFirst(existing);
return;
}
if (_map.Count == capacity)
{
var lru = _order.Last!; // least recently used
_order.RemoveLast();
_map.Remove(lru.Value.Key);
}
_map[key] = _order.AddFirst((key, value));
}
}
}
The dictionary maps keys to nodes, so finding a node is O(1); the linked list keeps recency order, so moving and evicting are O(1).
In production I'd usually reach for IMemoryCache (with SizeLimit and sliding expiration) or HybridCache (.NET 9) rather than hand-rolling, unless I need strict LRU semantics.
A pricing service cached computed quotes in a Dictionary with no eviction and memory grew until the container was OOM-killed every few days. A first attempt at LRU used a List<string> of keys with Remove(key) + Insert(0, key) on every hit — O(n) per access — and CPU rose sharply at ~100,000 entries. Replacing it with the Dictionary + LinkedList design made hits O(1); after load testing we eventually moved to IMemoryCache with a size limit because it also handled expiry and memory pressure.
Checks whether the candidate knows the textbook claims and the real-world costs, and can apply the structure correctly.
Remove(value) in an LRU, or thinks LinkedList<T> supports indexing.public sealed class LinkedListNode<T>
{
internal LinkedList<T>? list;
internal LinkedListNode<T>? next;
internal LinkedListNode<T>? prev;
internal T item;
}
On 64-bit: 16 bytes of object header + MethodTable, three 8-byte references, plus T (8 bytes for a reference, padded). That's ~48 bytes per node for LinkedList<int> — versus 4 bytes per element in List<int>. For a million elements, ~48 MB vs ~4 MB, plus a million objects for the GC to trace.
CPUs load memory in 64-byte cache lines and prefetch sequential addresses. Traversing a List<T> walks contiguous memory, so the prefetcher keeps data ready. Traversing a linked list follows next pointers to arbitrary addresses — each hop can be a cache miss (~100 ns from DRAM). Nodes allocated close together initially may be adjacent, but after the GC compacts and the list is reordered, locality degrades.
This is why Bjarne Stroustrup's well-known benchmark showed vectors beating linked lists even for middle insertion: finding the position (a linear scan) dominates, and scanning arrays is far faster.
.NET's LinkedList<T> keeps only head; head.prev is the tail. AddLast(x) is "insert before head" (because the list is circular), and Last returns head.prev. The public Next/Previous properties return null at the ends even though the internal links wrap — the property getters hide the circularity.
LinkedList<T> has a version field and its enumerator throws if the list is modified during foreach. But you can traverse manually and modify safely:
for (var node = list.First; node is not null; )
{
var next = node.Next; // save before removal
if (ShouldRemove(node.Value)) list.Remove(node);
node = next;
}
| Operation | Dictionary | LinkedList | Total |
|---|---|---|---|
| Get (hit) | O(1) lookup | O(1) move to front | O(1) |
| Get (miss) | O(1) | — | O(1) |
| Set (new, not full) | O(1) insert | O(1) AddFirst (allocates a node) | O(1) |
| Set (new, full) | O(1) remove LRU + insert | O(1) RemoveLast + AddFirst | O(1) |
Performance refinements:
(Key, Value) in the node so eviction can remove the dictionary entry without a reverse lookup.IMemoryCache and HybridCacheMicrosoft.Extensions.Caching.Memory.MemoryCache:
SizeLimit with per-entry Size.HybridCache (.NET 9) layers an in-memory cache over a distributed cache (Redis) with stampede protection (only one caller computes a missing value). For most service caching needs, these are better than a custom LRU.
LinkedList<T>Queue<T> for one end).SortedSet<T>/SortedDictionary.Next/Prev in your own objects (e.g. pooled objects) avoids separate node allocations.List<int> vs LinkedList<int>.IMemoryCache configured with SizeLimit under a load test.public sealed class LruCache<TKey, TValue>(int capacity) where TKey : notnull
{
private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map = new(capacity);
private readonly LinkedList<(TKey Key, TValue Value)> _order = new();
public bool TryGet(TKey key, out TValue value)
{
if (_map.TryGetValue(key, out var node))
{
_order.Remove(node); // O(1): we hold the node
_order.AddFirst(node); // reuse the node, no allocation
value = node.Value.Value;
return true;
}
value = default!;
return false;
}
public void Put(TKey key, TValue value)
{
if (_map.TryGetValue(key, out var existing))
{
_order.Remove(existing);
_map.Remove(key);
}
else if (_map.Count >= capacity)
{
var lru = _order.Last!; // least recently used
_order.RemoveLast();
_map.Remove(lru.Value.Key);
}
_map[key] = _order.AddFirst((key, value));
}
}
// Usage
var cache = new LruCache<string, byte[]>(capacity: 3);
cache.Put("a", [1]); cache.Put("b", [2]); cache.Put("c", [3]);
cache.TryGet("a", out _); // order now a, c, b
cache.Put("d", [4]); // evicts b
LinkedList<T> for "fast insertion" without node referencesWhat happens: LinkedList<T> used because textbooks say inserts are O(1).
Why it is wrong: you must first find the position (O(n) pointer chasing), which is slower than shifting an array for most sizes.
Do instead: use List<T> unless you hold LinkedListNode<T> references.
What happens: list.ElementAt(i) in loops.
Why it is wrong: each call walks from the start — O(n) per access, O(n²) overall.
Do instead: iterate with nodes or use a List<T>.
What happens: list.Remove(value) after already finding the node.
Why it is wrong: searches again — O(n).
Do instead: keep the node and call list.Remove(node) — O(1).
List<T> for LRU orderingWhat happens: keys.Remove(k); keys.Insert(0, k); on every cache hit.
Why it is wrong: O(n) per hit; CPU grows with cache size.
Do instead: Dictionary + LinkedList with node references, or IMemoryCache.
What happens: a hand-written LRU cache used as a singleton without locking. Why it is wrong: concurrent moves corrupt the linked list and dictionary consistency. Do instead: lock (or shard), or use thread-safe caches.
What happens: otherList.AddFirst(node) without removing it first.
Why it is wrong: throws InvalidOperationException — nodes belong to one list.
Do instead: Remove(node) from the current list first, or add the value instead.
What happens: millions of small values stored in a linked list. Why it is wrong: ~48 bytes of overhead per element plus GC tracing of millions of objects. Do instead: use arrays/lists, or an index-based node pool.
foreachWhat happens: removing nodes inside a foreach loop over the list.
Why it is wrong: the enumerator's version check throws.
Do instead: iterate manually, saving node.Next before removal.
What happens: custom LRU implementations without expiry, metrics or memory limits.
Why it is wrong: reinvents functionality with more bugs (no stampede protection, no size accounting).
Do instead: use IMemoryCache, HybridCache, or a proven library; hand-roll only for specific needs.
What happens: the LRU node is removed from the list but its key stays in the dictionary. Why it is wrong: the dictionary points to a detached node — memory leak and incorrect hits. Do instead: store the key in the node and remove both together.
Seniors treat LinkedList<T> as a specialist tool and can explain why the big-O advantage rarely shows up in practice.
| Claim | Reality in .NET |
|---|---|
| "O(1) insert in the middle" | Only with a node in hand; finding it is O(n) |
| "No resizing cost" | One allocation per element instead |
| "Good for large data" | Poor cache locality and high GC load |
| "Good for LRU and splicing" | Yes, with a dictionary of nodes |
| "Good as a deque" | Yes, .NET has no built-in deque |
For production caches they usually prefer MemoryCache or a proven library, and only hand-roll an LRU when they need exact behaviour and have benchmarks.
A linked list stores each element in a node that points to the next node (singly linked) or to both neighbours (doubly linked). Nodes are separate heap objects, not contiguous.
.NET's LinkedList<T> is a doubly linked, circular list of LinkedListNode<T> objects. In practice List<T> is faster for most workloads because arrays are cache-friendly and allocate less; linked list nodes are scattered and each costs an object allocation.
Use a linked list when you need frequent O(1) removal or move of known nodes, such as an LRU cache (Dictionary of key → node + linked list for recency order).
List<T> wraps a T[] plus a _size count and a _version counter. When the array is full, Add allocates a new array twice as large (starting at 4) and copies, so Add is amortised O(1). Insert and Remove shift elements, so they are O(n). Every change increments _version; the enumerator checks it on each MoveNext and throws InvalidOperationException if the list changed. In hot paths, pre-size with the constructor or EnsureCapacity, and use CollectionsMarshal.AsSpan to work on the backing array without copying (but never add or remove while holding that span).
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
How to tell it (STAR)
**Situation:** Our tile server at [company] got slower as its in-memory cache filled, which made no sense for a cache. **Task:** I needed to find out why and fix it before a [marketing launch] that would raise traffic. **Action:** A flame graph showed most CPU in `List<T>.Remove` and `Insert`. The LRU tracked recency in a `List<string>`, so each hit shifted [50,000] keys twice. I rewrote it with a dictionary of linked list nodes so moving an entry to the front is O(1), added a lock because requests run in parallel, and wrote benchmarks across cache sizes. **Result:** Cache-hit latency dropped from [X microseconds] to [Y nanoseconds], CPU fell [about X%], and latency now improves as the cache warms. The benchmark runs in CI so the regression cannot return quietly.
Last updated 30 Oct 2026.