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
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:
- Answers turned into a WhatsApp message. It worked, but it didn't look professional, and the answers lived in a chat.
- 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.
- 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:
state: what the page shows.synced: the last copy the server confirmed.
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
- Copy
public/salahstars/topublic/<name>/, replace theSECTIONSarray (each question is{ id, t: "one" | "many" | "short" | "long" | "grid", q, o }) and setAPI = "/api/forms/<name>". - Add
"<name>": { "title": "…" }toserver/forms.json. ./scripts/deploy.sh. It runs the tests, backs up the database, ships pages and API, and checks both return 200.- 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
- An
Arecord forforms, proxied. The*.oyware.comorigin certificate already covered it. - A
formsdatabase and user, with the Postgres 15+GRANT ALL ON SCHEMA publicstep. .env(mode600), Nginx block (static pages +/api/proxy),forms-api.service, a nightlybackup-db.sh formscron line, andformsadded to the Mac's backup pull.- An end-to-end check with two isolated browsers standing in for two devices: edits on A appear on B within 5 seconds and the other way round, the server copy matches, idle pages make no saves. Then the test answers were deleted.