Skip to content

Build an Offline Survey Sync Workflow with CouchDB

4 min readCouchDBDatabasesNoSQL

A field survey app cannot assume that every device has a stable connection. A worker may collect data for hours, reconnect later, and discover that someone at the office edited the same record.

CouchDB is useful here because replication is part of the database rather than an external synchronization service. The hard part is conflict handling: CouchDB preserves conflicting revisions, but your application still has to decide how to merge them.

Who this is for: TypeScript developers evaluating CouchDB for offline or multi-site data.

Outcome: Two databases that represent a field device and a central server, with replication and a real concurrent-edit conflict.

Time: About 20 minutes, plus the first CouchDB binary download.

Prerequisites: Node.js 20 or newer and pnpm. No Docker or Cloud account is required.

Start CouchDB locally

Install the Layerbase CLI, then create a local CouchDB instance:

bash
npm i -g layerbase
lbase create couch-surveys -e couchdb --start
lbase url couch-surveys

The last command prints the actual URL:

text
http://127.0.0.1:5984/couch_surveys

If port 5984 is busy, the CLI chooses another port. Copy the URL it prints rather than assuming the default.

The final path is the default database created with the instance. This tutorial creates two other databases, so the script reduces that URL to the server origin before connecting.

Local CouchDB uses admin as both the default username and password. The script below adds those credentials when COUCH_URL does not already contain credentials.

Create the project:

bash
mkdir couchdb-survey-sync
cd couchdb-survey-sync
pnpm init
pnpm add nano
pnpm add -D tsx typescript @types/node

Build the sync example

Create survey-sync.ts:

typescript
import nano from 'nano'

type Survey = {
  _id: string
  _rev?: string
  _conflicts?: string[]
  surveyor: string
  location: string
  responses: {
    satisfaction: number
    notes: string
  }
  reviewed?: boolean
}

const connectionUrl = new URL(
  process.env.COUCH_URL ?? 'http://127.0.0.1:5984',
)

if (!connectionUrl.username) {
  connectionUrl.username = 'admin'
  connectionUrl.password = 'admin'
}

connectionUrl.pathname = '/'
connectionUrl.search = ''
connectionUrl.hash = ''

const couch = nano(connectionUrl.toString())
const DEVICE_DB = 'survey_device_tutorial'
const SERVER_DB = 'survey_server_tutorial'

async function recreateDatabase(name: string): Promise<void> {
  const databases = await couch.db.list()
  if (databases.includes(name)) {
    await couch.db.destroy(name)
  }
  await couch.db.create(name)
}

await recreateDatabase(DEVICE_DB)
await recreateDatabase(SERVER_DB)

const device = couch.use<Survey>(DEVICE_DB)
const server = couch.use<Survey>(SERVER_DB)

const surveys: Survey[] = [
  {
    _id: 'survey-001',
    surveyor: 'Alice',
    location: 'North Ridge',
    responses: {
      satisfaction: 4,
      notes: 'Trail is open after overnight rain.',
    },
  },
  {
    _id: 'survey-002',
    surveyor: 'Ben',
    location: 'River Delta',
    responses: {
      satisfaction: 3,
      notes: 'Water level is above the seasonal average.',
    },
  },
  {
    _id: 'survey-003',
    surveyor: 'Alice',
    location: 'Coastal Bluff',
    responses: {
      satisfaction: 5,
      notes: 'No erosion near the marked observation point.',
    },
  },
]

await device.bulk({ docs: surveys })
console.log(`Saved ${surveys.length} surveys on the field device`)

await couch.db.replicate(DEVICE_DB, SERVER_DB)
const firstSync = await server.list()
console.log(`Central server now has ${firstSync.rows.length} surveys`)

// Both sides read the same revision before going offline.
const deviceCopy = await device.get('survey-001')
const serverCopy = await server.get('survey-001')

// The field worker changes the notes.
await device.insert({
  ...deviceCopy,
  responses: {
    ...deviceCopy.responses,
    notes: 'Trail reopened, but the lower bridge is still slippery.',
  },
})

// An office reviewer changes a different field on the same document.
await server.insert({
  ...serverCopy,
  reviewed: true,
})

await couch.db.replicate(DEVICE_DB, SERVER_DB)

const afterSync = await server.get('survey-001', { conflicts: true })
console.log(`Visible revision: ${afterSync._rev}`)
console.log(`Hidden conflicting revisions: ${afterSync._conflicts?.length ?? 0}`)

The script deliberately destroys and recreates only the two databases whose names end in _tutorial. Do not reuse those names for real data.

Run it with the URL returned by the CLI:

bash
COUCH_URL="$(lbase url couch-surveys)" pnpm tsx survey-sync.ts

Expected output:

text
Saved 3 surveys on the field device
Central server now has 3 surveys
Visible revision: 2-<revision hash>
Hidden conflicting revisions: 1

The revision hash will differ on every run. The important result is the final 1: both edits reached the central database, and CouchDB recorded a conflict.

What CouchDB did, and what it did not do

On a single database, an update must include the current _rev. A stale update is rejected with HTTP 409, which prevents an unnoticed overwrite on that node.

Replication is different. When two databases accept updates from the same starting revision, both revisions are copied. CouchDB chooses one revision as the visible winner using a deterministic algorithm. It does not understand that one person changed notes while another changed reviewed, and it does not merge those fields for you.

A production application should:

  1. Read documents with conflicts: true.
  2. Fetch each revision listed in _conflicts.
  3. Apply a domain-specific merge policy.
  4. Write the merged document as a new revision.
  5. Delete the losing revisions after the merge is verified.

Some data can be merged automatically. A set of tags is a good example. Free-form notes or a status transition may need a person to choose the correct value.

Do not use _rev as an audit log. The CouchDB document design guide warns that compaction can remove old revision bodies. If the business needs history, store immutable audit events or explicit document snapshots.

Replication is not a backup

The two databases in this tutorial are replicas. If your application deletes a survey and replication runs, that deletion can reach the other database. A bad update can replicate just as easily as a good one.

A backup needs a recovery point that is independent of the live replication stream. For a production system:

  • Keep scheduled backups or storage snapshots with a defined retention period.
  • Test restoring into a separate database.
  • Record document counts and sample checksums before a migration or cutover.
  • Keep the source database available until the restored target passes verification.

For a Cloudant move, the Cloudant to CouchDB migration guide includes cutover, verification, and rollback steps.

Run the same workflow on Layerbase Cloud

Once the local workflow is proven, create a CouchDB database on Layerbase Cloud and copy the generated HTTPS URL from Quick Connect into COUCH_URL. Do not construct the hostname or port yourself.

CouchDB requires the Pro plan. As verified on July 23, 2026, Pro is $15 per month, supports up to 10 databases, and includes daily backups with 30-day retention. Check the current pricing page before making a purchasing decision because plan details can change.

Cloud is a better fit when you want a managed central CouchDB endpoint with TLS and scheduled backups. It is not a replacement for conflict policy, restore testing, or device-side storage. PouchDB is usually a better device or browser companion than running a full CouchDB server on every client.

When CouchDB is the wrong fit

CouchDB is a strong candidate when disconnected writers and replication are central requirements. It is usually a poor fit when:

  • The application needs relational joins and multi-row constraints.
  • Most work is ad-hoc analytics across many fields.
  • There is only one central service and ordinary CRUD is the whole problem.
  • The team does not have a clear conflict-resolution policy.

For local development, the Layerbase CLI is the shortest path. Layerbase Desktop provides the same local engine workflow in a GUI on macOS, Windows, and Linux. Move to Cloud after the sync and recovery behavior is understood, not before.