Offline-First with CouchDB and PouchDB in 2025 posted Wednesday, March 26, 2025 by The Neighbourhoodie Team
A few weeks ago, we gave you tooling to quickly and easily host your own CouchDB: CouchDB Minihosting! This week, weâre providing a demo application you can deploy on that installation, so you can try that part out with zero hassle. On top of that, consider this an up-to-date, best practises demo app for Offline-First with CouchDB and PouchDB. Weâre using Svelte 5 with Vite as build tooling and Pico.css for styles.
Hello Pouchnotes
Pouchnotes is a simple but fairly complete 250-line demo app that lets a user log in and take notes, online or offline, and automatically syncs them to a remote CouchDB, and to any other client they log in with. Itâs a real-timey, multi-device note-taking app.
Itâs intended as a demo to deploy with your CouchDB Minihosting instance, so you can try out the deployment tooling and play around with the CouchDB instance and its replication abilities.
Pouchnotes looks a bit like this:
The Pouchnotes readme will guide you through the necessary setup steps, and the CouchDB Minihosting Deploy Readme will walk you through the actual deployment process.
The Pouchnotes source is heavily commented, but if youâre interested in a bit of a higher-level walkthrough and some general tips for working with CouchDB and PouchDB, read on!
- Our Stack: CouchDB + PouchDB + Vite + Svelte 5 + Pico.css
- Data Flow in an Offline-First App
- PouchDB and TypeScript
- Notes on using PouchDB with Vite
- Conclusion
Our Stack: CouchDB + PouchDB + Vite + Svelte 5 + Pico.css
Once again, our stack is geared towards simplicity and speed:
- PouchDB provides a concise, well-typed interface to CouchDB and a local, in-browser database.
- Svelte, now in version 5, is still our favourite framework for easy-to-read, compact demo apps.
- Pico.css is a classless css library that just looks good, works great for apps like this, and saves ages of time.
- Vite, well, itâs in the name. Fast build tooling.
- âŠand CouchDB is what weâre all here for in the first place.
Data Flow in an Offline-First App
The most crucial aspect of an app like this is how the data moves around, both inside the app and also between the remote and local databases. Letâs start with that.
CouchDB <-> PouchDB Data Flow
In an Offline-First app, the whole point is that the app still works when itâs not connected to the network, i.e. is offline. For this to work, we need to avoid talking directly to any remote servers, because they might not be available. This is where PouchDB with its local database comes in: all of our database reads and writes go to the local database. The only data exchange between the client and the server happens through the replication protocol: we let the two databases, PouchDB and CouchDB, sort synchronisation out between themselves by starting a continuous replication. Theyâre really good at this, regardless of network availability, and weâd like to take this opportunity to discourage you from trying to implement this kind of thing yourself đ
To sum up:
- We only read from and write to the local PouchDB.
- We start a continuous, bi-directional replication between the local PouchDB and the userâs database in the remote CouchDB, meaning the two will be eventually consistent.
- If the network fails or the data center goes down, the replication protocol will deal with it and keep trying until it succeeds.
- Any user can log in from multiple devices and this will work on all of them.
Offline-First Authentication
All connections to the remote DB go through the local database? Well, almost: we have to authenticate against a single source of truth, so that step requires a connection to the remote CouchDB. Once we have a session though, we let the current user access their local DB, even if we can no longer reach the remote CouchDB to verify that session. We only prevent them from accessing their local data if we do reach the remote DB and it tells us that the session is invalid.
One niggle we need to take care of is that we canât know which user was last logged in, since the CouchDB session cookie doesnât include the username, and we might not be able to ask the CouchDB which user the cookie belongs to, because we may be offline. A common workaround here is to keep the username in LocalStorage and clear it as soon as the user logs out or the CouchDB explicitly lets us know that the session is invalid.
Data Flow inside the Frontend
Svelte 5 provides a nice, low-friction reactive $state()
abstraction (they call these runes). When a $state
updates, any UI elements affected by it re-render. This is convenient, so weâll put all our notes in one of these.
When the app loads, we fetch all of our notes from the local PouchDB with allDocs()
and chuck them into the $state
.
Weâve got our initial state, but what about real-time changes, e.g. from another client? For this, CouchDB and PouchDB provide an event emitter called the changes feed. This will fire an event whenever a document in the database changes. Since the remote and the local DB are connected via replication, we attach a changes listener to our local PouchDB and react to the changes that come in. Each change is matched against this decision tree:
- Does the change affect a note we already have in our store?
- Yes: Is it a deletion?
- Yes: We
splice()
the note out of our$state
. It will also disappear from the UI. - No: Itâs just an edit. We replace the note in our
$state
with that from the change. The note will update in the UI.
- Yes: We
- No: Itâs a new note. We
push()
it into our$state
and it will appear in the UI.
- Yes: Is it a deletion?
And weâre done. We can open the app on multiple devices, log in as the same user, and all changes we make will propagate everywhere, whether we were online or not.
Weâre not going to get into the potentially huge rabbit hole of conflicts here as weâve already published an extensive four-part blog post series that goes into great detail on avoiding and resolving conflicts. For this app, weâre going to lean on the idea of âuser-as-singletonâ and rely on each user having a rough idea of what their intentions were when they overwrote their own data.
PouchDB and TypeScript
PouchDB and TypeScript are really good friends, but itâs easy to miss just how well they get along. In general: most PouchDB methods, such as allDocs
, accept a type parameter (the bit in angle brackets). Assuming youâve only got a single document type in your database, weâd tell PouchDB about it like this:
type Note = {
text: string
type: "note"
}
const allDocsResult = await localDB.allDocs<Note>({include_docs: true})
// ^^^^ type parameter
Note that weâre using the convention of adding a type
key to every CouchDB document1, this just makes dealing with data a lot simpler in the long run. Anyway:
Now allDocsResult
will be typed as PouchDB.Core.AllDocsResponse<Note>
, so youâll have all the expected properties of an allDocs
response, eg. offset, update_seq, total_rows, rows
, and all the docs
in the rows
will be correctly typed as PouchDB.Core.ExistingDocument<Note & PouchDB.Core.AllDocsMeta>
. That looks a little intimidating, but drilling down into the PouchDB type definitions shows that this is exactly as expected:
PouchDB.Core.ExistingDocument<Note>
provides the types ofNote
plus the CouchDB attributes_id
and_rev
PouchDB.Core.AllDocsMeta
provides the optional the CouchDB attributes_attachments
and_conflicts
, which you can get if you passconflicts: true
as an option toallDocs
PouchDB.Core.ExistingDocument<{}>
is probably going to be your most used type, since thatâs how you define and documents you get from PouchDB or CouchDB and then store or use somewhere in your app. As an example, our Svelte 5 $store
rune for the notes documents is typed as:
let notes: PouchDB.Core.ExistingDocument<Note>[] = $state([])
As you can see, we store entire CouchDB documents, including _id
and _rev
, in our app state. We donât need these values for the UI, but we do need them if we want to modify or delete a document: you canât update a document in CouchDB without these two bits of information. Thatâs why we donât just have Note[]
in our app state, but PouchDB.Core.ExistingDocument<Note>[]
.
Now, this is all wonderful if thereâs only one type of document in your database, but letâs address the elephant in the room:
What if Iâve got more than one Document type?
This doesnât apply to Pouchnotes and this wonât be in the app code, but in many CouchDB databases, youâll find documents of multiple types. This means that by default, methods like allDocs
or the changes feed could return any of them. There are several ways to crumble this cookie, but the most common are:
1. Request all Document Types and Sort them out Later
Assuming our database includes both Note
and Todo
type documents, allDocs()
could return either, so we should tell PouchDB about this. A nice, reusable method is a TypeScript union of all your expected document types:
type Note = {
text: string
type: "note"
}
type Todo = {
text: string
done: boolean
type: "todo"
}
type AnyDocumentType = Note | Todo
const allDocsResult = await localDB.allDocs<AnyDocumentType>({include_docs: true})
One way to pick the document type you need from allDocsResult
could then be:
notes = allDocsResult.rows?.reduce(
(result: PouchDB.Core.ExistingDocument<Note>[], row) => {
if(row.doc?.type === 'note') {
result.push(row.doc)
}
return result
}, []
)
We use reduce()
here because we essentially want to both map()
and filter()
at the same time: If we map over the rows to move the docs into a new array, our resulting array will include undefined
wherever the document was not a note
, so weâd need to filter all of those out afterwards, and getting TypeScript to agree with that can be a little tricky. This reduce()
will type everything correctly without having to assert the type. This is a fine approach for one or two document types, but doesnât scale very well.
So letâs fix that, and abstract this to make it DRY and easier to use:
// First, define a generic type guard for documents
function isDocOfType<T extends AnyDocumentType>(
doc: AnyDocumentType | undefined,
type: T["type"]
): doc is T {
return !!doc && doc.type === type;
}
// Then, a generic version of the reduce method above that can also take a type parameter
function getDocumentsByType<T extends AnyDocumentType>(
allDocsResult: PouchDB.Core.AllDocsResponse<AnyDocumentType>,
type: T["type"]
): PouchDB.Core.ExistingDocument<T>[] {
return allDocsResult.rows.reduce((result: PouchDB.Core.ExistingDocument<T>[], row) => {
if (isDocOfType<T>(row.doc, type)) {
result.push(row.doc);
}
return result;
}, []);
}
// Now we get this really clean usage:
const notes = getDocumentsByType<Note>(allDocsResult, 'note')
const todos = getDocumentsByType<Todo>(allDocsResult, 'todo')
After much experimentation, this currently seems like the tidiest way of solving this problem, especially if you throw the two helper functions into a utils file and never look at them again đ 2
Alternatively: if youâre adding a type
value to all of your CouchDB documents, you can safely assert TypeScript types for them, since you can be certain they are what they claim to be. Explicit TypeScript assertions with as
are sometimes frowned upon, but if they solve your issue, theyâre safe to use in this context. An approach like this would therefore also be fine:
const justTheDocs = allDocsResult.rows.map(row => row.doc)
const grouped = Object.groupBy(justTheDocs, (doc) => doc?.type || 'untyped')
const notesFromGroup = (grouped.note || []) as PouchDB.Core.ExistingDocument<Note>[]
2. Limit the Documents Types you Request in Advance
We can of course also limit the document types as we request them, too. In the app, weâve encoded the document type
as a prefix into the document _id
s, eg. note::2025-03-07T18:52:49.617Z
. We can make use of this in an allDocs()
query:
const allDocsTodoResult = await localDB.allDocs<Note>({
include_docs: true,
startkey: 'note::',
endkey: 'note::\ufff0'
})
This limits the results to only documents where the _id
starts with note::
3.
This is a lot simpler than approach 1, and is especially useful if youâve encapsulated your app in a way that individual components only deal with one document type, or at least very few. If you need lots of different document types in a high-level component, for example, approach 1 seems more sensible.
Weâre not just limited to allDocs
and the primary index _id
, we could also use the pouchdb-find
plugin and use a query selector to get documents by type. If we were listening to a changes feed, we could use the selector
option to only listen to changes from a specific document type, assuming we only care about a subset of changes. As is often the case: it all depends on what youâre doing.
Notes on using PouchDB with Vite
Using PouchDB with Vite required two small extra steps:
$ npm i events
- Add
define: { global: "window" }
to thedefineConfig
object invite.config.ts
Conclusion
We hope Pouchnotes is both a useful companion to CouchDB Minihosting as well as an instructive example of the current best practices for an Offline-First app with CouchDB and PouchDB. Weâve built many such apps over the past decade, and weâre available should you require any commercial assistance with your Offline-First/Local-First project. Our friendly sales crew will be happy to hop on a call with you.
Footnotes
-
When discussing documents in an app like this, CouchDB and PouchDB are used interchangeably: the documents are identical in either, and it doesnât matter where the document originated. Thereâs simply no good superset name that would replace âa CouchDB and/or PouchDBâ document, so we just use either. â©
-
You might be thinking: âwhy not just use
Object.groupBy
?â Well, there is a type safe version of that, and you can try it out on this TypeScript Playground. Sadly, this doesnât play nice when the types in theAnyDocumentType
discriminated union are themselves wrapped inside other types, e.g. the PouchDB generics. If youâre a TypeScript expert and can make this work, please do get in touch, weâre very interested! â© -
You can do prefix search in
allDocs()
, i.e. âgive me all the documents whose_id
s start withnote::
â â by using the special high Unicode character\ufff0
. This works because CouchDB/PouchDB_id
s are sorted lexicographically. You also donât need to build or specify an index for this search, since the primary index is over the_id
s anyway. You are, however, limited to prefix searches, so you must start from the first character of the_id
. This means the order in which you encode information into your_id
s matters, you canât search for anything thatâs not at the beginning of an_id
. â©