Skip to content
In this postCouchDB

Build an Offline-First App with PouchDB and CouchDB

7 min readCouchDBPouchDBTypeScriptTutorials

An offline-first app writes to a database on the device before it contacts a server. That makes saving possible on a train, in a warehouse, or at an event with unreliable Wi-Fi. Synchronization becomes a separate operation with its own progress and errors.

This tutorial builds a small browser scratchpad in TypeScript. PouchDB stores the note locally; CouchDB exchanges revisions between browser profiles. You will test disconnected writes and inspect a genuine concurrent-edit conflict.

The local workflow is free and needs no cloud account. If you later want a managed remote, CouchDB on Layerbase Cloud requires Pro. The Pro plan is $15 per month. It is not included in Free or Solo. See current pricing.

Start a local CouchDB

Install the Layerbase CLI and create a named instance:

bash
pnpm add -g layerbase
lbase create pouch-notes -e couchdb --start
lbase url pouch-notes

Keep the URL returned by the final command. Use its actual port throughout this guide. Local CouchDB uses admin / admin for development; never use those credentials on a public server.

Open that server's /_utils/ path in your browser and sign in. In Fauxton:

  1. Create a non-partitioned database named pouch_notes_demo.
  2. Open the _users database. If it does not exist on this local instance, create a non-partitioned database named _users first. Create the following document inside it. This password is deliberately for the loopback-only demo.
json
{
  "_id": "org.couchdb.user:pouch_demo",
  "name": "pouch_demo",
  "type": "user",
  "roles": [],
  "password": "local-demo-only"
}
  1. In pouch_notes_demo permissions, add pouch_demo to member names. Keep _admin under administrator roles. The resulting _security document should be:
json
{
  "admins": { "names": [], "roles": ["_admin"] },
  "members": { "names": ["pouch_demo"], "roles": [] }
}

This user can replicate ordinary documents in this database but cannot create databases or change server configuration. Giving the browser a server-administrator password would grant much more access than the app needs. CouchDB's authentication model

In Fauxton's configuration editor, set:

SectionKeyValue
chttpdenable_corstrue
corscredentialstrue
corsoriginshttp://localhost:5173
corsmethodsGET,PUT,POST,HEAD,DELETE
corsheadersaccept,authorization,content-type,origin,referer

The exact origin matters: http://127.0.0.1:5173 is different from http://localhost:5173. Keep the app on the latter. For an instance with other clients, merge their required origins instead of replacing them. CouchDB CORS settings

Create the browser project

Use Node.js 22.12 or newer and pnpm. Create a directory with these files:

bash
mkdir pouch-scratchpad
cd pouch-scratchpad
pnpm init
pnpm add pouchdb-browser@9 events@3
pnpm add -D vite@7 typescript@5 @types/pouchdb-browser@6

Set "type": "module" in package.json. Add .env and node_modules/ to .gitignore if you keep the example in a repository.

Create vite.config.ts:

typescript
import { defineConfig } from 'vite'

export default defineConfig({
  define: { global: 'globalThis' },
  resolve: { alias: { events: 'events/' } },
  server: { host: 'localhost', port: 5173, strictPort: true },
})

The global mapping and browser events package support dependencies in the PouchDB bundle. A fixed port makes the CORS origin predictable.

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["main.ts", "vite.config.ts"]
}

Create index.html:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Offline scratchpad</title>
  </head>
  <body>
    <h1>Offline scratchpad</h1>
    <p>Save locally first. Connect when you want to exchange revisions.</p>
    <form id="connection">
      <label>Database URL <input id="url" type="url" required /></label>
      <label>Username <input id="username" required /></label>
      <label>Password <input id="password" type="password" required /></label>
      <button>Connect sync</button>
    </form>
    <button id="pause" type="button">Pause sync</button>
    <p id="status" role="status">Sync disconnected</p>
    <form id="editor">
      <label>Note <textarea id="body" rows="8" cols="60"></textarea></label>
      <button id="save" disabled>Save locally</button>
    </form>
    <button id="reload" type="button">Load latest local version</button>
    <p id="message" role="status"></p>
    <h2>Conflicting revisions</h2>
    <pre id="conflicts">None loaded</pre>
    <script type="module" src="/main.ts"></script>
  </body>
</html>

Create main.ts:

typescript
import PouchDB from 'pouchdb-browser'

type Note = { body: string }
type StoredNote = PouchDB.Core.Document<Note> &
  PouchDB.Core.GetMeta & { _conflicts?: string[] }

function element<T extends HTMLElement>(id: string): T {
  const found = document.getElementById(id)
  if (!found) throw new Error(`Missing #${id}. Check index.html.`)
  return found as T
}

