How We Process Customer Feedback at Scale: Our Architecture
TL;DR
FeedSense processes customer feedback at scale with a deliberately boring, PostgreSQL-centric pipeline: a Fastify API ingests feedback, a batching layer groups items, pg-boss (a Postgres-backed job queue) durably enqueues analysis jobs, a single-threaded worker calls our AI service behind an opossum circuit breaker, and failures fall through exponential-backoff retries into dead-letter queues. We chose pg-boss over BullMQ specifically to avoid adding Redis as a hard dependency for our jobs of record. The hard parts were never the AI calls -- they were retries, poison messages, dead-letter management, and making batch processing reliable when the thing you depend on (an external AI service) is allowed to fail.
Full disclosure: I am the founder of FeedSense, and I built this pipeline. This post is the real architecture from a production system, including the trade-offs I'd reconsider and one validation result that contradicted our original plan. Where I don't have a hard measured number, I say so rather than invent one -- technical readers can smell a fabricated benchmark from across the room.
Why Is Processing Customer Feedback Harder Than Collecting It?
Processing feedback is harder than collecting it because collection is a solved, synchronous problem -- accept an HTTP request, validate it, write a row -- while processing is an asynchronous, failure-prone pipeline that depends on slow external AI inference, must survive partial failures, and has to stay correct when individual items are malformed. Collection is a sprint. Processing is a system.
The volume only widens this gap. An IDC white paper sponsored by Box reports that roughly 90% of enterprise data is unstructured, and IDC projects that unstructured data is growing 55-65% annually -- far faster than structured data. Customer feedback is squarely in that category: free-text comments, support transcripts, survey responses, app-store reviews. Collecting it is easy; making sense of it at volume is where teams drown.
Here is the asymmetry in concrete terms. Writing a feedback row to a database takes single-digit milliseconds and either succeeds or fails cleanly. Analyzing that same row -- detecting sentiment, emotion, aspect-level signal, and category -- means calling an AI model whose latency is not just slow but wildly variable: one LLM inference SLO guide notes that "a single long-prompt request that hits the queue during a burst can push P99 to 3-4x the median." That same AI call can also time out, rate-limit you, return a malformed payload, or briefly fall over. The collection path has one failure mode. The processing path has a dozen, and most of this post is about handling them.
What Actually Goes Wrong When You Process Feedback at Scale?
The things that go wrong at scale are rarely the happy-path analysis -- they are the edge cases that only show up under volume: a single malformed item that crashes an entire batch, an AI service outage that triggers a retry storm, jobs that get picked up but never finish, and duplicate processing from at-least-once delivery. Each one is individually rare and collectively guaranteed.
This is the lesson that reorders your priorities once you run a pipeline in production: the analysis model is the easy 80%; the failure handling is the hard 20% that decides whether the system is trustworthy. These failures are not hypothetical at scale -- Microsoft's Azure Architecture Center documents a single real retry storm in which one client generated over 21,000 failed connection attempts to a dependency in a short window, and 81,608 inbound connections from a single IP address in one hour, the kind of self-inflicted overload that takes a recovering service back down. A pipeline that silently drops 5% of feedback is worse than no pipeline, because you trust numbers that are quietly wrong.
For the conceptual groundwork on what "analysis" even means here -- sentiment, themes, prioritization -- our complete guide to feedback analysis is the pillar this post sits under. This article is the engineering layer beneath that guide.
What Does FeedSense's Feedback Analysis Architecture Look Like?
FeedSense's architecture is a four-stage asynchronous pipeline: a Fastify API ingests and validates feedback, a batching service groups items into efficient units, pg-boss durably queues analysis jobs in PostgreSQL, and a worker pulls jobs and calls our stateless AI service before writing results back. Every stage is designed so that a failure in one does not corrupt the others.
Here is the high-level flow:
┌──────────────────────────────────────────────────────┐
│ FeedSense Backend │
│ (Fastify + Node) │
Feedback in │ │
───────────────► │ ┌────────────┐ ┌──────────────────────┐ │
SDK / API / │ │ Fastify │ │ Unified Batching │ │
CSV import / │ │ API layer │ ───► │ Service │ │
integrations │ │ (validate,│ │ (group items: │ │
│ │ persist) │ │ 8/45s or 50/60s) │ │
│ └────────────┘ └──────────┬───────────┘ │
│ │ boss.send() │
│ ▼ │
│ ┌──────────────────────┐ │
│ PostgreSQL ◄──────►│ pg-boss job queue │ │
│ (one DB: │ feedback-analysis │ │
│ data + jobs) │ (SKIP LOCKED) │ │
│ └──────────┬───────────┘ │
│ │ boss.work() │
│ ▼ batchSize: 1 │
│ ┌──────────────────────┐ │
│ │ Feedback Analysis │ │
│ │ Worker │ │
│ │ + poison filter │ │
│ │ + opossum breaker │ │
│ └──────┬─────────┬──────┘ │
│ results write-back│ │ HTTP │
│ ▼ ▼ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ Postgres │ │ retry → │ │
│ │ feedback │ │ DLQ on │ │
│ │ rows │ │ exhaustion │ │
│ └──────────┘ └─────────────┘ │
└────────────────────────────────────┼──────────────────┘
│ POST /feedback/batch
▼
┌────────────────────────┐
│ AI Service (separate) │
│ FastAPI, stateless │
│ pure compute, no Redis │
└────────────────────────┘
The single most important property of this diagram is what is not in it: there is no separate Redis instance acting as the durable system of record for jobs. The jobs live in the same PostgreSQL database as the feedback itself -- a deliberate choice the next section explains.
Why Did We Choose pg-boss Over BullMQ?
We chose pg-boss over BullMQ because pg-boss stores jobs in PostgreSQL -- the database we already run -- so our jobs of record gained durability and transactional proximity to our data without adding Redis as a hard dependency. BullMQ is excellent, but it is Redis-native, which means your durable job state lives in a second datastore with a different failure and persistence model than your primary data.
The trade-off is real, because pg-boss is not free wins. BullMQ uses Redis Lua scripts so queue state transitions happen atomically in memory, which is why it scales more gracefully at the high end. PostgreSQL-backed queues use SELECT ... FOR UPDATE SKIP LOCKED to hand each job to exactly one worker without blocking the others -- elegant and race-free. But as that same comparison frames it, "at very high throughput (thousands of jobs per second), the database locking overhead becomes a bottleneck that Redis handles more gracefully." That figure marks where the Postgres approach starts hitting lock contention, not a measured ceiling for BullMQ -- and we knowingly traded that high-end headroom for operational simplicity.
That trade made sense for us for three concrete reasons specific to a feedback tool, not a general high-frequency trading queue:
- Our job rate is modest and bursty, not sustained-high. Feedback arrives in waves -- a CSV import, a survey campaign, a spike after a release -- not as a constant firehose, so we are nowhere near the contention threshold where Redis-native queues earn their keep.
- One datastore is one thing to operate. As a small team, every piece of infrastructure is something I personally have to back up, monitor, secure, and reason about during an incident. Keeping jobs in Postgres meant one fewer stateful system in the 3am pager path.
- Jobs next to data unlocks transactional enqueue. Because pg-boss runs on Postgres, you can -- in principle -- create a database row and enqueue its job in the same transaction, so you never end up with a row that has no job or a job that has no row. (This one came with an asterisk. See the next section.)
For the record, BullMQ is the right call for a different shape of problem: a queue that's the hottest path in a system doing hundreds of thousands of operations per second, with Redis already a first-class datastore. We picked the boring option on purpose -- the same "don't add a dependency you don't need" instinct behind why we don't charge per tracked user.
What Surprised Us About pg-boss in Practice?
What surprised us was that the "killer feature" we picked pg-boss for -- creating data and enqueuing its job atomically through Prisma -- did not work the way the documentation implied. We had planned the entire migration around it, then validated the assumption before building on it, and the result came back partially negative.
Here is the specific, only-the-builder-knows-it finding. pg-boss can run against an external database adapter, and the plan was to hand it Prisma's transaction client so that boss.send() would participate in a prisma.$transaction(). When we actually tested it, pg-boss's internal schema-maintenance queries use PostgreSQL regclass types that Prisma's $queryRawUnsafe cannot deserialize, so pg-boss could not run its own startup and supervision queries through the Prisma adapter at all. The adapter approach was confirmed broken for pg-boss's main connection.
But the test also surfaced the nuance that let us keep going. Individual boss.send() calls generate plain INSERT statements with no exotic types, so a transactional send() does work through the Prisma transaction client even though the main connection does not. We validated this with a dedicated script that committed a row plus a job atomically, then rolled both back, and confirmed both behaved correctly. So our resolution was a hybrid: pg-boss runs on its own small dedicated connection pool (capped at five connections) for its lifecycle and polling, and we reach for the transactional send() path only where atomic enqueue genuinely matters.
How Does the Job Queue Process Feedback Reliably?
The queue processes feedback reliably by treating every job as durable, retryable, and isolatable: items are batched, enqueued into a Postgres-backed feedback-analysis queue, pulled by a single worker, and -- on failure -- automatically retried with exponential backoff before being moved to a dead-letter queue. The worker's contract is intentionally simple: returning marks a job complete, throwing tells pg-boss to retry, and exhausting the retry limit routes the job to the DLQ.
Walking the path a single piece of feedback takes makes the design legible:
- Ingest. The Fastify API validates the incoming feedback and persists it.
- Batch. A batching service accumulates items rather than enqueuing one job per comment -- real-time feedback flushes at 8 items or 45 seconds, CSV/bulk imports at 50 items or 60 seconds -- so a single AI request's overhead is amortized across a group.
- Enqueue. The batch becomes a single pg-boss job on the
feedback-analysisqueue, carrying the full items in its payload (capped around 50 items / ~10KB so we never bloat a job row). - Work. A worker pulls the job, filters out any quarantined items, and calls the AI service.
- Write back. Results are written to the feedback rows in chunks of 50 updates at a time.
- Retry or dead-letter. If the job throws, pg-boss retries it on a backoff schedule; if it exhausts its retries, the job lands in
feedback-analysis-dlqfor inspection.
Why Do We Process Jobs Single-Threaded?
We run the feedback worker single-threaded -- pg-boss batchSize: 1, one job at a time -- because our downstream AI service is protected by a circuit breaker, dynamic timeouts, and rate assumptions that were all designed around serial access. Cranking up concurrency would let the worker stampede a recovering AI service exactly when it is most fragile.
This is a case where the "obvious optimization" -- more workers, more throughput -- is a trap. The bottleneck is not our worker loop; it is the external AI inference, so a single orderly consumer applies backpressure naturally instead of piling on when the breaker trips. We also pass pg-boss includeMetadata: true so the handler can read the real retry count of the current job, which drives the poison-isolation behavior described below. Concurrency is a dial we can turn up later with the circuit breaker retuned to match -- straight out of the KISS and YAGNI playbook: don't build for a scale problem you don't have yet.
How Does Exponential Backoff Work for Failed Analyses?
Failed analyses retry on exponential backoff: the feedback-analysis queue is configured for three retries with a 60-second base delay that doubles each attempt -- roughly 60s, then 120s, then 240s, about seven minutes of patient retrying before a job is declared dead. Exponential backoff matters because a fixed retry interval hammers a struggling service in lockstep, while a doubling delay gives a transient outage room to recover.
We also centralize a set of named backoff presets in one module, so "how long do we wait before retrying X" is a single auditable decision rather than a magic number scattered across the codebase. The presets encode the different patience levels different failures deserve:
| Backoff preset | Base delay | Max delay | Used for |
|---|---|---|---|
| AI service retry | 5 seconds | 300 seconds | AI inference errors (the slow, recoverable kind) |
| Data error retry | 2 seconds | 300 seconds | Malformed-data errors (fail faster, recover faster) |
| Job recovery | 30 seconds | 300 seconds | Re-hydrating stuck jobs |
| Transaction retry | 100 ms (±25% jitter) | uncapped | Optimistic DB write contention |
Two design choices in that table are worth calling out. First, every preset that retries against a remote or contended resource has a cap (300 seconds), so backoff never grows unbounded into hours. Second, the transaction-retry preset is the only one with jitter -- a ±25% random spread -- because database write contention is exactly the thundering-herd scenario jitter exists to break up. As Marc Brooker, VP and Distinguished Engineer at AWS, frames the goal in the AWS Architecture Blog, "we want to spread out the spikes to an approximately constant rate." AI retries don't get jitter because a single serial worker isn't a herd.
How Do Circuit Breakers Protect the AI Service?
Circuit breakers protect the AI service by tracking the recent failure rate of calls and, once failures cross a threshold, "tripping" to reject further calls instantly instead of piling more load onto a service that is already struggling. This is not a theoretical risk: one production-resilience analysis reports that LLM API uptime across the industry fell from 99.66% to 99.46% between Q1 2024 and Q1 2025 -- 60% more downtime year-over-year as demand outpaced infrastructure. FeedSense wraps every AI call with opossum, a battle-tested circuit-breaker library, so a slow or down AI service fails fast and recovers cleanly rather than dragging the whole pipeline down with it.
The pattern itself predates us by decades. As Martin Fowler describes it, crediting Michael Nygard's book Release It!: "You wrap a protected function call in a circuit breaker object, which monitors for failures. Once the failures reach a certain threshold, the circuit breaker trips, and all further calls to the circuit breaker return with an error, without the protected call being made at all." That last clause is the whole point: the protected call is not made, which is what stops a cascading failure from consuming all your resources on doomed requests.
Here is how our breaker is actually tuned, and why each value is what it is:
| Setting | Value | Why |
|---|---|---|
| Error threshold | 50% | Trip when half of recent calls in the window are failing -- a clear signal the dependency is unhealthy, not just unlucky |
| Reset timeout | 30 seconds | After tripping, wait 30s, then probe with one call; our AI service's readiness endpoint responds in under a second, so fast recovery is safe |
| Rolling window | 60 seconds, 6 buckets | Track failure rate over the last minute, sliced into 10-second buckets for a smooth moving signal |
| Volume threshold | 5 requests | Never trip until at least 5 calls have happened in the window -- prevents one early failure on a quiet afternoon from opening the circuit |
| Per-call timeout | disabled (false) | The worker applies its own dynamic timeout (60s + 2s/item, max 300s), so a second timeout layer would just fight it |
That volumeThreshold of 5 is the kind of detail you only add after getting burned: without it, the very first failed call during a low-traffic period would compute a 100% failure rate and trip the breaker for everyone. The per-call timeout is disabled for a related reason -- the worker already wraps each AI request in an AbortController whose timeout scales with batch size (a 50-item batch gets the full 300-second ceiling), so a second breaker-imposed timeout would just fight it.
Why Do We Run Two Separate Circuit Breakers?
We run two separate circuit breakers -- one for automated batch/background AI calls and one for user-triggered calls -- so that a flood of background failures cannot trip the breaker that serves a human waiting on a live request, and vice versa. Isolating the two paths means one workload's bad day doesn't degrade the other's experience.
The reasoning is about blast radius. Automated feedback analysis runs constantly in the background; if the AI service has a rough patch, that batch breaker trips and those jobs retry later -- nobody is staring at a spinner. But user-triggered work (on-demand insights, an AI-chat question) is latency-sensitive with a person on the other end, so sharing one breaker would turn a silent background delay into a visible product failure. This is the same nuance-over-simplicity philosophy we apply to the analysis itself in our take on why most sentiment dashboards lie.
One more architectural note that makes the breakers cleaner: our AI service is a separate, stateless FastAPI service -- pure compute, no Redis, no session state, with all caching and state in the backend. That means the thing the breaker protects is a simple request-in/answer-out box, exactly what you want behind a circuit breaker -- and it stays dumb and replaceable, which the emotion analysis pipeline and AI sentiment analysis both ride on top of.
How Do We Handle Poison Messages and Dead-Letter Queues?
We handle poison messages with per-item failure tracking and the broader failures with dead-letter queues: each feedback item's failure count is tracked in Redis, and any item that fails three times within 24 hours is quarantined so it can never wedge a batch again, while jobs that exhaust their retries fall into a dedicated DLQ for human inspection. Poison handling protects the batch from one bad item; the DLQ protects the system from silent data loss.
A "poison message" is the classic distributed-systems failure: a single malformed or pathological item that crashes processing every time it's attempted, taking down its whole batch and retrying forever until you defend against it.
How Does Poison-Message Detection Actually Work?
Poison detection works by counting, per item, how many times it has failed -- atomically incrementing a Redis counter keyed by feedback ID with a 24-hour expiry -- and quarantining any item whose count reaches three. On the next batch, quarantined items are filtered out before the AI service is ever called, so a poison item stops poisoning.
There are two layers of defense here, and the interplay matters:
- Per-item quarantine. Before processing a batch, the worker checks every item's failure counter in a single Redis round trip and drops any that have hit the threshold of three failures, marking them
failedin the database with a reason so a human can review them later. - Retry-time isolation. When a batch job is on its second retry or later, the worker stops processing the batch as a group and instead processes each item individually. This is the clever part: if 49 items are fine and 1 is poison, batch-level retries would keep failing all 50 together; switching to individual processing on retry isolates the culprit so the 49 good items succeed and only the 1 bad item accrues failures toward quarantine.
Critically, all of this Redis interaction is fail-open: if Redis is unavailable, the poison-detection layer returns "process everything" rather than blocking. Redis being down must never stop feedback from being analyzed -- a missing optimization is acceptable, a halted pipeline is not. That stance is a recurring rule in our infrastructure: protective layers degrade gracefully, they don't become single points of failure.
What Do Dead-Letter Queues Do in Our System?
Dead-letter queues catch jobs that have exhausted their retries so they are preserved for inspection and manual replay instead of being silently dropped. FeedSense runs dedicated DLQs for each critical pipeline -- feedback analysis, CSV imports, emails, billing webhooks, GDPR deletions, and Slack notifications -- each fed automatically when its main queue's retry limit is hit.
When the feedback-analysis queue exhausts its three retries on a job, pg-boss routes that job to feedback-analysis-dlq rather than discarding it. From there we have an admin surface to inspect failed jobs, see their last error, and either retry or purge them. We also expose DLQ sizes as Prometheus gauges, so a growing dead-letter queue becomes an alert -- a leading indicator that something downstream is broken -- rather than a discovery we make weeks later when a customer asks why their feedback was never analyzed.
| Dead-letter queue | Protects | Fed when |
|---|---|---|
feedback-analysis-dlq | AI analysis of feedback | 3 retries exhausted |
import-jobs-dlq | CSV / bulk imports | 3 retries exhausted |
emails-dlq | Transactional email delivery | retries exhausted |
paddle-webhooks-dlq | Billing webhook processing | retries exhausted |
gdpr-deletions-dlq | GDPR deletion compliance | retries exhausted |
slack-notifications-dlq | Slack notification delivery | retries exhausted |
One refinement we added over time: the DLQ tooling classifies errors as transient (timeouts, connection resets, "AI batch service error") versus permanent. A job that dead-lettered purely because the AI service was briefly down is a good candidate for an automatic replay once the service recovers; a job that dead-lettered because of genuinely malformed data is not, and replaying it just burns retries. Distinguishing the two keeps the DLQ from becoming a junk drawer.
How Does Batch Processing Stay Reliable?
Batch processing stays reliable through a layered defense: batches are size- and time-bounded so they never grow unmanageable, the full payload travels with the job so there's no fragile state to rehydrate, poison items are isolated on retry, and the durable Postgres-backed queue guarantees a crashed worker's job is reprocessed rather than lost. Reliability here is the sum of many small guarantees, not one big trick.
Beyond the batch-sizing balance covered above -- and the hard 5,000-item ceiling on any single bulk operation -- two properties make batches resilient. The job payload carries the full feedback items, not just IDs to be re-fetched later, which eliminates the "orphaned job pointing at deleted data" class of bug. And because pg-boss enforces a maximum processing time per job (10 minutes for feedback analysis, 30 minutes for imports), a worker that hangs or crashes mid-batch doesn't hold a job hostage forever; the job expires and becomes eligible for reprocessing. That durability is the unglamorous foundation under prioritizing feature requests and automated feedback categorization -- you can't prioritize signal you dropped on the floor.
What Would We Do Differently If We Started Over?
If I started over, I'd validate the transactional-enqueue assumption on day one, lean on pg-boss's native job history instead of a parallel audit log, and make concurrency a config value from the start. None of these are regrets exactly -- the system works -- but they're the honest "knowing what I know now" list, and vague hindsight helps no one:
- Validate the load-bearing assumption first. We planned a whole migration around transactional enqueue before confirming it worked. A 30-line validation script in week one would have caught it -- test the thing your architecture leans on before you lean on it.
- Don't maintain two sources of job truth. We keep a
JobExecutionLogtable alongside pg-boss's own job records, so for a while we were dual-writing. There's a real audit-trail argument for it, but starting fresh I'd lean on pg-boss's native job history first and add a custom log only if a concrete need appeared -- classic YAGNI. - Make concurrency a first-class dial. Single-threaded is the right default given our circuit-breaker design, but I hard-coded the assumption in too many places. With concurrency as a config value -- and the breaker's volume threshold and AI rate limits expressed in terms of it -- turning the dial up later would be a one-line change rather than a refactor.
The honest limitation today: I know the configured bounds cold -- batch sizes, timeouts, retry schedules, the 10-minute job ceiling -- but I have deliberately not put a "we process N feedbacks per second" number in this post, because I haven't run the controlled load test that would make that claim true. When we do, that'll be its own post with real graphs.
What Can You Take Away for Your Own Feedback Pipeline?
The takeaway for anyone building a feedback or analysis pipeline is that the queue, retry, and failure-isolation design matters more than the AI model you plug in -- so spend your engineering effort on durability, backpressure, and poison handling, and choose boring infrastructure you can actually operate. The model is replaceable; a pipeline you can trust is not. Here's the distilled checklist from our experience:
- Make jobs durable. Use a real queue with persistence, not an in-memory array or a fire-and-forget call. A crashed process should never lose work.
- Retry with capped exponential backoff. Fixed-interval retries hammer struggling dependencies; doubling delays with a ceiling give them room to recover.
- Put a circuit breaker in front of every external service. Failing fast when a dependency is down protects your whole system from resource exhaustion -- opossum or its equivalent is an afternoon of work.
- Defend against poison messages explicitly. Track per-item failures and quarantine the pathological ones, or one bad record will wedge your queue forever.
- Never silently drop data -- use a DLQ. Dead-letter queues plus a size alert turn "we lost data and don't know it" into "we have a queue to inspect," the cheapest insurance in the whole design.
- Pick infrastructure you can operate at your size. We chose pg-boss over BullMQ to avoid a second stateful datastore. Your constraints may differ -- just make the trade-off on purpose.
The meta-lesson: processing feedback at scale is a reliability-engineering problem wearing an AI costume. The right to say "every piece of feedback gets analyzed" lives in the unglamorous machinery of queues, retries, breakers, and dead-letter handling.
If you'd rather have that machinery already built and stop maintaining it yourself, that's the whole point of FeedSense -- start free with 100 feedback items a month, no credit card, and let the pipeline be our problem instead of yours.
Frequently Asked Questions
What is the best architecture for processing customer feedback at scale?
The best architecture is an asynchronous pipeline: an API ingests and persists feedback synchronously, a durable job queue decouples ingestion from analysis, and workers process batches with retries and circuit breakers. FeedSense uses Fastify, a pg-boss (PostgreSQL) queue, and a worker calling a stateless AI service. The key is decoupling collection from the slower, failure-prone analysis step.
Why use pg-boss instead of BullMQ for a job queue?
Use pg-boss when you already run PostgreSQL and want jobs stored durably in your existing database without adding Redis as a dependency. BullMQ is Redis-native and scales more gracefully at the high end, because Postgres-backed queues start hitting lock contention around thousands of jobs per second. FeedSense chose pg-boss for operational simplicity -- one datastore to back up, monitor, and reason about -- since our feedback job rate is bursty rather than sustained-high.
How do circuit breakers help with AI service calls?
Circuit breakers monitor the recent failure rate of calls to an external service and "trip" once failures cross a threshold, rejecting further calls instantly instead of piling load onto a struggling service. FeedSense uses opossum with a 50% error threshold and a 30-second reset probe. This prevents cascading failures and lets a recovering AI service stabilize before traffic resumes, a pattern popularized by Michael Nygard's Release It.
What is a poison message in a job queue?
A poison message is a malformed or pathological item that fails processing every time it's attempted, potentially crashing its batch and retrying forever. FeedSense detects them by tracking per-item failure counts in Redis and quarantining any item that fails three times within 24 hours, so a single bad feedback record can never wedge the whole queue indefinitely.
How does exponential backoff prevent retry storms?
Exponential backoff increases the wait between retries -- for example 60s, then 120s, then 240s -- instead of retrying at a fixed interval. This gives a temporarily failing service room to recover rather than being hammered in lockstep. FeedSense caps backoff at 300 seconds so it never grows unbounded, and adds random jitter only to database-contention retries, where synchronized retries would otherwise collide.
What is a dead-letter queue and why do you need one?
A dead-letter queue (DLQ) holds jobs that have exhausted their retries, preserving them for inspection or manual replay instead of silently dropping them. FeedSense runs dedicated DLQs for feedback analysis, imports, emails, billing webhooks, GDPR deletions, and Slack notifications, and exposes their sizes as metrics so a growing DLQ triggers an alert -- turning silent data loss into a visible, fixable signal.
How does batch processing improve feedback analysis performance?
Batching groups multiple feedback items into a single AI request, amortizing per-request overhead instead of paying it per comment. FeedSense flushes live feedback at 8 items or 45 seconds and bulk imports at 50 items or 60 seconds. Batching reduces cost and overhead, while size and time limits keep any single failure cheap to retry and prevent oversized job payloads.
Should a feedback processing worker run single-threaded or concurrent?
It depends on the downstream dependency. FeedSense runs the worker single-threaded because the external AI service is protected by a circuit breaker and dynamic timeouts tuned for serial access -- high concurrency would stampede a recovering service. If your downstream can absorb parallel load, concurrency raises throughput. Either way, match the worker's concurrency to what your slowest dependency can safely handle.
How do you make a job queue resilient to worker crashes?
Use a durable queue that persists jobs (FeedSense uses pg-boss on PostgreSQL) and set a maximum processing time per job. If a worker crashes mid-job, the job isn't lost -- it expires after its time limit and becomes eligible for reprocessing by another worker. Combined with automatic retries and dead-letter queues, this guarantees in-flight work survives a crash.
Does FeedSense store its AI service state in Redis?
No. FeedSense's AI service is a stateless FastAPI service -- pure compute with no Redis and no session state. All caching and state, including a content-hashed embedding cache, live in the backend. This keeps the AI service simple and replaceable, makes it a clean target to put behind a circuit breaker, and means scaling or restarting it carries no risk of losing cached or in-flight state.
Tags:
Stay in the loop
Get the latest insights on customer feedback, product management, and building better SaaS products. No spam, unsubscribe anytime.
By subscribing, you agree to our privacy policy. We respect your inbox.
