This comparison evaluates five distinct approaches to storing data in JavaScript environments, ranging from simple file I/O to complex distributed databases. jsonfile provides a straightforward wrapper for reading and writing JSON files, ideal for configuration or simple state storage in Node.js. lowdb builds on this by offering a minimalist database interface with lodash-style querying, suitable for small-scale prototypes. nedb is an embedded database engine that mimics the MongoDB API but runs entirely in memory or on disk without a server, though it is no longer actively maintained. localforage acts as an abstraction layer over browser storage mechanisms (IndexedDB, WebSQL, localStorage), providing a simple key-value API with asynchronous support for web applications. Finally, pouchdb is a robust, CouchDB-compatible database that runs in the browser or Node.js, featuring built-in synchronization capabilities for offline-first architectures.
Choosing the right data storage solution for a JavaScript application depends heavily on your environment (Node.js vs. Browser), the complexity of your queries, and whether you need synchronization capabilities. The packages jsonfile, lowdb, nedb, localforage, and pouchdb represent five distinct points on the spectrum from simple file I/O to distributed database systems. Let's break down how they handle real-world engineering challenges.
The fundamental difference lies in where and how data is physically stored.
jsonfile and lowdb operate directly on the file system in Node.js. They treat your data as a single JSON file. This is simple but means the entire file must be read into memory to access any part of it.
// jsonfile: Direct file I/O
const jsonfile = require('jsonfile');
const file = 'data.json';
const obj = { name: 'Alice' };
await jsonfile.writeFile(file, obj);
const data = await jsonfile.readFile(file);
// lowdb: File-based with DB interface
const { Low } = require('lowdb');
const { JSONFile } = require('lowdb/node');
const db = new Low(new JSONFile('data.json'), { posts: [] });
await db.read();
db.data.posts.push({ title: 'Hello' });
await db.write();
localforage abstracts browser storage. It automatically picks the best available driver (usually IndexedDB) but exposes a simple key-value API. It does not store data in a visible file you can easily edit.
// localforage: Browser Key-Value Store
import localforage from 'localforage';
await localforage.setItem('user', { name: 'Bob' });
const user = await localforage.getItem('user');
nedb creates its own data files (often with a .db extension) using an append-only log structure in Node.js or Electron. It manages its own indexing and memory caching.
// nedb: Embedded Document Engine
const Datastore = require('nedb');
const db = new Datastore({ filename: 'users.db', autoload: true });
db.insert({ name: 'Charlie' }, function (err, newDoc) {
// Document saved to users.db
});
pouchdb can run in the browser (using IndexedDB internally) or Node.js. It organizes data into documents and revisions, similar to CouchDB, and manages complex internal structures for synchronization.
// pouchdb: Distributed Document Store
import PouchDB from 'pouchdb';
const db = new PouchDB('my_database');
await db.put({ _id: 'user:dave', name: 'Dave' });
const doc = await db.get('user:dave');
How you retrieve data varies wildly across these tools.
jsonfile has zero querying capabilities. You must load the whole file and use standard JavaScript array methods to find what you need.
// jsonfile: Manual filtering after load
const data = await jsonfile.readFile('data.json');
const admin = data.users.find(u => u.role === 'admin');
lowdb leverages lodash (or native JS in newer versions) to chain queries on the in-memory data object. It is readable but limited to what fits in RAM.
// lowdb: Lodash-style chaining
const post = db.get('posts').find({ id: 123 }).value();
const highScores = db.get('scores').filter(s => s > 100).value();
nedb offers a rich query API that closely mirrors MongoDB. You can query, sort, and paginate without loading everything into your own application memory manually.
// nedb: MongoDB-like queries
db.find({ age: { $gt: 25 } }, function (err, docs) {
docs.sort((a, b) => a.name.localeCompare(b.name));
});
localforage is strictly key-value. You cannot query by value inside an object. You must know the key, or iterate over all keys (which is slow).
// localforage: Key-based access only
const user = await localforage.getItem('user:123');
// No equivalent to find({ name: 'Alice' }) without iterating all keys
pouchdb supports complex queries via MapReduce (design documents) or Mango queries (MongoDB-like). It handles indexing automatically for synced data.
// pouchdb: Mango queries
const result = await db.find({
selector: { type: 'user', active: true },
sort: [{ name: 'asc' }]
});
Data integrity under load is a critical architectural decision.
jsonfile and lowdb are not safe for concurrent writes. If two requests try to write at the same time, you risk corrupting the JSON file or losing data. They are designed for single-writer scenarios.
// jsonfile/lowdb: Risk of race conditions
// Request A writes { count: 1 }
// Request B writes { count: 2 } simultaneously
// Result might be corrupted JSON or lost update
nedb handles concurrent writes better by queuing operations internally, but it is not fully ACID compliant. It uses a write-ahead log, but crashes during writes can still lead to compaction issues.
// nedb: Internal queuing
db.insert({ id: 1 }); // Queued
db.insert({ id: 2 }); // Queued after first
// Safer than raw files, but not enterprise-grade
localforage relies on the browser's IndexedDB implementation, which is transactional and handles concurrent tabs reasonably well within the same origin.
// localforage: Browser-managed transactions
await localforage.setItem('count', 1);
// Safe across tabs due to IndexedDB transaction locks
pouchdb provides full ACID compliance within a single database instance. It uses Multi-Version Concurrency Control (MVCC) to handle conflicts, especially during replication.
// pouchdb: MVCC and Conflict Resolution
try {
await db.put(doc);
} catch (err) {
if (err.status === 409) {
// Handle conflict: merge changes and retry
const existing = await db.get(doc._id);
// Merge logic here
}
}
This is the dividing line between local tools and distributed systems.
jsonfile, lowdb, nedb, and localforage are local-only. They have no built-in mechanism to sync data with a server or other clients. You must write your own API endpoints and sync logic.
// localforage/nedb: Manual sync required
const data = await db.find({});
await fetch('/api/sync', { method: 'POST', body: JSON.stringify(data) });
// You must handle errors, retries, and conflicts manually
pouchdb has built-in synchronization. It can replicate data bi-directionally with a remote CouchDB server automatically, handling offline queues and conflict detection out of the box.
// pouchdb: Automatic Replication
const remoteDB = new PouchDB('https://myserver.com/db');
// Syncs automatically, works offline, retries on reconnect
db.sync(remoteDB, {
live: true,
retry: true
}).on('change', function (change) {
console.log('Data synced:', change);
});
A crucial factor for long-term projects is the health of the library.
nedb is no longer actively maintained. The original repository has been archived or sees minimal activity, and there are known issues with performance on large datasets and data corruption in edge cases. Do not start new projects with nedb. Consider pouchdb (for sync needs) or better-sqlite3 (for performance) as alternatives.
// nedb: Deprecated warning
// While still functional for small Electron apps, avoid for new critical infrastructure.
jsonfile, lowdb, localforage, and pouchdb are actively maintained and widely used in production. lowdb recently updated to version 3.0 with a new API, so ensure you read the latest docs.
// lowdb v3+ usage
import { Low } from 'lowdb';
// Modern ESM support and cleaner API
| Feature | jsonfile | lowdb | nedb | localforage | pouchdb |
|---|---|---|---|---|---|
| Environment | Node.js | Node.js | Node.js/Electron | Browser/Node | Browser/Node |
| Data Model | Raw JSON | JSON Object | Documents (Mongo-like) | Key-Value | Documents (Couch-like) |
| Querying | None (Manual) | Lodash/JS | MongoDB API | Keys Only | Mango/MapReduce |
| Sync | ❌ No | ❌ No | ❌ No | ❌ No | ✅ Built-in |
| Concurrency | ❌ Unsafe | ❌ Unsafe | ⚠️ Queued | ✅ Transactional | ✅ MVCC |
| Status | ✅ Active | ✅ Active | ⚠️ Deprecated | ✅ Active | ✅ Active |
jsonfile is your go-to for configuration files or simple state persistence in scripts where data complexity is low. It's the digital equivalent of a text file.
lowdb is perfect for prototypes and CLI tools where you need a bit of structure and querying without the hassle of installing a database server. Think of it as a "poor man's database" for development.
nedb should be treated as legacy technology. While it powered many Electron apps in the past, its lack of maintenance makes it a liability for new projects. Migrate existing apps if possible.
localforage is the standard for browser storage when you need reliability and async support without dealing with the verbose IndexedDB API. It's the sweet spot for caching and user preferences in web apps.
pouchdb is the heavyweight champion for offline-first apps. If your users need to work on a plane, in a tunnel, or across multiple devices with automatic sync, this is the only choice on this list that solves that problem out of the box.
Final Thought: Don't overengineer. If you just need to save a settings object, jsonfile or localforage is enough. If you need to build the next Google Docs with offline support, pouchdb is worth the complexity. Avoid nedb for anything new.
Choose jsonfile when you need to persist simple configuration objects or small datasets to a file in a Node.js environment without the overhead of a database engine. It is the best fit for scripts, build tools, or servers where data integrity is managed by the application logic rather than the storage layer. Avoid it for concurrent writes or complex querying needs, as it lacks locking mechanisms and query capabilities.
Select localforage for frontend web applications that require a simple, asynchronous key-value store with broad browser compatibility. It is the optimal choice when you want the performance of IndexedDB but prefer the simplicity of a localStorage-like API, avoiding the boilerplate of direct IndexedDB usage. Use this for caching user preferences, offline form data, or session state where relational queries are not required.
Opt for lowdb when building rapid prototypes, CLI tools, or small internal services in Node.js that need basic querying capabilities without setting up a full database server. It shines in scenarios where the dataset is small (under a few thousand records) and simplicity of setup is more critical than high-concurrency performance. Do not use it for production applications with heavy write loads, as the entire file is read/written on every operation.
Avoid using nedb for new projects as it is no longer actively maintained and has known stability issues with large datasets. However, if you are maintaining a legacy Electron app or a small Node.js service that already relies on its MongoDB-like API, it can still serve as a lightweight, serverless embedded solution. For new development requiring an embedded document store, consider modern alternatives like better-sqlite3 or pouchdb instead.
Choose pouchdb when building offline-first web or mobile applications that require data synchronization with a remote CouchDB or Cloudant server. It is the industry standard for scenarios where users must continue working without connectivity and sync changes once back online. Be prepared for a larger bundle size and increased complexity in managing replication conflicts compared to simpler storage solutions.
Easily read/write JSON files in Node.js. Note: this module cannot be used in the browser.
Writing JSON.stringify() and then fs.writeFile() and JSON.parse() with fs.readFile() enclosed in try/catch blocks became annoying.
npm install --save jsonfile
readFile(filename, [options], callback)readFileSync(filename, [options])writeFile(filename, obj, [options], callback)writeFileSync(filename, obj, [options])options (object, default undefined): Pass in any fs.readFile options or set reviver for a JSON reviver.
throws (boolean, default: true). If JSON.parse throws an error, pass this error to the callback.
If false, returns null for the object.const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
jsonfile.readFile(file, function (err, obj) {
if (err) console.error(err)
console.dir(obj)
})
You can also use this method with promises. The readFile method will return a promise if you do not pass a callback function.
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
jsonfile.readFile(file)
.then(obj => console.dir(obj))
.catch(error => console.error(error))
options (object, default undefined): Pass in any fs.readFileSync options or set reviver for a JSON reviver.
throws (boolean, default: true). If an error is encountered reading or parsing the file, throw the error. If false, returns null for the object.const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
console.dir(jsonfile.readFileSync(file))
options: Pass in any fs.writeFile options or set replacer for a JSON replacer. Can also pass in spaces, or override EOL string or set finalEOL flag as false to not save the file with EOL at the end.
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, function (err) {
if (err) console.error(err)
})
Or use with promises as follows:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj)
.then(res => {
console.log('Write complete')
})
.catch(error => console.error(error))
formatting with spaces:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2 }, function (err) {
if (err) console.error(err)
})
overriding EOL:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2, EOL: '\r\n' }, function (err) {
if (err) console.error(err)
})
disabling the EOL at the end of file:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { spaces: 2, finalEOL: false }, function (err) {
if (err) console.log(err)
})
appending to an existing JSON file:
You can use fs.writeFile option { flag: 'a' } to achieve this.
const jsonfile = require('jsonfile')
const file = '/tmp/mayAlreadyExistedData.json'
const obj = { name: 'JP' }
jsonfile.writeFile(file, obj, { flag: 'a' }, function (err) {
if (err) console.error(err)
})
options: Pass in any fs.writeFileSync options or set replacer for a JSON replacer. Can also pass in spaces, or override EOL string or set finalEOL flag as false to not save the file with EOL at the end.
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj)
formatting with spaces:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2 })
overriding EOL:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2, EOL: '\r\n' })
disabling the EOL at the end of file:
const jsonfile = require('jsonfile')
const file = '/tmp/data.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { spaces: 2, finalEOL: false })
appending to an existing JSON file:
You can use fs.writeFileSync option { flag: 'a' } to achieve this.
const jsonfile = require('jsonfile')
const file = '/tmp/mayAlreadyExistedData.json'
const obj = { name: 'JP' }
jsonfile.writeFileSync(file, obj, { flag: 'a' })
(MIT License)
Copyright 2012-2016, JP Richardson jprichardson@gmail.com