const local = new PouchDB<Note>('pouch-scratchpad-demo')
const editor = element<HTMLTextAreaElement>('body')
const status = element<HTMLParagraphElement>('status')
const message = element<HTMLParagraphElement>('message')
const conflicts = element<HTMLPreElement>('conflicts')
const save = element<HTMLButtonElement>('save')
save.disabled = true
let loading = false
let current: StoredNote | undefined
let sync: PouchDB.Replication.Sync<Note> | undefined
let remote: PouchDB.Database<Note> | undefined

function report(error: unknown): void {
  message.textContent = error instanceof Error
    ? error.message
    : 'Operation failed. Check credentials, permissions, and connectivity.'
}

function statusCode(error: unknown): number | undefined {
  if (typeof error === 'object' && error !== null && 'status' in error) {
    return Number(error.status)
  }
}

async function loadNote(): Promise<void> {
  if (loading) return
  loading = true
  save.disabled = true
  editor.disabled = true
  try {
    for (let attempt = 0; attempt < 3; attempt++) {
      let note: StoredNote
      try {
        note = await local.get('shared-note', { conflicts: true })
      } catch (error: unknown) {
        if (statusCode(error) !== 404) throw error
        current = undefined
        editor.value = ''
        conflicts.textContent = 'No note yet'
        message.textContent = 'Ready to create a local note.'
        save.disabled = false
        return
      }

      const versions = []
      let retry = false
      for (const revision of note._conflicts ?? []) {
        try {
          const other = await local.get('shared-note', { rev: revision })
          versions.push({ revision, body: other.body })
        } catch (error: unknown) {
          if (statusCode(error) !== 404) throw error
          retry = true
          break
        }
      }
      if (retry) continue

      current = note
      editor.value = note.body
      conflicts.textContent = versions.length
        ? JSON.stringify({ visible: note.body, alternatives: versions }, null, 2)
        : 'No conflicting revisions'
      save.disabled = versions.length > 0
      message.textContent = versions.length
        ? 'Conflict found. Preserve both versions before resolving it.'
        : 'Loaded the latest local version.'
      return
    }
    throw new Error('The note kept changing. Try Load latest local version again.')
  } finally {
    loading = false
    editor.disabled = false
  }
}

async function pauseSync(): Promise<void> {
  sync?.cancel()
  sync = undefined
  if (remote) await remote.close()
  remote = undefined
  status.textContent = 'Sync paused. Local saving still works.'
}

element<HTMLFormElement>('connection').addEventListener('submit', async (event) => {
  event.preventDefault()
  try {
    await pauseSync()
    const url = new URL(element<HTMLInputElement>('url').value)
    if (url.username || url.password || !url.pathname.slice(1)) {
      throw new Error('Use a database URL without embedded credentials.')
    }
    const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
    if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
      throw new Error('Use HTTPS, or HTTP only for a local loopback server.')
    }
    remote = new PouchDB<Note>(url.href, {
      skip_setup: true,
      auth: {
        username: element<HTMLInputElement>('username').value,
        password: element<HTMLInputElement>('password').value,
      },
    })
    await remote.info()
    element<HTMLInputElement>('password').value = ''
    sync = local.sync(remote, { live: true, retry: true })
    sync.on('active', () => { status.textContent = 'Exchanging revisions...' })
    sync.on('paused', (error: unknown) => {
      status.textContent = error
        ? 'Connection interrupted; retrying. Local saving still works.'
        : 'Replication paused; no pending work reported.'
    })
    sync.on('denied', report)
    sync.on('error', report)
    message.textContent = 'Sync connected. Load the latest local version when ready.'
  } catch (error: unknown) {
    report(error)
  }
})

element<HTMLFormElement>('editor').addEventListener('submit', async (event) => {
  event.preventDefault()
  if (loading || save.disabled) return
  try {
    if (current?._conflicts?.length) {
      throw new Error('Resolve the loaded conflicts before saving another edit.')
    }
    const result = await local.put({
      _id: 'shared-note',
      ...(current ? { _rev: current._rev } : {}),
      body: editor.value,
    })
    current = { _id: result.id, _rev: result.rev, body: editor.value }
    message.textContent = 'Saved on this device. Remote delivery depends on sync.'
  } catch (error: unknown) {
    if (statusCode(error) === 409) {
      message.textContent = 'A newer revision arrived. Copy your draft, load it, and merge.'
    } else {
      report(error)
    }
  }
})

element<HTMLButtonElement>('pause').addEventListener('click', async () => {
  try { await pauseSync() } catch (error: unknown) { report(error) }
})

element<HTMLButtonElement>('reload').addEventListener('click', async () => {
  try { await loadNote() } catch (error: unknown) { report(error) }
})

