Build an Offline-First App with PouchDB and CouchDB
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:
pnpm add -g layerbase
lbase create pouch-notes -e couchdb --start
lbase url pouch-notesKeep 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:
- Create a non-partitioned database named
pouch_notes_demo. - Open the
_usersdatabase. If it does not exist on this local instance, create a non-partitioned database named_usersfirst. Create the following document inside it. This password is deliberately for the loopback-only demo.
{
"_id": "org.couchdb.user:pouch_demo",
"name": "pouch_demo",
"type": "user",
"roles": [],
"password": "local-demo-only"
}- In
pouch_notes_demopermissions, addpouch_demoto member names. Keep_adminunder administrator roles. The resulting_securitydocument should be:
{
"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:
| Section | Key | Value |
|---|---|---|
chttpd | enable_cors | true |
cors | credentials | true |
cors | origins | http://localhost:5173 |
cors | methods | GET,PUT,POST,HEAD,DELETE |
cors | headers | accept,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:
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@6Set "type": "module" in package.json. Add .env and node_modules/ to .gitignore if you keep the example in a repository.
Create vite.config.ts:
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:
{
"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:
<!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:
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:
pnpm exec tsc
pnpm exec viteOpen 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:
- Pause sync in both profiles.
- Edit and save the note in profile one.
- Reload the page in profile one. The local note should still be there, without reconnecting sync.
- Reconnect both profiles and load the latest local version in profile two.
The edit should arrive. Repeat with the local CouchDB server stopped:
lbase stop pouch-notesSave another edit in the still-open app, then restart the server:
lbase start pouch-notesA 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
_revin a write to one database produces a409; 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.
Keep reading
- Set Up Obsidian LiveSync with Managed CouchDBConnect Obsidian Self-hosted LiveSync to managed CouchDB, configure a private sync database, add another device, and verify offline recovery.
- CouchDB alternatives in 2026: pick by replication modelCompare CouchDB alternatives for offline-first sync, document storage, managed hosting, and relational data. The right replacement depends on which part of CouchDB you actually use.
- Postgres mTLS from Supabase Edge Functions: a step-by-step guideSupabase Edge Functions connect from rotating, shared egress IPs, so IP allowlisting cannot pin them. Here is how to lock down a Postgres database with client-certificate (mTLS) auth and connect to it from a Deno edge function, step by step.
- Branching with any databaseNeon branches Postgres. PlanetScale branches MySQL. Layerbase branches all of them, because branching happens at the filesystem, not inside the engine. Here is how it works.