Build an LRU Cache Engine in Node.js
Build a production-grade LRU cache from scratch with O(1) operations.
Introduction
30 Second Summary
Every app you use relies on caching to stay fast. Whether it's your browser remembering images or a backend storing database results, caches decide what to keep and what to throw away when space runs out.
In this project, you will build an LRU Cache Engine from scratch in Node.js. You will implement the data structures, eviction logic, TTL expiration, and a REST API to interact with your cache.
What You'll Build
A production-grade, in-memory LRU Cache Engine implemented entirely from scratch using a Doubly Linked List and a HashMap for O(1) time complexity on all operations.
By the end of this project, you'll have:
- A Doubly Linked List and LRU Cache class with O(1) get, set, and delete operations built from scratch.
- A TTL expiration system using both lazy checking on reads and an active background sweep to prevent memory leaks.
- An Express REST API exposing cache operations with a live metrics endpoint tracking hit rate, evictions, and expirations.
- Secret Mission: Implement an LFU (Least Frequently Used) eviction policy and wire it up via the Strategy Pattern so eviction algorithms are swappable at runtime.
Are there any prerequisites?
You should have working knowledge of JavaScript and familiarity with data structures concepts. Node.js must be installed on your machine (v22 or higher).
Set Up the Project
Before writing any cache logic, you need a clean project foundation. The data structures you build in later steps depend on ESM imports, a test runner to prove correctness, and a server to expose the cache over HTTP.
In this step, you will scaffold the project directory, install Express 5, confirm your Node.js version, and verify that both the server and the built-in test runner work.
In this step, get ready to:
- Create the project directory and install Express.
- Verify your Node.js version and confirm the server responds.
- Run a smoke test with the Node.js built-in test runner.
Initialize the project
You need a project folder with a package.json configured for ESM modules and Express installed as the only dependency.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir lru-cache-engine
cd lru-cache-engine
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to skip the interactive questionnaire and generate a default package.json immediately. You will replace its contents in the next step.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the lru-cache-engine folder on your Desktop, then click Open.
- Open package.json from the Explorer sidebar on the left.
- Replace the entire contents of package.json with the following:
{
"name": "lru-cache-engine",
"version": "1.0.0",
"type": "module",
"description": "LRU Cache Engine built from scratch for interview prep",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "5.2.1"
}
}
What does this configuration do?
The "type": "module" field enables ESM syntax so you can use import and export instead of require().
The test script uses node --test which invokes Node.js's built-in test runner. Zero extra dependencies needed.
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
- Return to your terminal and install the declared dependency by running:
npm install
- Confirm Express installed by running:
npm list express
You should see express@5.2.1 listed in the output.
Seeing an error about missing dependencies?
Make sure your package.json has the dependencies field with "express": "5.2.1" before running npm install. If you see a permissions error, try running with sudo npm install on macOS.
Still stuck? Help me fix my npm install error
- Create the src/ and test/ directories by running:
mkdir src test
- Confirm the directories exist by running:
ls
You should see node_modules, package-lock.json, package.json, src, and test in the output.
Verify the environment
This project uses ESM syntax and the built-in test runner, both of which require Node.js 22 or higher.
- Check your Node.js version by running:
node --version
✔️ I see version 22 or higher
Your Node.js version is compatible. Move on to the next action below.
ⓧ I see an older version
You need Node.js 22 or higher for the built-in test runner and stable ESM support.
- If you use nvm, install and activate the latest LTS by running:
nvm install --lts
nvm use --lts
- If you do not use nvm, visit nodejs.org and download the latest LTS installer for your platform.
- After upgrading, run node --version again to confirm v22 or higher.
ⓧ Command not found
Node.js is not installed on your machine.
- Visit nodejs.org and download the LTS installer (v22 or v24).
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version to confirm it installed correctly.
Now create the minimal server file that will eventually serve your cache API.
- In VS Code, right-click the src folder in the Explorer sidebar.
- Click New File and name it server.js.
- Paste the following code into src/server.js:
import express from 'express';
const app = express();
app.use(express.json());
// Health check endpoint to verify the server is running
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Start the server on port 3000 (or PORT env variable)
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Cache engine running on http://localhost:${PORT}`);
});
export default app;
What does this code do?
This sets up a minimal Express server with JSON body parsing and a single /health route. You will add cache routes here in a later step.
The export default app at the bottom makes the app importable for testing without starting the server again.
- Save src/server.js.
- Start the server by running:
node src/server.js
You should see Cache engine running on http://localhost:3000 printed in your terminal.
- Open a second terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows).
- Test the health endpoint by running:
curl http://localhost:3000/health
You should see {"status":"ok"} in the response.
- Return to the first terminal tab where the server is running.
- Stop the server by pressing Ctrl+C.
Seeing EADDRINUSE or connection refused?
If port 3000 is already in use, another process is occupying it. Stop that process or change the port by running PORT=3001 node src/server.js instead.
If curl says connection refused, make sure the server is still running in the first terminal tab before testing.
Need more help? Help me debug my Express server startup
Confirm the test runner works
Node.js ships a built-in test runner via the node:test module. You will use it throughout this project to prove your cache behaves correctly.
- In VS Code, right-click the test folder in the Explorer sidebar.
- Click New File and name it smoke.test.js.
- Paste the following code into test/smoke.test.js:
import { describe, it } from 'node:test';
import assert from 'node:assert';
// A minimal test to confirm the runner works
describe('Smoke Test', () => {
it('should pass', () => {
assert.strictEqual(1 + 1, 2);
});
});
What does this code do?
The describe and it functions come from the node:test module. They structure tests into suites and individual cases just like Jest or Mocha.
The assert.strictEqual call verifies that two values are exactly equal. If they are not, the test fails with a clear diff.
- Save test/smoke.test.js.
- Run the smoke test by running:
node --test test/smoke.test.js
You should see a checkmark next to should pass and a summary showing 1 test passed, 0 failed.
Test failing or showing an error?
If you see a syntax error about import, confirm that package.json has "type": "module" set. Without it, Node.js treats files as CommonJS and import statements fail.
If the runner says it cannot find the file, double-check that the file is named smoke.test.js inside the test/ directory (not the project root).
Still stuck? Help me fix my Node.js test runner error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full content of each file at this point.
{
"name": "lru-cache-engine",
"version": "1.0.0",
"type": "module",
"description": "LRU Cache Engine built from scratch for interview prep",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "5.2.1"
}
}
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Cache engine running on http://localhost:${PORT}`);
});
export default app;
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('Smoke Test', () => {
it('should pass', () => {
assert.strictEqual(1 + 1, 2);
});
});
Your project skeleton is in place. Next up, you will build the Doubly Linked List that powers every O(1) operation in the cache.
Build the Doubly Linked List
Your project structure is ready. Now it's time to build the core data structure that makes the entire LRU Cache possible.
An LRU Cache needs to track which entries were used most recently. A Doubly Linked List lets you add, remove, and reorder entries in O(1) time because every operation just rewires a few pointers. No loops. No searching. This is the key insight interviewers want to hear.
In this step, get ready to:
- Implement a Node class and DoublyLinkedList class with O(1) pointer operations.
- Write a test suite that proves every DLL operation works correctly.
- Run the tests and confirm all pass green.
Implement the Node class
Every entry in the cache will be represented as a Node. Each node holds a key-value pair and pointers to the nodes before and after it in the list.
- Create a new file src/DoublyLinkedList.js and add the Node class by pasting this code:
export class Node {
constructor(key, value) {
this.key = key;
this.value = value;
// Pointers to neighboring nodes in the list
this.prev = null;
this.next = null;
// Used later for TTL expiration
this.expiresAt = null;
}
}
What does this code do?
Each Node stores a key (for Map lookup) and a value (the cached data). The prev and next pointers let the list wire nodes together without arrays or indexes.
The expiresAt property is null for now. You will use it in a later step when you add TTL expiration.
You won't see a change yet. The DoublyLinkedList class in the next chunk uses these nodes.
Implement the DoublyLinkedList class
The DoublyLinkedList class manages the order of nodes. It uses sentinel (dummy) head and tail nodes that never hold real data. This eliminates all null-check edge cases in every operation.
- Add the DoublyLinkedList class below the Node class in src/DoublyLinkedList.js:
export class DoublyLinkedList {
constructor() {
// Sentinel nodes eliminate null-check edge cases
this.head = new Node(null, null);
this.tail = new Node(null, null);
this.head.next = this.tail;
this.tail.prev = this.head;
this.length = 0;
}
// Insert a node right after the sentinel head (most recently used position)
addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
this.length++;
}
// Unlink a node from wherever it sits in the list
removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
this.length--;
}
// Shortcut: remove then re-add at head
moveToHead(node) {
this.removeNode(node);
this.addToHead(node);
}
// Remove the node just before the sentinel tail (least recently used)
removeTail() {
if (this.length === 0) return null;
const node = this.tail.prev;
this.removeNode(node);
return node;
}
}
Why sentinel nodes?
The dummy head and tail nodes never hold real cache entries. They exist so that every real node always has a valid prev and next pointer. Without them, you would need if (node.prev !== null) checks everywhere.
This is the production approach and exactly what interviewers expect to see. It keeps every method to 3-4 lines of pure pointer manipulation.
What does each method do?
- addToHead(node) inserts a node at the front of the list (most recently used position).
- removeNode(node) unlinks a node from its current position by rewiring its neighbors' pointers.
- moveToHead(node) combines remove and add to reposition a node as most-recently-used.
- removeTail() evicts the least-recently-used node from the back of the list.
- Save src/DoublyLinkedList.js.
You won't see runtime output yet. The test file in the next substep exercises all four methods.
Seeing a syntax error on save?
- Make sure both classes use the export keyword before class. Without it, the test file cannot import them.
- Check that your package.json still has "type": "module". ESM imports only work when this field is present.
Why is my DoublyLinkedList file showing errors?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
export class Node {
constructor(key, value) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
this.expiresAt = null;
}
}
export class DoublyLinkedList {
constructor() {
this.head = new Node(null, null);
this.tail = new Node(null, null);
this.head.next = this.tail;
this.tail.prev = this.head;
this.length = 0;
}
addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
this.length++;
}
removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
this.length--;
}
moveToHead(node) {
this.removeNode(node);
this.addToHead(node);
}
removeTail() {
if (this.length === 0) return null;
const node = this.tail.prev;
this.removeNode(node);
return node;
}
}
Write tests for the DLL
Every DLL operation claims to be O(1). The test suite proves that each method correctly manipulates pointers without breaking the list structure.
- Create a new file test/dll.test.js and paste the following test suite:
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Node, DoublyLinkedList } from '../src/DoublyLinkedList.js';
describe('DoublyLinkedList', () => {
it('addToHead places nodes in correct order', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
// Most recent addition (c) should be right after the sentinel head
assert.strictEqual(dll.head.next, c);
assert.strictEqual(c.next, b);
assert.strictEqual(b.next, a);
assert.strictEqual(a.next, dll.tail);
assert.strictEqual(dll.length, 3);
});
it('removeNode unlinks a node from the middle', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
// Remove the middle node (b)
dll.removeNode(b);
assert.strictEqual(c.next, a);
assert.strictEqual(a.prev, c);
assert.strictEqual(dll.length, 2);
});
it('moveToHead repositions an existing node', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
// Move the tail-most real node (a) to the front
dll.moveToHead(a);
assert.strictEqual(dll.head.next, a);
assert.strictEqual(a.next, c);
assert.strictEqual(dll.length, 3);
});
it('removeTail returns and unlinks the tail node', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
dll.addToHead(a);
dll.addToHead(b);
// The tail-most real node is a (added first)
const removed = dll.removeTail();
assert.strictEqual(removed, a);
assert.strictEqual(dll.tail.prev, b);
assert.strictEqual(dll.length, 1);
});
it('removeTail returns null on empty list', () => {
const dll = new DoublyLinkedList();
const removed = dll.removeTail();
assert.strictEqual(removed, null);
});
});
What do these tests prove?
- The first test verifies insertion order. The most recent addToHead call puts its node closest to the sentinel head.
- The second test removes a node from the middle and checks that neighbors wire directly to each other.
- The third test confirms moveToHead repositions a node without changing the total length.
- The last two tests cover removeTail for both a populated list and an empty list (the edge case).
- Save test/dll.test.js.
You won't see output yet. The next substep runs the test suite.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Node, DoublyLinkedList } from '../src/DoublyLinkedList.js';
describe('DoublyLinkedList', () => {
it('addToHead places nodes in correct order', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
assert.strictEqual(dll.head.next, c);
assert.strictEqual(c.next, b);
assert.strictEqual(b.next, a);
assert.strictEqual(a.next, dll.tail);
assert.strictEqual(dll.length, 3);
});
it('removeNode unlinks a node from the middle', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
dll.removeNode(b);
assert.strictEqual(c.next, a);
assert.strictEqual(a.prev, c);
assert.strictEqual(dll.length, 2);
});
it('moveToHead repositions an existing node', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
const c = new Node('c', 3);
dll.addToHead(a);
dll.addToHead(b);
dll.addToHead(c);
dll.moveToHead(a);
assert.strictEqual(dll.head.next, a);
assert.strictEqual(a.next, c);
assert.strictEqual(dll.length, 3);
});
it('removeTail returns and unlinks the tail node', () => {
const dll = new DoublyLinkedList();
const a = new Node('a', 1);
const b = new Node('b', 2);
dll.addToHead(a);
dll.addToHead(b);
const removed = dll.removeTail();
assert.strictEqual(removed, a);
assert.strictEqual(dll.tail.prev, b);
assert.strictEqual(dll.length, 1);
});
it('removeTail returns null on empty list', () => {
const dll = new DoublyLinkedList();
const removed = dll.removeTail();
assert.strictEqual(removed, null);
});
});
Run tests and verify
- Run the DLL test suite by running this command:
node --test test/dll.test.js
- You should see all 5 tests pass. Look for # pass 5 and # fail 0 in the summary at the bottom of the output.
Tests failing or not running?
- If you see ERR_MODULE_NOT_FOUND, check that the import path in test/dll.test.js is exactly '../src/DoublyLinkedList.js' with the .js extension. ESM requires the full file extension in imports.
- If a specific assertion fails, compare the pointer assignments in your addToHead method. The order of the four lines matters. You must set this.head.next.prev = node before this.head.next = node, otherwise you overwrite the reference you need.
- If you see SyntaxError: Cannot use import statement, make sure your package.json contains "type": "module".
Why are my DLL tests failing?
Why is every operation O(1)?
Look at each method. None of them contain a loop or call any method that loops. Every operation manipulates a fixed number of pointers (at most 4 pointer reassignments). That is the definition of O(1) constant time.
In an interview, this is the key talking point. The Map gives you O(1) lookup by key. The DLL gives you O(1) reordering by pointer. Combined, they make the entire LRU Cache O(1) for get, set, and delete.
Your Doubly Linked List is complete and tested. Next up, you will build the LRU Cache class that combines this DLL with a Map to achieve O(1) get, set, and eviction.
Build the LRU Cache
You have a working Doubly Linked List that can add, remove, and reposition nodes in O(1) time. Now you need a way to combine that list with a fast key lookup so you can find any cache entry instantly.
The LRU Cache pairs a HashMap (JavaScript's built-in Map) with your DLL. The Map gives O(1) key lookup, and the DLL gives O(1) recency tracking. Together, every cache operation (get, set, delete) runs in constant time.
In this step, get ready to:
- Implement an LRUCache class with O(1) get, set, and delete using Map + DLL.
- Track cache metrics (hits, misses, evictions) for observability.
- Write and run tests proving correctness of the cache behavior.
Implement the LRUCache class
The LRUCache class is the central data structure interviewers want to see. Every read moves the accessed entry to the front of the list (most recently used), and every write that exceeds capacity evicts the entry at the back (least recently used).
- Create a new file src/LRUCache.js and add the following code:
import { Node, DoublyLinkedList } from './DoublyLinkedList.js';
export class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map();
this.dll = new DoublyLinkedList();
this.hits = 0;
this.misses = 0;
this.evictions = 0;
this.expirations = 0;
this.sweepInterval = null;
}
get(key) {
const node = this.map.get(key);
if (!node) {
this.misses++;
return null;
}
if (node.expiresAt && Date.now() > node.expiresAt) {
this.delete(key);
this.expirations++;
this.misses++;
return null;
}
this.dll.moveToHead(node);
this.hits++;
return node.value;
}
set(key, value, ttl = null) {
const existing = this.map.get(key);
if (existing) {
existing.value = value;
existing.expiresAt = ttl ? Date.now() + ttl : null;
this.dll.moveToHead(existing);
return;
}
const node = new Node(key, value);
node.expiresAt = ttl ? Date.now() + ttl : null;
this.map.set(key, node);
this.dll.addToHead(node);
if (this.map.size > this.capacity) {
const evicted = this.dll.removeTail();
this.map.delete(evicted.key);
this.evictions++;
}
}
delete(key) {
const node = this.map.get(key);
if (!node) return false;
this.dll.removeNode(node);
this.map.delete(key);
return true;
}
size() {
return this.map.size;
}
getMetrics() {
const total = this.hits + this.misses;
return {
size: this.map.size,
capacity: this.capacity,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
expirations: this.expirations,
hitRate: total === 0 ? 0 : (this.hits / total * 100).toFixed(2) + '%'
};
}
startSweep(intervalMs = 1000) {
this.sweepInterval = setInterval(() => {
const now = Date.now();
for (const [key, node] of this.map) {
if (node.expiresAt && now > node.expiresAt) {
this.dll.removeNode(node);
this.map.delete(key);
this.expirations++;
}
}
}, intervalMs);
}
stopSweep() {
if (this.sweepInterval) {
clearInterval(this.sweepInterval);
this.sweepInterval = null;
}
}
}
What does this code do?
The constructor wires up the two data structures: a Map for instant key-to-node lookup, and your DoublyLinkedList for ordering entries by recency. It also initializes counters for observability metrics.
- get(key) looks up the node in the Map. If found and not expired, it moves the node to the head (marking it most recently used) and increments hits. If not found or expired, it increments misses.
- set(key, value, ttl) either updates an existing entry (moving it to head) or creates a new node at the head. If the cache exceeds capacity, it evicts the tail node (least recently used).
- delete(key) removes the entry from both the Map and the DLL, keeping them in sync.
- getMetrics() returns a snapshot of cache performance including the hit rate as a percentage.
- startSweep(intervalMs) runs a background interval that actively removes expired entries. This prevents memory leaks from entries that are never read again after expiring.
Why both Map and DLL?
A Map alone gives O(1) lookup but cannot track ordering. An array or linked list alone gives ordering but O(n) lookup. By combining both, you get O(1) for every operation. The Map finds the node instantly, and the DLL repositions or removes it without scanning.
This is the exact answer interviewers want when they ask "why not just use a Map with timestamps?" A timestamp approach requires scanning all entries to find the oldest one on eviction, making it O(n).
- Save src/LRUCache.js.
Write tests for the LRU Cache
Your tests need to prove three things: basic operations work, eviction targets the right entry, and metrics track correctly.
- Create a new file test/lru.test.js and add the following test suite:
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { LRUCache } from '../src/LRUCache.js';
describe('LRUCache', () => {
it('get and set work for basic operations', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), null);
});
it('evicts least recently used when capacity exceeded', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('d', 4);
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), 3);
assert.strictEqual(cache.get('d'), 4);
});
it('accessing a key prevents it from being evicted', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.set('d', 4);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), null);
});
it('updating a key does not increase size', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('a', 2);
assert.strictEqual(cache.size(), 1);
assert.strictEqual(cache.get('a'), 2);
});
it('delete removes entry from cache', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.delete('a');
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.size(), 0);
});
it('tracks metrics correctly', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.get('b');
cache.get('c');
const metrics = cache.getMetrics();
assert.strictEqual(metrics.evictions, 1);
assert.strictEqual(metrics.misses, 1);
assert.strictEqual(metrics.hits, 2);
});
});
What do these tests prove?
- The first test confirms basic get/set works and missing keys return null.
- The eviction test proves that when you add a 4th item to a capacity-3 cache, the oldest untouched entry ('a') gets evicted.
- The "accessing prevents eviction" test is the key LRU behavior. Reading 'a' moves it to the head, so 'b' becomes the least recently used and gets evicted instead.
- The update test confirms that setting an existing key overwrites in place without growing the cache size.
- The metrics test verifies that hits, misses, and evictions all count correctly after a known sequence of operations.
- Save test/lru.test.js.
Run the tests and verify
- Run the LRU cache tests by running this command:
node --test test/lru.test.js
You should see all 6 tests pass with green checkmarks in the TAP output.
Seeing test failures?
- Check that your import path in test/lru.test.js matches the file location exactly: '../src/LRUCache.js'.
- Make sure src/LRUCache.js imports from './DoublyLinkedList.js' with the .js extension. ESM requires full file extensions in import paths.
- Confirm "type": "module" is set in your package.json. Without it, Node.js treats files as CommonJS and import statements will fail.
Still stuck? Help me debug my LRU cache test failures
✔️ Awesome, I've got everything!
Great. Double check you've saved both src/LRUCache.js and test/lru.test.js.
ⓧ I'd like to double check the full code
Here is the complete src/LRUCache.js:
import { Node, DoublyLinkedList } from './DoublyLinkedList.js';
export class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.map = new Map();
this.dll = new DoublyLinkedList();
this.hits = 0;
this.misses = 0;
this.evictions = 0;
this.expirations = 0;
this.sweepInterval = null;
}
get(key) {
const node = this.map.get(key);
if (!node) {
this.misses++;
return null;
}
if (node.expiresAt && Date.now() > node.expiresAt) {
this.delete(key);
this.expirations++;
this.misses++;
return null;
}
this.dll.moveToHead(node);
this.hits++;
return node.value;
}
set(key, value, ttl = null) {
const existing = this.map.get(key);
if (existing) {
existing.value = value;
existing.expiresAt = ttl ? Date.now() + ttl : null;
this.dll.moveToHead(existing);
return;
}
const node = new Node(key, value);
node.expiresAt = ttl ? Date.now() + ttl : null;
this.map.set(key, node);
this.dll.addToHead(node);
if (this.map.size > this.capacity) {
const evicted = this.dll.removeTail();
this.map.delete(evicted.key);
this.evictions++;
}
}
delete(key) {
const node = this.map.get(key);
if (!node) return false;
this.dll.removeNode(node);
this.map.delete(key);
return true;
}
size() {
return this.map.size;
}
getMetrics() {
const total = this.hits + this.misses;
return {
size: this.map.size,
capacity: this.capacity,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
expirations: this.expirations,
hitRate: total === 0 ? 0 : (this.hits / total * 100).toFixed(2) + '%'
};
}
startSweep(intervalMs = 1000) {
this.sweepInterval = setInterval(() => {
const now = Date.now();
for (const [key, node] of this.map) {
if (node.expiresAt && now > node.expiresAt) {
this.dll.removeNode(node);
this.map.delete(key);
this.expirations++;
}
}
}, intervalMs);
}
stopSweep() {
if (this.sweepInterval) {
clearInterval(this.sweepInterval);
this.sweepInterval = null;
}
}
}
Here is the complete test/lru.test.js:
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { LRUCache } from '../src/LRUCache.js';
describe('LRUCache', () => {
it('get and set work for basic operations', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), null);
});
it('evicts least recently used when capacity exceeded', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('d', 4);
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), 3);
assert.strictEqual(cache.get('d'), 4);
});
it('accessing a key prevents it from being evicted', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.set('d', 4);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), null);
});
it('updating a key does not increase size', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('a', 2);
assert.strictEqual(cache.size(), 1);
assert.strictEqual(cache.get('a'), 2);
});
it('delete removes entry from cache', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.delete('a');
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.size(), 0);
});
it('tracks metrics correctly', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.get('b');
cache.get('c');
const metrics = cache.getMetrics();
assert.strictEqual(metrics.evictions, 1);
assert.strictEqual(metrics.misses, 1);
assert.strictEqual(metrics.hits, 2);
});
});
Every operation is O(1)
Walk through the time complexity of each method to confirm there are no hidden loops on the hot path.
- get(key): one Map lookup (O(1)) + one moveToHead (O(1) pointer manipulation). Total: O(1).
- set(key, value): one Map lookup + one addToHead + at most one removeTail. Total: O(1).
- delete(key): one Map lookup + one removeNode. Total: O(1).
This is the answer interviewers are looking for. If you used an array instead of a DLL, moving an element to the front would be O(n) because you'd shift every other element.
Your LRU Cache is working and tested. Next up, you'll add TTL expiration so entries can automatically expire after a set time. This is how production caches like Redis avoid serving stale data.
Add TTL Expiration
Your LRU cache evicts entries when capacity is exceeded, but what about entries that become stale over time? A cached API response from 10 minutes ago might be completely wrong now.
Your LRUCache already includes TTL (Time-To-Live) support through the code you wrote in the last step. In this step, you will understand how the two-layer expiration strategy works and write tests to prove its correctness.
In this step, get ready to:
- Understand the lazy expiration and active sweep mechanisms in your cache.
- Write tests proving TTL behavior is correct.
- Run the full test suite and verify all tests pass.
Understand how TTL expiration works in your cache
Your cache uses two complementary strategies to handle expiration. This dual approach is exactly how Redis handles TTL internally, and explaining it is a strong interview talking point.
- In src/LRUCache.js, find the get(key) method. Look at the expiration check:
if (node.expiresAt && Date.now() > node.expiresAt) {
this.delete(key);
this.expirations++;
this.misses++;
return null;
}
What is lazy expiration?
Lazy expiration means you only check if an entry is expired when someone tries to read it. If the entry has passed its expiresAt timestamp, the cache deletes it and returns null as if it never existed.
This keeps the hot path fast. You never scan all entries on every get() call. You only pay the cost of checking one timestamp for the single key being accessed.
- Now find the set(key, value, ttl = null) method. Look at where the timestamp is stored:
const node = new Node(key, value);
node.expiresAt = ttl ? Date.now() + ttl : null;
How does the TTL get stored?
When ttl is provided (in milliseconds), the cache computes an absolute expiration timestamp using Date.now() + ttl. Storing an absolute timestamp means the expiration check is a single comparison, not a calculation.
If ttl is not provided, expiresAt stays null and the entry lives until evicted by LRU.
- Finally, find the startSweep(intervalMs) method at the bottom of the class:
startSweep(intervalMs = 1000) {
this.sweepInterval = setInterval(() => {
const now = Date.now();
for (const [key, node] of this.map) {
if (node.expiresAt && now > node.expiresAt) {
this.dll.removeNode(node);
this.map.delete(key);
this.expirations++;
}
}
}, intervalMs);
}
Why do you need active sweep if lazy expiration exists?
Lazy expiration only cleans up entries that are accessed. If an entry expires but nobody ever reads it again, it stays in memory forever. The active sweep runs on a setInterval and periodically scans for expired entries to prevent memory leaks.
The stopSweep() method calls clearInterval to shut down the background process. This is critical for clean test teardown and graceful server shutdown.
Write tests for lazy expiration
The TTL logic has multiple code paths: entries that expire, entries that don't, and background cleanup. You need tests for each to be confident the implementation is correct.
- In test/lru.test.js, find the last closing }); of the tracks metrics correctly test. Add these two tests below it (but still inside the describe block):
it('TTL causes entry to expire on get', async () => {
const cache = new LRUCache(10);
cache.set('temp', 'data', 50);
// Entry is accessible before TTL expires
assert.strictEqual(cache.get('temp'), 'data');
// Wait longer than the 50ms TTL
await new Promise(resolve => setTimeout(resolve, 60));
// Entry should now be gone (lazy expiration on read)
assert.strictEqual(cache.get('temp'), null);
});
it('entry without TTL never expires', async () => {
const cache = new LRUCache(10);
cache.set('permanent', 'data');
// Wait the same duration as the TTL test above
await new Promise(resolve => setTimeout(resolve, 60));
// Entry without TTL should still be accessible
assert.strictEqual(cache.get('permanent'), 'data');
});
What do these tests prove?
The first test sets an entry with a 50ms TTL, confirms it's accessible immediately, waits 60ms, then checks that get() returns null. This exercises the lazy expiration path.
The second test verifies entries without a TTL never expire, even after the same wait. This prevents a regression where all entries accidentally get an expiration.
- Save test/lru.test.js.
Tests timing out or flaky?
TTL tests rely on real time passing. If your machine is under heavy load, a 60ms setTimeout might resolve slightly early. Try increasing the delay from 60 to 80 if you see intermittent failures.
Make sure both tests are marked async. Without async, the await won't pause execution and the test will pass before the TTL expires.
Help me debug my TTL test failures
Write a test for active sweep
The active sweep test proves that expired entries are removed in the background, even without being accessed.
- In test/lru.test.js, add this test below the entry without TTL never expires test (still inside the describe block):
it('active sweep removes expired entries', async () => {
const cache = new LRUCache(10);
cache.set('x', 1, 50);
cache.set('y', 2);
// Start background sweep every 30ms
cache.startSweep(30);
// Wait long enough for TTL to expire and sweep to run
await new Promise(resolve => setTimeout(resolve, 100));
cache.stopSweep();
// 'x' should have been swept away, 'y' (no TTL) remains
assert.strictEqual(cache.size(), 1);
assert.strictEqual(cache.get('y'), 2);
});
What does this test prove?
This test sets entry x with a 50ms TTL and entry y without a TTL. The sweep runs every 30ms. After 100ms, the sweep should have found and removed x without anyone calling get('x').
The stopSweep() call is critical. Without it, the interval keeps running and the test process never exits cleanly.
- Save test/lru.test.js.
Test process hangs and never exits?
If your test process keeps running after all tests complete, you likely forgot cache.stopSweep(). The active setInterval keeps the Node.js event loop alive indefinitely. Add cache.stopSweep() before your assertions.
Help me fix my sweep test that hangs
✔️ Awesome, I've got everything!
Great. Double check you have saved test/lru.test.js before running the tests.
ⓧ I'd like to double check the full code
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { LRUCache } from '../src/LRUCache.js';
describe('LRUCache', () => {
it('get and set work for basic operations', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), null);
});
it('evicts least recently used when capacity exceeded', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.set('d', 4);
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.get('b'), 2);
assert.strictEqual(cache.get('c'), 3);
assert.strictEqual(cache.get('d'), 4);
});
it('accessing a key prevents it from being evicted', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.set('d', 4);
assert.strictEqual(cache.get('a'), 1);
assert.strictEqual(cache.get('b'), null);
});
it('updating a key does not increase size', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('a', 2);
assert.strictEqual(cache.size(), 1);
assert.strictEqual(cache.get('a'), 2);
});
it('delete removes entry from cache', () => {
const cache = new LRUCache(3);
cache.set('a', 1);
cache.delete('a');
assert.strictEqual(cache.get('a'), null);
assert.strictEqual(cache.size(), 0);
});
it('tracks metrics correctly', () => {
const cache = new LRUCache(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.get('a');
cache.get('b');
cache.get('c');
const metrics = cache.getMetrics();
assert.strictEqual(metrics.evictions, 1);
assert.strictEqual(metrics.misses, 1);
assert.strictEqual(metrics.hits, 2);
});
it('TTL causes entry to expire on get', async () => {
const cache = new LRUCache(10);
cache.set('temp', 'data', 50);
assert.strictEqual(cache.get('temp'), 'data');
await new Promise(resolve => setTimeout(resolve, 60));
assert.strictEqual(cache.get('temp'), null);
});
it('entry without TTL never expires', async () => {
const cache = new LRUCache(10);
cache.set('permanent', 'data');
await new Promise(resolve => setTimeout(resolve, 60));
assert.strictEqual(cache.get('permanent'), 'data');
});
it('active sweep removes expired entries', async () => {
const cache = new LRUCache(10);
cache.set('x', 1, 50);
cache.set('y', 2);
cache.startSweep(30);
await new Promise(resolve => setTimeout(resolve, 100));
cache.stopSweep();
assert.strictEqual(cache.size(), 1);
assert.strictEqual(cache.get('y'), 2);
});
});
Run all tests and verify
- Run the full test suite by running this command:
node --test
- You should see all tests pass green, including the three new TTL tests.
Why does this dual-layer approach matter in interviews?
When you explain your cache in an interview, the dual-layer TTL approach shows systems thinking. Lazy expiration keeps reads fast (O(1) with one timestamp comparison). Active sweep prevents unbounded memory growth from abandoned keys.
Each approach has a weakness the other covers. Lazy alone leaks memory. Sweep alone adds latency to every interval tick. Together they give you the best of both worlds.
Seeing test failures?
If the TTL tests fail but older tests pass, check that you placed the new it() blocks inside the existing describe('LRUCache', ...) block, not after its closing brace.
If you see a syntax error, make sure you did not accidentally delete the final }); that closes the describe block.
Help me debug my failing TTL tests
Your cache now handles time-based expiration with zero external dependencies. Next up, you will expose the entire cache engine through a REST API so you can interact with it using curl and see live metrics in real time.
Expose the Cache via REST API
Your LRU Cache handles O(1) lookups, eviction, and TTL expiration. Now you need a way for other services (or an interviewer's curl commands) to interact with it over HTTP.
In this step, you will wire up an Express REST API that exposes every cache operation as an endpoint. You will also add a metrics endpoint so you can observe hit rates, evictions, and expirations in real time.
In this step, get ready to:
- Build REST endpoints for POST, GET, DELETE, and metrics.
- Test the API manually with curl.
- Confirm all unit tests still pass.
Build the Express REST API
The existing src/server.js only has a health endpoint. You will replace it with a full API that imports the cache, instantiates it, and exposes CRUD routes.
- Open src/server.js and replace the entire file contents with this code:
import express from 'express';
import { LRUCache } from './LRUCache.js';
const app = express();
app.use(express.json());
// Create cache with 100-entry capacity and start background TTL sweep
const cache = new LRUCache(100);
cache.startSweep(1000);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Set a cache entry with optional TTL
app.post('/cache', (req, res) => {
const { key, value, ttl } = req.body;
if (!key || value === undefined) {
res.status(400).json({ error: 'key and value are required' });
return;
}
cache.set(key, value, ttl || null);
res.status(201).json({ key, stored: true });
});
What does this code do?
The top of the file imports your LRUCache class and creates a single shared instance with a capacity of 100 entries. startSweep(1000) kicks off the background TTL cleanup every second.
The POST route destructures key, value, and optional ttl from the request body. It validates that both key and value exist before calling cache.set().
- Below the POST route (but above where the PORT variable will go), add the remaining routes and server startup:
// Retrieve a cache entry by key
app.get('/cache/:key', (req, res) => {
const value = cache.get(req.params.key);
if (value === null) {
res.status(404).json({ key: req.params.key, found: false });
return;
}
res.json({ key: req.params.key, value, found: true });
});
// Delete a cache entry by key
app.delete('/cache/:key', (req, res) => {
const deleted = cache.delete(req.params.key);
res.json({ key: req.params.key, deleted });
});
// Return live cache metrics
app.get('/metrics', (req, res) => {
res.json(cache.getMetrics());
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Cache engine running on http://localhost:${PORT}`);
});
export default app;
What does this code do?
- GET /cache/:key calls cache.get() which moves the node to head (marking it recently used) and returns the value. If the key is missing or expired, it returns a 404.
- DELETE /cache/:key removes the entry from both the Map and DLL, returning whether the deletion succeeded.
- GET /metrics returns the live stats from getMetrics() including hit rate, eviction count, and current cache size.
- Save src/server.js.
Seeing a syntax error on save?
- Make sure the import path is './LRUCache.js' with the .js extension. ESM requires explicit file extensions.
- Check that the template literal in console.log uses backticks, not regular quotes.
- Confirm the closing }); after each route handler is present.
Why is my server.js throwing an import error?
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/server.js before moving on to test it.
ⓧ I'd like to double check the full code
import express from 'express';
import { LRUCache } from './LRUCache.js';
const app = express();
app.use(express.json());
const cache = new LRUCache(100);
cache.startSweep(1000);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/cache', (req, res) => {
const { key, value, ttl } = req.body;
if (!key || value === undefined) {
res.status(400).json({ error: 'key and value are required' });
return;
}
cache.set(key, value, ttl || null);
res.status(201).json({ key, stored: true });
});
app.get('/cache/:key', (req, res) => {
const value = cache.get(req.params.key);
if (value === null) {
res.status(404).json({ key: req.params.key, found: false });
return;
}
res.json({ key: req.params.key, value, found: true });
});
app.delete('/cache/:key', (req, res) => {
const deleted = cache.delete(req.params.key);
res.json({ key: req.params.key, deleted });
});
app.get('/metrics', (req, res) => {
res.json(cache.getMetrics());
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Cache engine running on http://localhost:${PORT}`);
});
export default app;
Test the API manually with curl
Before writing integration tests, you will hit the API directly with curl to see the cache respond in real time. This is the same workflow an interviewer would ask you to demo.
- Start the server by running:
node src/server.js
You should see Cache engine running on http://localhost:3000 in your terminal.
- Open a second terminal window in the same project directory.
- Set a cache entry by running:
curl -X POST http://localhost:3000/cache -H "Content-Type: application/json" -d '{"key":"user:1","value":{"name":"Alice"},"ttl":30000}'
You should see {"key":"user:1","stored":true} in the response.
- Retrieve that entry by running:
curl http://localhost:3000/cache/user:1
You should see {"key":"user:1","value":{"name":"Alice"},"found":true} confirming the cache returned your stored value.
- Check the live metrics by running:
curl http://localhost:3000/metrics
You should see a JSON response with hits, misses, evictions, expirations, size, and hitRate. After one GET that found the key, you will see hits: 1 and a non-zero hit rate.
Why test with curl first?
Manual testing with curl lets you observe the full HTTP round-trip: request body parsing, status codes, and response shapes. In an interview, being able to demo your API live shows confidence beyond just writing unit tests.
- Stop the server in the first terminal by pressing Ctrl+C.
Getting connection refused?
- Make sure the server is still running in the first terminal. If you see an error on startup, check src/server.js for syntax issues.
- Confirm nothing else is using port 3000. You can check with lsof -i :3000 (macOS) or netstat -ano | findstr :3000 (Windows).
- If the POST command gives a parse error, make sure you are using single quotes around the -d JSON payload (or escaped double quotes on Windows).
Why is my curl request to localhost:3000 being refused?
Run the full test suite
The API layer sits on top of the same LRUCache class your unit tests cover. Running the full suite confirms you did not accidentally break anything while updating server.js.
- Run the full test suite by running:
node --test
- You should see all tests pass green, including the DLL, LRU, and TTL tests from previous steps.
Tests failing after the server update?
- The test runner imports LRUCache directly. If you changed the class export name or file path, the tests will fail on import.
- Make sure you did not accidentally modify src/LRUCache.js while editing src/server.js.
Why are my LRU cache tests failing after I updated server.js?
Your cache engine is now a fully functional HTTP service. You can set entries with TTL, retrieve them, observe metrics, and prove correctness with automated tests. Next up, you will take this to the Secret Mission and make the eviction strategy swappable at runtime.
Secret mission
Implement LFU Eviction with Strategy Pattern
This is the difference between a senior and a staff-level answer in an interview. When the interviewer asks "what if we want a different eviction policy?", you show extensibility via the Strategy Pattern. Instead of only LRU, implement LFU (Least Frequently Used) and make eviction strategies swappable at runtime.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for reference, or delete them to free up disk space.
Resources you used:
- The lru-cache-engine project folder on your Desktop (source files, tests, and node_modules).
- The running Express server process (if you haven't stopped it yet).
Keep everything running
No action needed. Choose this if you plan to keep iterating on your cache engine, extending it with new eviction strategies, or using it as a reference for interview prep.
- Your project folder stays on your Desktop at ~/Desktop/lru-cache-engine.
- If your server is still running, you can stop it anytime with Ctrl+C in the terminal.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your files stay on disk for when you return.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the Express server.
- Your project folder remains at ~/Desktop/lru-cache-engine and is ready to pick up again anytime.
Delete - I don't want to use this again
Remove all project files and free up disk space. This cannot be undone.
- If your server is still running, press Ctrl+C in the terminal to stop it.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/lru-cache-engine
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\lru-cache-engine
- Confirm the folder is gone by running:
ls ~/Desktop | grep lru-cache-engine
You should see no output, confirming the folder has been removed.
Nice Work!
Nice Work!
You just built a production-grade, in-memory LRU Cache Engine from scratch in Node.js. No Redis, no libraries. Every pointer, every eviction, every TTL check is code you wrote and can explain.
You've learned how to:
- Build a Doubly Linked List from scratch with O(1) add, remove, and move operations using sentinel nodes.
- Implement an LRU Cache combining a Map and DLL for O(1) get, set, and delete with TTL expiration using lazy checking and active background sweep.
- Expose cache operations via an Express REST API with real-time metrics tracking hit rate, evictions, and size.
- Secret Mission: Implemented LFU eviction and the Strategy Pattern for swappable eviction policies at runtime.
Ready to quiz yourself?