neighbourhoodie-nnh-logo

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:

A screenshot of the Pouchnotes app

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

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.
    • No: It’s a new note. We push() it into our $state and it will appear in the UI.

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 of Note 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 pass conflicts: true as an option to allDocs

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 _ids, 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:

  1. $ npm i events
  2. Add define: { global: "window" } to the defineConfig object in vite.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

  1. 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. ↩

  2. 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 the AnyDocumentType 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! ↩

  3. You can do prefix search in allDocs(), i.e. “give me all the documents whose _ids start with note::” – by using the special high Unicode character \ufff0. This works because CouchDB/PouchDB _ids are sorted lexicographically. You also don’t need to build or specify an index for this search, since the primary index is over the _ids 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 _ids matters, you can’t search for anything that’s not at the beginning of an _id. ↩

« Back to the blog post overview
og-image-preview