A feedback button is easy to ship. A feedback system is harder.
The difference is not the input form. It is what happens after someone presses Send:
- Was the report stored before the API responded?
- Did it include enough context to investigate?
- Who owns the next action?
- Can the team reply through the original channel?
- What happens when notifications fail?
- How do old reports become visible before they are forgotten?
This tutorial builds a small but operational feedback workflow with TypeScript, Express, and PostgreSQL. The same design works for bug reports, feature requests, support questions, and in-product conversations.
Start with the workflow, not the widget
A useful feedback lifecycle can be modeled as:
captured -> new -> acknowledged -> planned/resolved/closed
|
+-> needs_more_information
Every active item should have three properties:
- An owner responsible for the next decision.
- A next-action timestamp that makes inactivity queryable.
- A reply path when the reporter expects a response.
Without those properties, a unified inbox merely creates a unified place to ignore.
The system we will build has four parts:
Browser or app
|
v
Feedback API -----> PostgreSQL
|
v
Transactional outbox
|
v
Notification worker
|
v
Chat, email, or issue tracker
PostgreSQL is the source of truth. Chat tools and email are delivery surfaces, not databases.
1. Store feedback, context, and delivery state
Enable UUID generation and create three tables:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key uuid NOT NULL UNIQUE,
source text NOT NULL CHECK (
source IN ('web', 'mobile', 'chat', 'email', 'api')
),
message text NOT NULL CHECK (char_length(message) BETWEEN 1 AND 5000),
context jsonb NOT NULL DEFAULT '{}'::jsonb,
reporter_id text,
reply_channel text,
reply_ref text,
status text NOT NULL DEFAULT 'new' CHECK (
status IN (
'new',
'acknowledged',
'needs_more_information',
'planned',
'resolved',
'closed'
)
),
owner_id text,
next_action_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE feedback_event (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
event_type text NOT NULL,
actor_id text,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE feedback_outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
kind text NOT NULL,
payload jsonb NOT NULL,
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
claimed_at timestamptz,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (feedback_id, kind)
);
CREATE INDEX feedback_open_next_action_idx
ON feedback (next_action_at)
WHERE status NOT IN ('resolved', 'closed');
CREATE INDEX feedback_outbox_pending_idx
ON feedback_outbox (available_at)
WHERE delivered_at IS NULL;
context should contain a small allowlist of diagnostic fields, such as:
- Application release
- Current route, without query parameters
- Feature name
- Browser family
- Request or trace ID
- Account plan or role, if genuinely useful and permitted
Do not turn contextual feedback into accidental surveillance. Avoid access tokens, form contents, complete URLs, arbitrary local storage, or sensitive user attributes.
reply_ref should be an opaque channel identifier rather than a copied conversation. Encrypt it if it grants access or contains personal data.
2. Capture the closest useful context
The best time to collect technical context is when the user submits the report. Asking someone later which release or screen they were using creates avoidable work.
Here is a minimal browser client with retry-safe idempotency:
const release = document
.querySelector<HTMLMetaElement>('meta[name="app-release"]')
?.content ?? 'unknown';
export async function submitFeedback(message: string): Promise<string> {
// Generated once and reused across retries of this submission.
const idempotencyKey = crypto.randomUUID();
const body = {
idempotencyKey,
source: 'web',
message,
context: {
pagePath: location.pathname,
release,
feature: document.body.dataset.feature ?? 'unknown'
}
};
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch('/api/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (response.ok) {
const result = await response.json();
return result.id;
}
if (response.status < 500) {
throw new Error(`Feedback rejected with ${response.status}`);
}
} catch (error) {
if (attempt === 2) throw error;
}
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
}
throw new Error('Feedback could not be submitted');
}
The idempotency key prevents a network timeout from creating several identical reports. Generate a new key for a genuinely new submission.
3. Persist the report and notification atomically
A common failure looks like this:
await database.insert(feedback);
await sendChatNotification(feedback); // Process crashes here.
The feedback exists, but nobody is notified. Reversing the order is also unsafe because the notification can succeed while the database insert fails.
Use a transactional outbox instead. The report and the request to notify are committed together.
import express from 'express';
import { z } from 'zod';
import { pool } from './db.js';
const app = express();
app.use(express.json({ limit: '16kb' }));
const feedbackInput = z.object({
idempotencyKey: z.string().uuid(),
source: z.enum(['web', 'mobile', 'chat', 'email', 'api']),
message: z.string().trim().min(1).max(5000),
context: z.object({
pagePath: z.string().max(500).optional(),
release: z.string().max(100).optional(),
feature: z.string().max(100).optional(),
traceId: z.string().max(200).optional()
}).strict().default({})
});
function removeQueryAndFragment(path = ''): string {
return path.split(/[?#]/, 1)[0].slice(0, 500);
}
app.post('/api/feedback', async (req, res) => {
const parsed = feedbackInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid feedback payload' });
}
const input = parsed.data;
const context = {
...input.context,
pagePath: removeQueryAndFragment(input.context.pagePath)
};
const client = await pool.connect();
try {
await client.query('BEGIN');
const inserted = await client.query<{ id: string }>(
`INSERT INTO feedback (
idempotency_key, source, message, context, reporter_id
)
VALUES ($1, $2, $3, $4::jsonb, $5)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`,
[
input.idempotencyKey,
input.source,
input.message,
JSON.stringify(context),
// Derive identity from trusted authentication middleware.
req.user?.id ?? null
]
);
let feedbackId: string;
if (inserted.rowCount === 1) {
feedbackId = inserted.rows[0].id;
await client.query(
`INSERT INTO feedback_outbox (feedback_id, kind, payload)
VALUES ($1, 'feedback.created', $2::jsonb)`,
[feedbackId, JSON.stringify({ source: input.source })]
);
} else {
const existing = await client.query<{ id: string }>(
`SELECT id FROM feedback WHERE idempotency_key = $1`,
[input.idempotencyKey]
);
feedbackId = existing.rows[0].id;
}
await client.query('COMMIT');
return res.status(202).json({ id: feedbackId });
} catch (error) {
await client.query('ROLLBACK');
console.error(error);
return res.status(500).json({ error: 'Feedback could not be stored' });
} finally {
client.release();
}
});
In production, add rate limiting, bot protection where appropriate, and a maximum request body size. Do not trust a client-provided user ID; derive it from the authenticated session.
4. Deliver notifications with a reclaimable worker
Multiple workers can safely claim jobs with FOR UPDATE SKIP LOCKED:
async function claimJob() {
const result = await pool.query(
`WITH candidate AS (
SELECT id
FROM feedback_outbox
WHERE delivered_at IS NULL
AND available_at <= now()
AND (
claimed_at IS NULL OR
claimed_at < now() - interval '5 minutes'
)
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE feedback_outbox AS job
SET claimed_at = now(), attempts = attempts + 1
FROM candidate
WHERE job.id = candidate.id
RETURNING job.*`
);
return result.rows[0] ?? null;
}
async function runWorker() {
while (true) {
const job = await claimJob();
if (!job) {
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
try {
await notificationAdapter.send({
eventId: job.id,
type: job.kind,
feedbackId: job.feedback_id,
payload: job.payload
});
await pool.query(
`UPDATE feedback_outbox
SET delivered_at = now()
WHERE id = $1`,
[job.id]
);
} catch (error) {
const delaySeconds = Math.min(300, 2 ** job.attempts);
await pool.query(
`UPDATE feedback_outbox
SET claimed_at = NULL,
available_at = now() + ($2 * interval '1 second')
WHERE id = $1`,
[job.id, delaySeconds]
);
}
}
}
This is at-least-once delivery. If the worker crashes after sending but before updating delivered_at, the notification may be repeated. Include eventId in the outgoing request and use destination-side idempotency when available. Otherwise, make duplicate notifications visibly harmless.
After a reasonable number of failed attempts, move the job to a dead-letter state and alert on it. Infinite silent retries are another kind of abandoned inbox.
5. Make ownership part of the data model
A notification saying “new feedback arrived” is not an assignment.
Create a triage operation that records both the state change and its history:
BEGIN;
UPDATE feedback
SET status = 'acknowledged',
owner_id = $2,
next_action_at = $3,
updated_at = now()
WHERE id = $1
AND status = 'new';
INSERT INTO feedback_event (
feedback_id,
event_type,
actor_id,
data
)
VALUES (
$1,
'feedback.assigned',
$2,
jsonb_build_object('nextActionAt', $3)
);
COMMIT;
Choose a policy appropriate to the team. For example:
- Rotate a daily triage owner.
- Assign by product area.
- Send security reports to a restricted queue.
- Separate support questions from product decisions.
- Require a next action for every non-closed item.
A feature request does not have to become roadmap work. “Reviewed and closed” is a valid outcome when it is explicit.
6. Query inactivity instead of relying on memory
A feedback channel stays healthy when neglect is measurable. Start with a few operational queries rather than a large analytics dashboard.
-- New reports without an owner
SELECT id, source, message, created_at
FROM feedback
WHERE status = 'new'
AND owner_id IS NULL
ORDER BY created_at;
-- Active reports whose next action is overdue
SELECT id, owner_id, status, next_action_at
FROM feedback
WHERE status NOT IN ('resolved', 'closed')
AND next_action_at < now()
ORDER BY next_action_at;
-- Oldest pending notification
SELECT min(created_at) AS oldest_pending_delivery
FROM feedback_outbox
WHERE delivered_at IS NULL;
Useful health signals include:
- Age of the oldest unowned report
- Number of overdue next actions
- Notification delivery failures
- Time from capture to acknowledgement
- Reports waiting for information from the user
Set thresholds from the service promise you can actually maintain. Do not label a channel “live” if nobody is expected to monitor it live.
7. Preserve the return path
Feedback is often a conversation, not a one-way event. Represent outbound replies through an adapter:
interface ReplyTarget {
channel: 'web-chat' | 'email' | 'mobile' | 'none';
reference?: string;
}
interface ReplyAdapter {
send(target: ReplyTarget, message: string): Promise<void>;
}
When a reply succeeds, append a feedback.reply_sent event. When it fails, queue it through an outbox just like the original notification.
For anonymous forms with no return path, say so in the UI. For chat or authenticated applications, keep the channel reference needed to continue the conversation. Avoid asking for an email address unless a reply actually requires it.
Common failure modes
Treating chat as durable storage
Chat history can be deleted, access can change, and messages can scroll away. Store the canonical report before notifying chat.
Collecting context without limits
An unrestricted client payload can leak secrets or create oversized rows. Use a strict schema, field length limits, and an allowlist.
Generating a new ID on every retry
This produces duplicates during temporary network failures. Reuse one idempotency key for all attempts of the same submission.
Promising real-time support without staffing it
A live widget changes user expectations. Display expected availability or use asynchronous wording when immediate responses are not guaranteed.
Mixing feedback directly into the roadmap
Reports are evidence, not automatically requirements. Keep capture, triage, and product prioritization as separate steps.
Closing an item without responding
If the user expects a reply, an internal status update is insufficient. Track outbound delivery as part of the workflow.
Having no owner for the queue
Shared responsibility often becomes no responsibility. Use a named person, rotation, or deterministic routing rule.
A hosted option for founder-led live chat
After the workflow is defined, a hosted contact layer can replace some custom capture and reply-channel code.
Knocket is one implementation example for makers and small teams. It provides a shareable contact page, an embeddable web live-chat widget, a mobile WebView SDK, and a unified inbox. Website installation uses a script tag without requiring a custom backend, and visitors do not need an account to begin a chat.
In a Knocket setup, messages can be routed to Telegram, and a quoted Telegram reply can be delivered back to the website visitor. That can make founder-led replies convenient, but it does not remove the need for ownership, retention decisions, escalation rules, or a separate product backlog.
When evaluating any hosted option, check:
- Whether its data handling matches your privacy policy
- Whether conversation history can be exported or retained as required
- How outages and failed deliveries are surfaced
- Whether the reply path works for anonymous visitors
- Whether the channel creates response-time expectations you can meet
Ship the operational loop
Before publishing a feedback entry point, verify that:
- Submission and notification intent are committed atomically.
- Retries do not create duplicate reports.
- Context is minimal, allowlisted, and privacy-conscious.
- Every active report can have an owner and next action.
- Failed notifications are retried and observable.
- The team can reply through the original channel when promised.
- Overdue reports appear in a query, digest, or alert.
- Closing feedback is distinct from adding work to the roadmap.
The goal is not to collect more messages. It is to create a short, reliable path from user context to an owned decision—and, when appropriate, back to the user.
Disclosure: I work on Knocket, so treat it as one implementation example rather than a neutral recommendation.
