DEV Community

Cover image for Faster SQLite Lookups in Node.js Using Hash Keys & Indexing
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

Faster SQLite Lookups in Node.js Using Hash Keys & Indexing

Hello, I'm Maneshwar. I'm working on FreeDevTools online currently building **one place for all dev tools, cheat codes, and TLDRs* — a free, open-source hub where developers can quickly find and use tools without any hassle of searching all over the internet.*

Yesterday’s post was all about pushing SQLite harder with Node worker threads and multiple better-sqlite3 connections. That solved the concurrency and CPU scaling problem pretty well.

But there was another bottleneck hiding in plain sight — the way we search rows.

Turns out you can squeeze a ridiculous amount of extra performance just by rethinking how you design WHERE lookups. Not by rewriting business logic. Not by switching databases. Just by changing the shape of the key you search by.

And yeah, it’s stupid how much faster it gets.

The real pain

Many apps have queries like:

SELECT * FROM table
WHERE a = ? AND b = ? AND c = ? AND d = ?;
Enter fullscreen mode Exit fullscreen mode

You start with one condition, then two more get added, and suddenly you’re comparing multiple string or text fields in every lookup.

At small scale it’s fine. But the moment you scale read traffic or run queries in parallel, performance falls off a cliff because SQLite has to:

  • juggle multiple values in the key comparison
  • compare variable-length text
  • walk deeper nodes in the B-Tree
  • store bigger keys → fewer keys per page → more B-Tree levels and comparisons

So I tried something different: pack multiple identifying fields into a single hash column and index that instead.

Not rocket science — but genuinely game-changing.

The trick: hash the identifying fields into one integer

Instead of searching by (cluster, name) strings, I build a URL-like string and hash it:

export function hashUrlToKey(url: string): string {
  const hash = crypto.createHash('sha256').update(url).digest();
  return hash.readBigInt64BE(0).toString(); // 8 bytes → BIGINT
}
Enter fullscreen mode Exit fullscreen mode

Now instead of:

WHERE cluster=? AND name=?
Enter fullscreen mode Exit fullscreen mode

We do:

WHERE url_hash = ?
Enter fullscreen mode Exit fullscreen mode

Create index / primary key on that column (preferably WITHOUT ROWID), and boom — SQLite only compares one 8-byte integer per step instead of multiple text values.

Benchmark: 50,000 lookups on ~52k-row table

We ran the exact same benchmark twice — once using a hashed BIGINT key lookup, and once using a composite text lookup. Nothing else changed.

Hashed lookup — url_hash (BIGINT, WITHOUT ROWID)

╔════════════════════════════════════════════════════════════════════╗
║                      SVG Icon URL Lookup Benchmark                ║
╚════════════════════════════════════════════════════════════════════╝

📋 Configuration:
   Icon rows:           51,898
   URL entries:         51,898
   Total queries:       50,000 (fixed)
   Worker configs:      [1, 2]
   CPU pinning:         Workers on CPU [0, 1]
   Hash algorithm:      SHA256 (first 8 bytes as INTEGER)
   Table structure:     WITHOUT ROWID (clustered by url_hash)

⚡ Running benchmarks...

   2p × 1w (2 workers, 25000 queries each) ... ✓ 0.670s
   2p × 2w (4 workers, 12500 queries each) ... ✓ 0.824s

══════════════════════════════════════════════════════════════════════════════════════════════════════════════
📈 RESULTS - SVG ICON URL LOOKUP PERFORMANCE
══════════════════════════════════════════════════════════════════════════════════════════════════════════════

┌──────┬────────────┬──────────┬──────────┬──────────────┬──────────────┬─────────────┬──────────────┐
│ Rank │ Config     │ Proc×Wrk │ Queries  │ Duration (s) │ QPS          │ Avg Query   │ vs Slowest   │
├──────┼────────────┼──────────┼──────────┼──────────────┼──────────────┼─────────────┼──────────────┤
│    1 │ 2p-1w      │      2×1 │   50,000 │        0.670 │       74,680 │     13.39µs │        1.23x │
│    2 │ 2p-2w      │      2×2 │   50,000 │        0.824 │       60,680 │     16.48µs │        1.00x │
└──────┴────────────┴──────────┴──────────┴──────────────┴──────────────┴─────────────┴──────────────┘

📌 Summary:
   🎯 Workload:        50,000 URL lookups
   ⚡ Best config:     2p-1w → 0.670s
   🚀 Peak QPS:        74,680.4 queries/second
   ⏱️ Avg lookup:      13.39µs per URL
   ✅ Performance validated
