Designing a Local-First Web App with IndexedDB: Lessons from My Simple Text Editor
Jun 8, 2025
•6 minute read•111 views
In my early career days, I remember seeing one of my seniors open up Jimmy Breck-McKye’s Dead Simple Text Editor during a Google Meet. It was clean, fast, and didn't need logins or installs—just a quick, disposable notepad to share ideas. Something about that resonated with me.
So I tried building my own.
Except… I didn’t just want another simple editor. I wanted to improve on it.
Dead Simple Text Editor had no storage, no tabbed view, no offline recovery—just plain text. I wanted to change that, to make something minimal but also capable enough to keep up with how we actually use editors. Think: multiple tabs, instant saves, theme customizations ,offline-ready, and no login friction.
That's how Simple Text Editor (https://github.com/gourav221b/simple-text-editor) came to be. And the secret sauce behind making it feel snappy and persistent?
IndexedDB + Dexie.js.
Try it out here: https://editor.devgg.in/ .
Why IndexedDB?
Most frontend devs stick to localStorage
when they want to persist data. It works, sure—but it’s like using a sticky note to store a draft novel.
IndexedDB, on the other hand, gives you a full-blown local database in the browser. It lets you store structured data (like entire documents) asynchronously, which means you won’t freeze the UI every time you save something. It also survives tab closures and browser reloads—exactly what a user needs from a real text editor.
The only problem?
IndexedDB is verbose and painful to work with directly.
That’s where Dexie.js enters the picture.
How We Use IndexedDB via Dexie.js
At the heart of this project is a tiny database called EditorDB
. It has one table: editors
. Each record is basically a document tab—complete with a filename and text content.
Here’s what the schema looks like:
// db.ts
import Dexie, { Table } from 'dexie';
export interface EditorDocument {
id?: number;
name: string;
content: string;
}
export class EditorDexie extends Dexie {
editors!: Table<EditorDocument>;
constructor() {
super('EditorDB');
this.version(1).stores({
editors: '++id, name, content',
});
}
}
export const db = new EditorDexie();
Creating a New Tab
Whenever a user clicks “New File,” we spin up a blank editor record and store it:
await db.editors.add({
name: 'Untitled.txt',
content: '',
});
Internally, this happens in EditorWrapper.tsx
, which manages the collection of tabs.
Live Listing with useLiveQuery
We use Dexie’s useLiveQuery()
hook to reactively fetch and update the tab list whenever a document is added, renamed, or deleted:
const editorDocs = useLiveQuery(() => db.editors.toArray());
This makes the UI feel alive. No manual state sync needed. Add a tab → UI updates. Rename a doc → title changes in real-time.
Auto-Saving via Debounced Writes
Typing inside the editor doesn't trigger a save every keystroke (thank god). Instead, we debounce the updates:
await db.editors.update(id, { content: newContent });
Same goes for renaming:
await db.editors.update(id, { name: newName });
We debounce both inside Editor.tsx
using useEffect()
to avoid hammering the database.
Deleting Tabs
Tabs can be removed with:
await db.editors.delete(id);
Again, thanks to useLiveQuery()
, the UI reflects this instantly.
Active Tab State with localStorage
To avoid IndexedDB overhead for just knowing which tab is active, we use localStorage
to store the active tab's ID. It's synchronous and dead simple. When the app boots up, it reads the last open tab ID and restores it.
What were the challenges?
Here’s the part devs don’t usually talk about. IndexedDB is powerful, yes—but it's not all rainbows.
Keeping UI in Sync
Since everything is async, there's a constant dance between:
Live DB data (
useLiveQuery
)Local state (active tab, input fields)
Debounced updates (saving content)
It’s easy to accidentally overwrite fresh data if you don't debounce and structure your updates carefully.
Handling Errors Gracefully
Errors can come from:
Storage limits (
QuotaExceededError
)Transaction issues
Browser-specific quirks (Safari is a menace sometimes)
Dexie gives us nice errors, but user-facing feedback is a different ballgame. We log most things to the console for now, but in a production version, we’d:
Retry failed writes
Warn users
Let them download a backup if all else fails
Thinking About Performance
Right now, each tab is just plain text. But if we scale this to handle 100+ notes or 1MB+ documents, things can get sluggish. Dexie can handle it, but the UI might not.
Future plans:
Use Web Workers for parsing large text
Add virtualized tab rendering
Possibly chunk content for very large docs
Schema Migrations (A Hidden Beast)
Adding a created_at
timestamp later? Sounds easy.
But every IndexedDB change needs versioning:
this.version(2).stores({
editors: '++id, name, content, created_at'
});
And that means writing upgrade logic for users with old data. Dexie helps, but you still need to handle every case yourself.
Browser Quirks & Compatibility
Tested this on Chrome, Edge, and Firefox—it works. But Safari sometimes throws random transaction aborts.
Also: private browsing mode disables IndexedDB in many browsers.
There’s no perfect fix. Just test like crazy.
Final Thoughts
Building this tiny editor taught me more than expected. IndexedDB—when used with Dexie.js—can give your app the persistence and power of a desktop tool, all within the browser.
But it also demands respect.
You can’t treat IndexedDB like a simple key-value store. You need to:
Handle sync carefully
Debounce updates
Plan for errors
Test across browsers
Think long-term (schema versions, data limits)
Link to Project:
🔗 https://github.com/gourav221b/simple-text-editor
Looking for Contributions
One limitation of the current setup is that all data is stored entirely in the browser's IndexedDB, which means it's tightly coupled to the device and browser the user is on. There’s currently no built-in way to migrate or sync your data across browsers or machines.
A super useful feature would be to add:
Export/Import Mechanism for IndexedDB Data
The idea is simple:
Export: Add a button that serializes all stored editor documents into a downloadable JSON file (containing
id
,name
, andcontent
).Import: Allow users to upload that JSON file on another browser or device, which then populates the local IndexedDB with the same documents.
This would make it trivial to:
Transfer your writing from one browser to another
Backup your tabs before clearing cache
Share your editor state with someone else (like a portable notebook!)
It’s not a complex problem, but doing it cleanly — handling ID conflicts, duplicate filenames, and ensuring proper validation — makes for a really neat community contribution opportunity. If you're looking to explore Dexie.js a little deeper or want a weekend side task, this could be a great start. PRs welcome!
Issues link: https://github.com/gourav221b/simple-text-editor/issues/2
Conclusion
If you’re looking to build your own offline-capable tools, start small. Learn Dexie. Use useLiveQuery
. Think in transactions, not just state.
And most importantly, test what happens when your tab crashes. Because chances are, your users will do it accidentally—so let’s make sure their ideas don’t vanish with it.