try { await loadNote() } catch (error: unknown) { report(error) }

Run the type check, then the development server:

bash
pnpm exec tsc
pnpm exec vite

Open http://localhost:5173. Enter the actual local CouchDB URL with /pouch_notes_demo appended, username pouch_demo, and password local-demo-only. Connect sync, write a note, and save locally. Use Load latest local version to display replicated changes; incoming changes intentionally do not overwrite a draft you are typing.

This example uses Basic authentication with the password held in memory while connected. It does not store it in localStorage or build-time variables. The local PouchDB database stores note content, so use a dedicated browser profile for the tutorial.

Prove that writes work without the server

After the first save, open the app in a second browser profile. Two tabs in the same profile can share the same IndexedDB database, which would not demonstrate independent devices.

Connect the second profile to the same CouchDB database and load the note. Now:

  1. Pause sync in both profiles.
  2. Edit and save the note in profile one.
  3. Reload the page in profile one. The local note should still be there, without reconnecting sync.
  4. Reconnect both profiles and load the latest local version in profile two.

The edit should arrive. Repeat with the local CouchDB server stopped:

bash
lbase stop pouch-notes

Save another edit in the still-open app, then restart the server:

bash
lbase start pouch-notes

A connected replication session uses retry: true to retry recoverable connection failures. After reconnection, load the note in the other profile. Incorrect passwords and rejected writes still require your attention. PouchDB replication guide

The Vite page itself is not an offline-installable app: a fresh page load needs its web server. This test proves local database persistence and synchronization while the remote database is unavailable. Shipping a fully offline web app also requires caching its application shell, usually through a service worker.

Make a conflict on purpose

Sync both profiles, load the same note in each, and pause both. Change the note differently in each profile and save both edits. Reconnect and load the latest local version after synchronization settles.

The conflict panel should show the visible body and another revision's body. The app disables ordinary saving when it has loaded unresolved conflicts. This makes the problem visible instead of silently treating the visible revision as the only edit.

There are two different failure modes:

  • A stale _rev in a write to one database produces a 409; copy your draft before loading the newer version.
  • Disconnected replicas can accept divergent edits. Replication preserves those branches and chooses a deterministic visible revision, not a semantic merge. PouchDB conflict guide

For this demo, copy both bodies out of the conflict panel, then use Fauxton's conflict-resolution interface on shared-note to choose or merge the intended content. Keep sync connected and reload the local version afterward. For an application, build that explicit choice into the UI: fetch all conflicting leaves, preserve the original text, write the agreed merge against the current winner, and remove only the losing revisions that were reviewed. Check every write result and recheck for concurrent changes.

Do not implement โ€œlast timestamp winsโ€ for important free-form notes without considering clock skew and lost work. The CouchDB conflict walkthrough explains the underlying revision behavior.

Move the remote to Layerbase Cloud

Once the local behavior is clear, create a managed CouchDB instance. Keep the generated server-admin credentials in your administrative workflow.

Repeat the database, regular-user, and member-permission setup above on the new instance, using a strong unique password. Set CORS to the exact origin of your deployed application. Copy the generated HTTPS hostname from Quick Connect and append /pouch_notes_demo for this test. Enter the regular user's credentials in the app.

Connecting the existing local replica to a new, empty remote uploads its content there. Use a new browser profile if you want an empty test instead. Never repoint a profile containing customer data at a different tenant's endpoint.

The hosted migration still needs an acceptance check: save in both directions, interrupt the connection, reconnect after idle, and verify a backup restore into a separate database. For immediate availability, consider pinning CouchDB always-on within the Pro pool. We do not assume a particular wake time or that continuous replication will let the instance sleep.

Before turning this into a multi-user product

The tutorial has one user and one note. A production app needs deliberate boundaries:

  • Provision users and databases from trusted server code. Each tenant should only receive credentials for databases it can access.
  • Treat database membership as access to the whole database. A replication filter is not a row-level security boundary. CouchDB database security
  • Handle logout and account switching. Disconnect sync and close or remove the correct local replica; never sync a previous user's local data into another account.
  • Plan for browser storage eviction and shared devices. IndexedDB is persistent storage, not a backup or encryption guarantee.
  • Show local-save and remote-sync states separately, and let users recover failed or conflicting edits.
  • Keep independent backups. Replication also carries deletions.

Stop the local tutorial instance when finished with lbase stop pouch-notes. To resume later, run lbase start pouch-notes and use lbase url pouch-notes to confirm its endpoint.

For a ready-made note-sync client, see Obsidian LiveSync with managed CouchDB. For your own app, the useful starting point is the same: save locally, replicate deliberately, and make conflicts recoverable.