oyware [email protected]

A questionnaire everyone edits together

forms.oyware.com, a home for client questionnaires. One shared copy per form, saved on every tap, live on every device. Static pages, an 18 MB Node process, one Postgres table

2026-10-13HTML, plain JavaScript, Node 22 (node:http), pg, PostgreSQL 16 (jsonb), Nginx

Before building anything for a client, I ask them what it should do. The page went through three versions in one evening, and each one taught something:

  1. Answers turned into a WhatsApp message. It worked, but it didn't look professional, and the answers lived in a chat.
  2. A Send button that saved each person's answers to Postgres and emailed me. More professional, but each browser had its own copy. Answers typed in incognito were invisible from normal Chrome, which isn't what I wanted.
  3. One shared copy per form. No Send button and no email. Every tap saves, and anyone who opens the link on any device sees the same answers. The next day I open the same link and the answers are there.

Version 3 is what runs at forms.oyware.com/<name>. The first form is the requirements questionnaire for a volunteer prayer-tracker app for a school class, at /salahstars.

The shape

browser ──► Cloudflare ──► Nginx :443
                             ├── /salahstars/          static HTML from /var/www/forms.oyware.com
                             └── /api/forms/salahstars  forms-api on 127.0.0.1:3004 ──► Postgres "forms"
                                  GET  = read the shared copy
                                  POST = patch it with what changed

Nginx serves the page straight from disk. Node only reads and patches one row per form, so it is under 200 lines of node:http with one dependency, pg. It sits at about 18 MB of RAM, which matters on a 1 GB droplet shared with five other apps.

Why no framework

There are three routes: GET and POST /api/forms/:name, plus GET /api/health. Fastify or Express would add more code than the app has. With no TypeScript there is also no build step: systemd runs node --max-old-space-size=128 server/index.mjs directly, with MemoryMax=160M as a hard ceiling. That still follows the "production builds only" rule, because nothing in memory is a dev tool.

Send only what changed, let Postgres merge it

If every save sent the whole form, a phone and a laptop open at the same time would overwrite each other's answers. So the page keeps two copies:

Whatever differs between them is this page's unsaved change, and only those questions go to the server. Postgres merges them into the stored answers with jsonb ||, which replaces just the keys that were sent:

INSERT INTO responses (form, client_key, respondent, answers, notes, summary)
VALUES ($1, 'shared', COALESCE($2, ''), $3, $4, COALESCE($5, ''))
ON CONFLICT (form, client_key) DO UPDATE
  SET answers = responses.answers || EXCLUDED.answers,
      notes   = responses.notes   || EXCLUDED.notes,
      respondent = CASE WHEN $2::text IS NULL THEN responses.respondent ELSE EXCLUDED.respondent END,
      submit_count = responses.submit_count + 1, updated_at = now()
RETURNING answers, notes, respondent, submit_count AS version, updated_at

The reply is the merged document. The page takes the server's value for every question it hasn't changed itself and keeps its own for the ones it has. A test covers the case that matters: a phone and a laptop editing different questions, then the phone changing its mind, and both edits surviving.

Live on every device, without websockets

Each open page asks GET /api/forms/<name> every 5 seconds while it's visible. If version moved, it applies the new answers. It never overwrites the field someone is typing in; that field catches up when it loses focus. For one or two people filling a form, polling is simpler than websockets and costs nothing: the document is about 10 KB, and a hidden tab doesn't poll.

Saving is debounced by 0.7 s, one request at a time, retried with backoff when the network drops, and flushed with fetch(..., { keepalive: true }) when the tab is hidden or closed. The top bar says Saving…, ✓ Saved or Offline.

The bug: jsonb reorders keys

The first shared version saved the form 46 times in a minute for five changes. The priority grid is an object: {"checkin": "Must have now", "qibla": "Later"}. Postgres jsonb doesn't keep key order; it stores keys shorter-first, so the object came back as {"qibla": …, "checkin": …}. The page compared answers with JSON.stringify, saw a "change", saved again, got the reordered object back, and looped forever.

The fix is to compare by content with the keys sorted:

const canon = (v) => Array.isArray(v) ? v.map(canon)
  : v && typeof v === "object" ? Object.keys(v).sort().reduce((o, k) => (o[k] = canon(v[k]), o), {})
  : v;
const same = (x, y) => JSON.stringify(canon(x ?? null)) === JSON.stringify(canon(y ?? null));

How it was caught: I let two browsers sit idle and read the version counter before and after. It went up 22 in 10 seconds with nobody touching anything. After the fix, one tap makes one save and an idle page makes none. "It saved" isn't enough of a check; count how many times.

The same problem waits for anyone who compares jsonb round-trips as strings.

Small guards, no accounts

The form is an unlisted public link. Anyone who has it can read and edit the answers, which is the point. The API still protects itself:

Guard What it stops
Origin must be https://forms.oyware.com on writes another site posting into the table
form name must be in server/forms.json writes to forms that don't exist
a patch is ≤ 300 keys of strings, lists of strings or objects of strings; body ≤ 200 KB junk and oversized posts
600 saves per IP per 10 minutes a script hammering it (autosave needs headroom)
hidden "website" field simple bots, which fill every field and get a quiet fake "ok"

The rate-limit map is swept on an unref'd interval and capped at 5,000 IPs, so it can't grow without bound. The IP comes from X-Real-IP, which Nginx sets after Cloudflare's real-IP rewrite. The app trusts that header only because it listens on 127.0.0.1 and nothing else can reach it.

Pages send X-Robots-Tag: noindex and Cache-Control: no-cache, so search engines skip them and an edited question shows up on the next load.

Adding a new questionnaire

  1. Copy public/salahstars/ to public/<name>/, replace the SECTIONS array (each question is { id, t: "one" | "many" | "short" | "long" | "grid", q, o }) and set API = "/api/forms/<name>".
  2. Add "<name>": { "title": "…" } to server/forms.json.
  3. ./scripts/deploy.sh. It runs the tests, backs up the database, ships pages and API, and checks both return 200.
  4. Read the answers by opening the link, or with ./scripts/responses.sh <name>.

No DNS, Nginx or database work: the subdomain, the table and the backup already exist.

What the first deploy took