An embedded, single-file NoSQL database for Node.js — simple to set up, zero production dependencies.
Pocket DB stores everything in a single append-only file — no server, no daemon, no setup. You open a file, work with collections of JSON documents, and close. That is the whole model.
It is inspired by SQLite (one file, embedded) and MongoDB (document model, familiar API), but intentionally small. The core constraint — never reserialise the entire database on a write — means every insert, update and delete is a fast append. Reading a document means seeking to its offset and reading only those bytes.
Good fit for: desktop apps, CLI tools, Electron apps, local servers, plugins, structured caches, offline-first prototypes.
Not a fit for: multi-process concurrent writers, datasets requiring complex aggregation pipelines, or anything that would normally call for a full server database.
npm install pocket-dbNo native binaries. No optional dependencies. Pure TypeScript compiled to ESM.
import { open } from "pocket-db";
const db = open({ path: "./data.pdb" });
const users = db.collection("users");
// Insert
const { insertedId } = users.insertOne({ name: "Ada", role: "admin", age: 37 });
// Find
const ada = users.findOne({ name: "Ada" });
console.log(ada); // { _id: "...", name: "Ada", role: "admin", age: 37 }
// Query with operators
const admins = users.find({ role: "admin", age: { $gte: 18 } }).toArray();
// Update
users.updateOne(insertedId, { $set: { age: 38 }, $inc: { loginCount: 1 } });
// Delete
users.deleteOne(insertedId);
db.close();All writes are appended to the end of the file. Reads go directly to the byte offset of the document — no full-file scan. The in-memory state is rebuilt by replaying the log when open() is called. Deleted and updated documents leave dead records behind; db.compact() reclaims that space in a single forward pass.
A database holds any number of named collections. Collections are created implicitly on first access and persisted to the log. Each collection has its own primary index (keyed by _id) and optional secondary indexes.
Every document gets a _id: a 24-character lowercase hex string (12-byte ObjectId layout — 4-byte timestamp, 5-byte random, 3-byte counter). You can supply your own _id on insert as long as it matches that format.
const db = open({ path?: string });
db.collection(name: string): Collection
db.compact(): void // reclaim space from dead records
db.close(): voidcollection.insertOne(doc): InsertOneResult
collection.insertMany(docs): InsertManyResult
collection.findOne(query?): Record | null
collection.find(query?): Cursor
collection.countDocuments(query?): number
collection.updateOne(id | query, update): UpdateResult
collection.updateMany(query, update): UpdateResult
collection.replaceOne(id, doc): ReplaceOneResult
collection.replaceOne(doc & { _id }): ReplaceOneResult
collection.deleteOne(id | query): DeleteOneResult
collection.deleteMany(query?): DeleteManyResult
collection.createIndex(field, { type: "string" | "number" }): CreateIndexResult
collection.dropIndex(field): DropIndexResult
collection.drop(): DropResultcursor.next(): Record | null
cursor.toArray(): Record[]
cursor.count(): number
cursor.sort(spec: Record<string, 1 | -1>): Cursor // up to 4 fields
cursor.limit(n: number): Cursor
cursor.skip(n: number): CursorQueries are plain objects. A bare value is shorthand for $eq.
| Operator | Description |
|---|---|
$eq |
Strict equality (no type coercion) |
$ne |
Not equal |
$gt / $gte |
Greater than / greater than or equal |
$lt / $lte |
Less than / less than or equal |
$in |
Field value is in the given array |
$nin |
Field value is not in the given array |
$exists |
Field is present (true) or absent (false) |
$not |
Negates an operator expression |
$and |
Logical AND of sub-queries |
$or |
Logical OR of sub-queries |
$nor |
Logical NOR of sub-queries |
// Compound query
users.find({
$and: [
{ role: { $in: ["admin", "editor"] } },
{ age: { $gte: 18, $lt: 65 } }
]
});
// Negation
users.find({ status: { $not: { $eq: "banned" } } });
// OR
users.find({ $or: [{ role: "admin" }, { role: "editor" }] });Updates are expressed as operator objects applied to the current document.
| Operator | Description |
|---|---|
$set |
Set one or more fields |
$unset |
Remove one or more fields |
$inc |
Increment a numeric field |
$min / $max |
Set field only if new value is lower / higher |
$push |
Append a value to an array field |
users.updateOne(id, {
$set: { role: "editor" },
$inc: { loginCount: 1 }
});_id is immutable and cannot be modified by any update operator.
Secondary indexes speed up equality and range queries. They are rebuilt from the log at every open.
// Create
users.createIndex("role", { type: "string" });
users.createIndex("age", { type: "number" });
// Drop
users.dropIndex("role");StringIndex supports $eq and $in lookups. NumberIndex additionally supports $gt, $gte, $lt, $lte range scans. The query planner automatically picks the most selective available index for each query.
const page = users
.find({ role: "admin" })
.sort({ age: -1, name: 1 }) // up to 4 sort fields
.skip(20)
.limit(10)
.toArray();Sort accepts 1 (ascending) and -1 (descending). Missing values sort first in ascending order and last in descending order. Sorting is always eager — narrow the candidate set with an indexed query before sorting over large collections.
Dead records accumulate as documents are updated or deleted. compact() rewrites the file in a single forward pass, keeping only live data:
db.compact();After compaction, all in-memory indexes are refreshed automatically.
insertMany, updateMany, and deleteMany are crash-safe: if the process is killed mid-batch, the partial batch is silently discarded on the next open. Either all operations are visible or none are.
open() creates a .lock file next to the database file. A second open() on the same path from a different process will throw. Stale locks left by crashed processes are detected via PID check and cleared automatically.
Pocket DB is designed for single-process use. Multiple concurrent writers on the same file are not supported.
Pocket DB is written in TypeScript and ships its own type declarations. All public types are exported from the package root:
import type {
Database, Collection, Cursor,
InsertOneResult, InsertManyResult,
UpdateResult, ReplaceOneResult,
DeleteOneResult, DeleteManyResult,
CreateIndexResult, DropIndexResult, DropResult,
OpenOptions, SortDirection
} from "pocket-db";The docs/ folder contains in-depth documentation available as a wiki:
- File format — binary layout, record structure, U29 encoding
- Storage semantics — replay rules, write path, crash recovery
- Query & update model — operators, compilation, cursor semantics
- Indexes — primary index, StringIndex, NumberIndex, query planner
- Compaction — algorithm, invariants, secondary index refresh
Pocket DB is benchmarked against several alternative embedded stores on a collection of 1,000 documents across ten common operations. The numbers below are ops/sec on an Apple M-series machine — higher is better. An asterisk marks the fastest adapter for each operation.
Benchmark results — 1,000 documents
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Operation pocket-db sqlite (memory) sqlite (file) json-file lowdb lokijs
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
insertOne 198,177 226,278 * 4,421 3,893 2,256 1,004
insertMany (100) 2,345 2,963 * 402 788 628 264
findById 142,776 1,064,774 248,942 12,532,585 * 321,548 4,061,606
findAll 96 361 361 115,774 870,822 * 1,179
findByName (scan) 97 536 524 19,579 24,235 * 8,222
findByRole (index) 277 884 887 18,527 21,796 * 3,215
updateOne 97,889 423,072 * 2,675 784 566 188
deleteOne 454,402 465,026 * 5,551 924 663 220
countAll 12,038 2,233,389 285,285 42,553,191 * 34,914,251 37,348,273
sortByScore (desc) 91 293 293 4,947 5,099 * 1,123
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
All values in ops/sec. * = fastest for this operation.
What these numbers reveal:
The append-only log design is the reason pocket-db's write throughput is competitive with SQLite in-memory for inserts and faster than everything else for deletes and single-document updates. Every mutation is a single sequential write — there is no B-tree rebalancing, no page allocation, and no full-file reserialisation. deleteOne and updateOne are particularly cheap because they only append a tombstone or a replacement record and update the in-memory index pointer.
Reads are a different story. Unlike json-file, lowdb, or LokiJS — which serve reads entirely from in-memory structures — pocket-db currently has no document cache. Every findOne and every cursor step seeks to the document's file offset and reads from disk. This explains why in-memory adapters show several orders of magnitude higher read throughput. A read cache is planned for V2 and will reach to close this gap for hot-document workloads without changing the write model.
In short: if your workload is write-heavy or you need durability on every write, pocket-db competes well. If you need high-throughput in-memory reads and can afford to lose data on crash, a pure in-memory store will outperform it today.
Run the benchmarks yourself:
npm install
npm run benchMIT © Fabien Bavent