Enter fullscreen mode Exit fullscreen mode

Composite string lookup — cluster + name (TEXT + TEXT)

╔════════════════════════════════════════════════════════════════════╗
║                      SVG Icon URL Lookup Benchmark                ║
╚════════════════════════════════════════════════════════════════════╝

📋 Configuration:
   Icon rows:           51,898
   URL entries:         51,898
   Total queries:       50,000 (fixed)
   Worker configs:      [1, 2]
   CPU pinning:         Workers on CPU [0, 1]
   Lookup strategy:     Cluster + name equality lookup
   Table structure:     WITHOUT ROWID (cluster + name columns)

⚡ Running benchmarks...

   2p × 1w (2 workers, 25000 queries each) ... ✓ 1.012s
   2p × 2w (4 workers, 12500 queries each) ... ✓ 1.112s

══════════════════════════════════════════════════════════════════════════════════════════════════════════════
📈 RESULTS - SVG ICON URL LOOKUP PERFORMANCE
══════════════════════════════════════════════════════════════════════════════════════════════════════════════

┌──────┬────────────┬──────────┬──────────┬──────────────┬──────────────┬─────────────┬──────────────┐
│ Rank │ Config     │ Proc×Wrk │ Queries  │ Duration (s) │ QPS          │ Avg Query   │ vs Slowest   │
├──────┼────────────┼──────────┼──────────┼──────────────┼──────────────┼─────────────┼──────────────┤
│    1 │ 2p-1w      │      2×1 │   50,000 │        1.012 │       49,391 │     20.25µs │        1.10x │
│    2 │ 2p-2w      │      2×2 │   50,000 │        1.112 │       44,975 │     22.23µs │        1.00x │
└──────┴────────────┴──────────┴──────────┴──────────────┴──────────────┴─────────────┴──────────────┘

📌 Summary:
   🎯 Workload:        50,000 URL lookups
   ⚡ Best config:     2p-1w → 1.012s
   🚀 Peak QPS:        49,391.36 queries/second
   ⏱️ Avg lookup:      20.25µs per URL
Enter fullscreen mode Exit fullscreen mode

TL;DR comparison

Same machine
Same table
Same query count
Same workers
Same WAL mode

Only difference:
url_hash = one 8-byte integer
cluster+name = two text compares

Result:
BIGINT lookup is ~1.5× faster in practice

Why hashing helps

SQLite indexes are B-Trees. Search cost is O(log N), but what you compare in each step matters a lot.

Composite text key Hashed bigint key
multiple comparisons per row one comparison
variable length fixed 8 bytes
fewer keys per page more keys per page
more page reads fewer reads
slower CPU comparisons single instruction compare

It's not magic — it’s just reducing the cost per step in a structure you’re already using.

If your app already has a few conditions that act like identity fields (user_id + device_id + type, cluster + name + version, etc.), stop treating them separately. Smash them into a hash and index it.

Parallel load matters too

This improvement becomes even more obvious when running 2 processes × multiple workers hammering the DB (like in yesterday’s post).

With multi-column text lookups, workers fight over CPU more. With hashed integer lookups, they glide.

That’s the difference between:

  • CPU being busy doing useful binary tree traversal
  • and CPU wasting cycles comparing strings

Real takeaway (not academic BS)

This isn’t just about icons or URL paths.

If you have 3–4 WHERE equality conditions that always go together, hash them into one synthetic key and index that column.

It:

  • simplifies every lookup
  • shrinks index pages
  • increases cache density
  • dramatically speeds up read concurrency

And you don’t need to redesign your whole DB.
You just add one column and one index.

Worst case, nothing changes.
Best case, you get free performance like I just did.

Try it when:

✔ Lookups are equality-based, not ranges
✔ Multiple fields always appear together in WHERE
✔ You’re doing heavy reads or concurrency
✔ Your keys are long strings or UUIDs

Closing thought

People love arguing about SQLite vs Postgres or whatever.
Meanwhile, you can get 50%+ perf improvement inside SQLite with a single column that stores a hash.

That’s the whole point:
Databases are fast. We just give them slow keys.

FreeDevTools

👉 Check out: FreeDevTools

Any feedback or contributors are welcome!

It’s online, open-source, and ready for anyone to use.

⭐ Star it on GitHub: freedevtools

Top comments (0)