<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[React Fibre]]></title><description><![CDATA[Practical engineering notes on React, frontend architecture, Node.js, system design and AI-enabled products—grounded in real implementations, trade-offs and les]]></description><link>https://rjt.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 14 Oct 2026 08:35:58 GMT</lastBuildDate><atom:link href="https://rjt.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[React State Architecture: What Belongs in Local State, Context, URL State and TanStack Query?]]></title><description><![CDATA[1. The State Management Question Is Usually Asked Backwards
A fetched ticket, the currently selected ticket, an authenticated user, an editable reply, and a search value are all “state.” They still ne]]></description><link>https://rjt.hashnode.dev/react-state-architecture-what-belongs-in-local-state-context-url-state-and-tanstack-query</link><guid isPermaLink="true">https://rjt.hashnode.dev/react-state-architecture-what-belongs-in-local-state-context-url-state-and-tanstack-query</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[tanstack-query]]></category><category><![CDATA[frontend]]></category><category><![CDATA[systemdesign]]></category><dc:creator><![CDATA[Ram Ji Tripathi]]></dc:creator><pubDate>Tue, 06 Oct 2026 15:33:11 GMT</pubDate><content:encoded><![CDATA[<h2>1. The State Management Question Is Usually Asked Backwards</h2>
<p>A fetched ticket, the currently selected ticket, an authenticated user, an editable reply, and a search value are all “state.” They still need different owners. The server owns the saved ticket. Navigation identifies the selected ticket. The user controls the unfinished reply. A search value may be temporary input or a committed view that a link should preserve.</p>
<p>A support agent moving from reading a conversation to generating, editing, and sending a reply crosses several of those boundaries on one screen. Choosing a global-state library first skips the harder questions: who can change each value, how long should it survive, and what makes it authoritative?</p>
<p>My AI Support Assistant provides a concrete example. Its React and TypeScript frontend uses Context for the current session, TanStack Query for ticket and message data, React Router for navigation, and component state for editable forms. Shareable search and filter controls are a proposed addition. The useful architectural lesson is how these responsibilities fit together, including the boundaries I still need to strengthen.</p>
<p>In <a href="https://blog.ramjwork.in/frontend/react-dashboard-system-design">Frontend System Design: How I Would Architect a Production Dashboard in React</a>, I considered the broader dashboard architecture. Here I want to isolate one question: how should state ownership be divided inside it? I start with ownership, lifetime, and source of truth, then choose the mechanism that fits each responsibility.</p>
<h2>2. Start With Ownership, Not Libraries</h2>
<p>I start by asking where a value comes from. Is it an authoritative server record, a user's unfinished input, a navigation choice, or a computation over other values? Then I ask what should happen when the component unmounts, the route changes, the browser reloads, or the account changes.</p>
<p>Scope and lifetime are different. A value can be needed by several sibling components while still belonging only to one screen. Conversely, a small user object can have an application-wide session lifetime. “Several consumers” is a reason to share access; it does not automatically justify permanent global ownership.</p>
<table>
<thead>
<tr>
<th>Question about the value</th>
<th>Likely owner</th>
<th>Architectural reason</th>
</tr>
</thead>
<tbody><tr>
<td>Is it unfinished input used by this screen?</td>
<td>Local component or nearest shared parent</td>
<td>Short lifetime and direct editing</td>
</tr>
<tr>
<td>Does it identify the current session?</td>
<td>Session layer / Context</td>
<td>Shared identity and bootstrap lifecycle</td>
</tr>
<tr>
<td>Does the server own the saved record?</td>
<td>TanStack Query cache</td>
<td>Fetching, freshness, and mutation reconciliation</td>
</tr>
<tr>
<td>Should a link or browser history preserve it?</td>
<td>Route or URL search parameters</td>
<td>Navigation is part of its meaning</td>
</tr>
<tr>
<td>Can existing values determine it?</td>
<td>Computation</td>
<td>Avoid a second independently writable value</td>
</tr>
<tr>
<td>Is it a coordinated, long-lived client workflow?</td>
<td>Dedicated store, if justified</td>
<td>Explicit transitions across consumers</td>
</tr>
</tbody></table>
<p>This is a decision aid, not a rule that every value has exactly one physical representation. A server response can seed an editable form. What matters is whether the form is an intentional working copy or an accidental competing source of truth.</p>
<p>Persistence also deserves its own decision. Putting state in Context does not make it survive reloads. Putting a draft in localStorage would add retention requirements. Putting it in the URL would make it visible in links and history. I would choose those behaviors explicitly instead of inheriting them from whichever storage mechanism is convenient.</p>
<p><strong>Current implementation: which owner supplies which state?</strong></p>
<pre><code class="language-text">AuthProvider: user + bootstrap status ──&gt; protected UI + computed role checks
URL: screen + ticket ID ────────────────&gt; navigation + query identifier
Server records ──&gt; TanStack Query ──────&gt; ticket/message views + computed metrics
AI suggestion mutation ──&gt; page handoff ──&gt; editable local composer
Local forms ───────────────────────────&gt; unsaved values + validation errors
</code></pre>
<h2>3. Local State: Keep Ephemeral UI Close to Where It Changes</h2>
<p><code>MessageComposer</code> owns its <code>content</code>, validation error, and submission error with <code>useState</code>. Its pending indicator comes from the message mutation. That distinction is useful: the text is editable UI state, while the request lifecycle already has an owner.</p>
<p>Excerpt from <code>MessageComposer.tsx</code>:</p>
<pre><code class="language-tsx">const [content, setContent] = useState(initialContent);
const [fieldError, setFieldError] = useState&lt;string | undefined&gt;();
const [submitError, setSubmitError] = useState&lt;string | null&gt;(null);

const isSubmitting = createMessageMutation.isPending;
</code></pre>
<p>The component validates the text, submits trimmed content, and clears the input after the mutation succeeds. A failed submission sets an error without executing that success reset. The textarea is disabled while the request is pending. There is no need to put each keystroke into the message query cache: it is not yet a saved message.</p>
<p>The ticket creation form follows the same boundary. It owns title, description, field errors, and a submission error locally. After successful creation, it navigates to the returned ticket's detail route. The unsaved form and the saved ticket are related, but they have different lifetimes and authorities.</p>
<p>Local does not mean “must live in the smallest possible component.” The AI reply handoff uses state in <code>TicketDetailPage</code> because the suggestion panel and composer are siblings. That parent is the nearest place that coordinates their interaction. Sharing within a screen does not require sharing across the whole application.</p>
<p>For a future dialog or temporary UI toggle, I would begin with the same ownership test: does another screen need to retain or control it? A form that must survive several routes may deserve a longer-lived owner. Component-local storage is a fit for these current forms, not a universal rule for every form.</p>
<h2>4. Context: Give the Session a Shared Owner</h2>
<p><code>AuthProvider</code> owns a nullable user and authentication bootstrap loading. The user shape contains <code>id</code>, <code>name</code>, <code>email</code>, and a role of <code>CUSTOMER</code>, <code>AGENT</code>, or <code>ADMIN</code>. The Context exposes that user, <code>login</code>, <code>logout</code>, and <code>isLoading</code>. It does not contain ticket lists or message arrays. Context could expose server data in another design, but providing access does not by itself define fetching, freshness, or reconciliation. Here those responsibilities belong to the query layer.</p>
<p>The token has a separate persistence boundary: the storage service reads and writes <code>access_token</code> in localStorage. At startup, a stored token triggers <code>fetchCurrentUser</code>, which calls <code>/auth/me</code>. A successful response establishes the user in Context; a failure removes the token and clears the user.</p>
<p>Simplified excerpt from <code>AuthProvider.tsx</code>; the surrounding token check and loading handling are omitted:</p>
<pre><code class="language-tsx">try {
  const currentUser = await fetchCurrentUser();
  setUser(currentUser);
} catch {
  storage.removeToken();
  setUser(null);
}
</code></pre>
<p>Login has two steps across this boundary. The auth service saves the response token, and the login page passes the returned user to Context before navigating to the dashboard. Context's <code>login</code> function itself sets the user and ends loading. A stored credential and the resolved user are complementary session representations, not two interchangeable user databases.</p>
<p>There is also a temporary login mutation result containing the response user. I would not describe authentication as having literally one object representation. The important current boundary is that protected routes and dashboard consumers read the session through Context rather than maintaining a second durable user store.</p>
<p><code>ProtectedRoute</code> waits for bootstrap, redirects when there is no user, and otherwise renders its nested outlet. Axios reads the token for Bearer authentication. On a 401 it removes the token and redirects to login, except that <code>/auth/me</code> failures are left to bootstrap handling.</p>
<p>Role-derived booleans control which UI actions appear. For example, the detail page computes management access from the user's role. That is presentation logic, not the final authorization boundary: the backend status route independently requires an agent or admin role. Context makes identity available; it does not make the browser authoritative over permissions.</p>
<h2>5. TanStack Query: Server State Is Not Just Global Client State</h2>
<p>A shared ticket array is only part of the problem. The frontend also needs to know whether it is loading, whether a request failed, which request parameters produced it, and what a successful mutation makes outdated. Sharing access across components does not transfer authority from the server to the browser. These concerns explain why ticket and message records belong in a query cache here.</p>
<p>The application's <code>QueryClient</code> has these defaults:</p>
<pre><code class="language-ts">export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      staleTime: 1000 * 60,
      refetchOnWindowFocus: false,
    },
  },
});
</code></pre>
<p>This config expresses a freshness policy; it does not promise continuous synchronization. The application uses a 60-second freshness window and disables window-focus refetching. That interval is not a polling schedule or proof that another agent's changes will appear immediately. The ticket and message views expose loading/error states and retry or refresh behavior instead of treating cache presence as sufficient evidence of freshness.</p>
<p>Ticket keys distinguish list queries, including their parameter object, from detail queries keyed by ticket ID. Message keys distinguish conversations by ticket ID. <code>useTicket</code> and <code>useMessages</code> enable their queries only when the identifier is truthy.</p>
<p>Excerpt from <code>useTickets.ts</code>:</p>
<pre><code class="language-ts">export function useTickets(params?: TicketListParams) {
  return useQuery({
    queryKey: ticketKeys.list(params),
    queryFn: () =&gt; getTickets(params),
  });
}
</code></pre>
<p>The same parameters define the cache identity and the API request. That alignment matters: two differently filtered requests should not silently share one list entry. It also creates a natural place for future normalized URL parameters to feed the fetching layer.</p>
<p>Mutation reconciliation is explicit. Ticket creation invalidates list queries. A status update patches status and <code>updatedAt</code> in an existing detail entry, then invalidates the lists and that detail. Message creation appends the returned message to the matching conversation cache on success.</p>
<p>The inspected list and thread render query data directly; they do not copy those arrays into component state or Context. This keeps the query result as the frontend's shared representation of the server response. It does not eliminate every consistency question: different cache entries can represent overlapping records, so mutation handling still needs to account for the views affected by a change.</p>
<h2>6. URL State: If Navigation Should Preserve It, Consider Putting It in the URL</h2>
<p>Selected ticket identity is already navigation state. The active router declares <code>/dashboard/tickets/:id</code>, and the detail page derives its query identifier from that parameter:</p>
<pre><code class="language-tsx">const { id } = useParams();
const ticketId = id ?? "";
</code></pre>
<p>A ticket link therefore carries the selected identity without requiring a separate selected-ticket store. Browser navigation can change that identity through the pathname, and a direct link identifies which record to request, subject to authentication and access. This does not imply that browser history restores an unsent composer draft.</p>
<p><strong>Current implementation.</strong> The list/dashboard behavior is narrower than a full URL-backed search interface. <code>TicketsPage</code> calls <code>useTickets()</code> without parameters and has no filter, search, sort, or pagination controls. The dashboard requests fixed parameters: page 1 and limit 100. The ticket parameter type supports status, priority, search, page, and limit, but that API capability is not an implemented navigation UI.</p>
<p>There is no <code>useSearchParams</code> or <code>URLSearchParams</code> usage in the inspected frontend source. The missing piece is a navigation design for future shareable list controls, rather than migrating existing component-only controls.</p>
<p><strong>How I would evolve it.</strong> If list filters, search, and pagination become navigation-relevant, I would put their committed values in URL search parameters. A link such as <code>/dashboard/tickets?status=OPEN&amp;page=2</code> would describe which view to request after a refresh, when opening a shared link, or when moving back and forward through history. It would preserve the selection criteria, not freeze the server results. Parsing should validate enum values and normalize page numbers before producing query parameters. Changing a filter should deliberately decide whether to reset the page.</p>
<p>Search adds a useful distinction. Text currently being typed can remain local while a committed search becomes URL state. History updates should reflect meaningful navigation rather than creating an entry for every keystroke. Composer content, credentials, and transient errors have no comparable reason to appear in the URL.</p>
<h2>7. A Real Workflow Shows Why State Changes Ownership</h2>
<p>The message workflow makes the boundary concrete. Before submission, the text belongs to the composer. During submission, the mutation owns pending/error request state. After server success, the returned message becomes part of the conversation cache and the thread renders it.</p>
<p>Excerpt from <code>useCreateMessage</code>:</p>
<pre><code class="language-ts">onSuccess: (newMessage) =&gt; {
  queryClient.setQueryData&lt;Message[]&gt;(
    messageKeys.ticket(ticketId),
    (existing) =&gt; (existing ? [...existing, newMessage] : [newMessage]),
  );
},
</code></pre>
<p>This is a cache update after success, not a speculative optimistic message insertion. The composer waits for <code>mutateAsync</code> before clearing text and invoking its callback. The server-returned record, rather than the raw input string, is what enters the message cache.</p>
<p>The callback increments a page-local <code>messageRefreshToken</code>. Despite its name, <code>MessageThread</code> uses it in a scroll effect alongside message count; it does not refetch messages because the token changed. That small detail matters when explaining ownership: the cache supplies conversation data, while this counter coordinates a UI effect.</p>
<p><strong>Current workflow: when does a draft become a conversation record?</strong></p>
<pre><code class="language-text">Ticket identity + query-backed view
  -&gt; separate suggestion request [mutation pending/error/result]
  -&gt; returned suggestion [candidate text; not sent]
  -&gt; click Use Reply [page-local handoff]
  -&gt; editable composer [local draft; human may edit]
  -&gt; explicit Send [message mutation]
       success -&gt; returned server message -&gt; query cache -&gt; rendered thread
                  clear composer + signal scroll
       failure -&gt; retain input + show submission error
</code></pre>
<p>The same send boundary applies to a reply typed without an AI suggestion.</p>
<p>The AI workflow starts with a separate mutation. <code>ReplySuggestion</code> requests generated text, displays mutation loading/error/result state, and derives a trimmed suggestion and a visibility boolean. It resets that mutation when the ticket ID changes. Generating a suggestion does not call the message creation hook.</p>
<p>Only clicking “Use Reply” invokes the parent callback. The page saves the suggestion as <code>composerDraft</code> and increments <code>composerKey</code>, remounting the composer so its local content initializes from that draft. The user can then edit the textarea and explicitly send through the ordinary message workflow.</p>
<p>This is an intentional working-copy boundary: generated text is a candidate, editable content is the human's draft, and a successful send returns a conversation record. Similar strings across these stages do not have identical authority. I would preserve that separation even if the implementation later changes how siblings exchange the draft.</p>
<p>The current handoff also has a tradeoff. The page retains a seed value while the composer owns the live edited text. Using another suggestion remounts the composer and replaces its local input. I would consider an explicit replacement confirmation or a clearer draft ownership API if preserving unsent edits becomes a requirement. Neither protection is implemented by the shown handoff.</p>
<h2>8. What About Redux?</h2>
<p>Redux and Redux Toolkit are not used in this frontend: there are no matching source imports/usages or dependencies in its package manifest. The current flows use Context, queries, route identity, component state, and mutation state without a Redux store.</p>
<p>These ownership requirements do not automatically require Redux. A dedicated client store becomes worth considering when there is a coordination problem that the existing owners cannot express cleanly; this implementation alone cannot settle that decision for another product or future workflow.</p>
<p>I would consider a dedicated client store for a cross-feature workflow that stays active across routes, long-lived unsaved work spanning several screens, or coordinated UI transitions with explicit state-machine-like rules. For example, a future multi-ticket response workspace might need draft identity and transitions beyond a single composer. That is a hypothetical requirement, not an existing feature.</p>
<p>Even then, I would define what the store owns before adding it. It might own selected workspace items and unsaved drafts while querying authoritative ticket records separately. Copying every fetched ticket into the new store would introduce synchronization work without proving that the workflow needs it.</p>
<h2>9. The Failure Modes I Would Watch For</h2>
<p><strong>Current limitations.</strong> Two session/cache boundaries deserve priority. First, ticket and message keys contain no account or session identifier. Second, logout removes the token and user but does not explicitly clear the query cache. The module-level QueryClient wraps AuthProvider, so the normal logout button does not recreate it.</p>
<p>Together, those choices leave an account-transition isolation gap. A later login in the same application lifetime can address keys used by the previous session. That is a source-supported isolation risk, not a reproduced data leak. Freshness configuration cannot substitute for session isolation.</p>
<p><strong>How I would evolve it.</strong> I would design logout cleanup and account-scoped query identity together, including cancellation of requests that could complete after the session changes. Clearing existing data is only part of the lifecycle. Which requests may still write, and which identity the next request uses, also need explicit answers. This is proposed work, not current behavior.</p>
<p><strong>Other current boundaries.</strong> The AI handoff replaces local input when another suggestion is used; draft-preservation behavior would need an explicit design. A separate constraint is the dashboard's derived metrics. They count the returned page-1 list capped at 100 requested tickets. The computation marks possible truncation when the returned count reaches that limit. These are summaries of fetched records, not verified complete account totals. Derived state can be internally correct while its input is incomplete.</p>
<p><strong>General failure modes.</strong> Copying query arrays into Context or local state and storing computed permissions independently create values that must stay aligned with their sources. Globalizing transient forms extends their lifetime and coordination surface. Keeping a navigation-relevant selection only in a component prevents the URL from describing it. These are design risks to watch for, not findings that all exist here. An intentional editable snapshot can justify duplication; an unedited mirror usually needs a stronger reason.</p>
<h2>10. The State Ownership Model I Would Use as the Dashboard Grows</h2>
<p>I would retain the existing division and strengthen the boundaries where behavior is incomplete. The target model below distinguishes retained choices from proposed additions.</p>
<table>
<thead>
<tr>
<th>State</th>
<th>Target owner</th>
<th>Status and reason</th>
</tr>
</thead>
<tbody><tr>
<td>Current user and bootstrap</td>
<td>Session Context</td>
<td>Current; shared session lifecycle</td>
</tr>
<tr>
<td>Credential persistence</td>
<td>Session storage service</td>
<td>Current localStorage mechanism; separate from user object</td>
</tr>
<tr>
<td>Tickets and messages</td>
<td>TanStack Query</td>
<td>Current query owner; proposed account scope and session cleanup</td>
</tr>
<tr>
<td>Selected ticket</td>
<td>URL route parameter</td>
<td>Current; identity belongs to navigation</td>
</tr>
<tr>
<td>Shareable filters and page</td>
<td>URL search parameters</td>
<td>Proposed; reproducible list views</td>
</tr>
<tr>
<td>Composer text and form errors</td>
<td>Local component state</td>
<td>Current; unfinished screen input</td>
</tr>
<tr>
<td>AI result and editable draft</td>
<td>Mutation result, then local workflow state</td>
<td>Current boundary; improve replacement semantics if needed</td>
</tr>
<tr>
<td>Permissions and dashboard summaries</td>
<td>Computed from session/query inputs</td>
<td>Current; respect backend authority and input completeness</td>
</tr>
<tr>
<td>Future cross-feature client workflow</td>
<td>Dedicated store only when justified</td>
<td>Proposed conditional choice</td>
</tr>
</tbody></table>
<p>Derived values already have useful examples. The detail page computes management permissions from the user role; the dashboard computes metrics and a five-item recent slice from query data with <code>useMemo</code>. The suggestion panel computes whether usable text is present from its mutation result and status. None needs an independently writable global field merely to make consumption convenient.</p>
<p>As the dashboard grows, I would review a new feature by walking through its transitions. What exists before the request? What changes on failure? What becomes authoritative on success? What should survive navigation, reload, or account change? Those questions expose ownership gaps earlier than asking where to put a new boolean.</p>
<h2>11. Closing</h2>
<p>My working model is to keep unfinished edits close to the UI, share session identity through a session layer, cache server records with explicit reconciliation, and let navigation own the choices a link should preserve. Compute values whose sources already exist. Add a client store when a concrete workflow needs a longer or broader lifetime.</p>
<p>For each new value, I would write down its source of truth, its consumers, and the event that ends its lifetime. Then I would decide whether navigation should preserve it and whether it can be computed from existing inputs. If those answers are unclear, choosing a more powerful store will only move the ambiguity. Resolve the ownership first; the tool choice follows.</p>
<p>Originally published on my engineering blog: <a href="https://blog.ramjwork.in/frontend/react-state-architecture">https://blog.ramjwork.in/frontend/react-state-architecture</a></p>
]]></content:encoded></item><item><title><![CDATA[Frontend System Design: How I Would Architect a Production Dashboard in React]]></title><description><![CDATA[React dashboards rarely become difficult because of JSX. They become difficult when ownership gets unclear: who owns server data, session state, URL state, transient UI, permissions, mutations, and fa]]></description><link>https://rjt.hashnode.dev/frontend-system-design-how-i-would-architect-a-production-dashboard-in-react</link><guid isPermaLink="true">https://rjt.hashnode.dev/frontend-system-design-how-i-would-architect-a-production-dashboard-in-react</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[frontend]]></category><category><![CDATA[systemdesign]]></category><category><![CDATA[tanstack-query]]></category><dc:creator><![CDATA[Ram Ji Tripathi]]></dc:creator><pubDate>Mon, 05 Oct 2026 14:30:26 GMT</pubDate><content:encoded><![CDATA[<p>React dashboards rarely become difficult because of JSX. They become difficult when ownership gets unclear: who owns server data, session state, URL state, transient UI, permissions, mutations, and failures?</p>
<p>I use the React frontend of <a href="https://blog.ramjwork.in/ai-engineering/ai-customer-support-architecture">my AI Support Assistant</a> as a reference. It has authentication, a dashboard, tickets, conversations, and AI reply suggestions. The code shows a useful set of boundaries and also where those boundaries need more definition. This article distinguishes what the application implements today from what I would change as it grows. The title describes a design goal, not a claim about production traffic or measured scale.</p>
<h2>Start with boundaries, not components</h2>
<p>Before choosing component sizes, I decide where navigation, identity, data access, and interaction state belong. In the current app, <code>main.tsx</code> mounts shared providers and the router. The router selects pages, <code>AppLayout</code> provides the authenticated shell, feature hooks connect UI to domain queries and mutations, and feature API modules use a shared Axios client.</p>
<p>For ticket reads, the current flow is:</p>
<pre><code class="language-text">Router -&gt; Page -&gt; Feature hook -&gt; TanStack Query
       -&gt; Feature API -&gt; Shared Axios client -&gt; Backend
</code></pre>
<p>This keeps request construction and authentication headers out of the page. It does not remove every dependency: dashboard metrics build on ticket hooks, and message UI reuses ticket error helpers. Those are manageable now, but I would watch cross-feature imports as the application expands.</p>
<p>The repository already groups auth, tickets, messages, and AI by feature. Dashboard hooks and components are also grouped, though the dashboard page remains in <code>src/pages</code>. The active router is <code>src/routes/AppRouter.tsx</code>; an <code>src/app/router.tsx</code> file is empty. I would consolidate those entry points before adding more folders.</p>
<h2>State ownership is the central decision</h2>
<p>The current frontend keeps tickets and messages in TanStack Query, identity in auth context, ticket identity in the route, and editable form fields in component state. Each has a different authority and lifetime. Treating them as one undifferentiated global store would make synchronization harder to reason about.</p>
<table>
<thead>
<tr>
<th>State</th>
<th>Owner and persistence</th>
<th>Synchronization</th>
<th>Common mistake</th>
</tr>
</thead>
<tbody><tr>
<td>Local UI</td>
<td>Component or nearest feature coordinator; usually mounted lifetime</td>
<td>Props and callbacks between collaborators</td>
<td>Put drafts and field errors in a global store by default</td>
</tr>
<tr>
<td>Server</td>
<td>TanStack Query; current query-client lifetime</td>
<td>Query keys, confirmed cache writes, invalidation/refetch</td>
<td>Copy fetched records into local state and maintain two versions</td>
</tr>
<tr>
<td>Session/auth</td>
<td>AuthProvider owns user/loading; token currently lives in localStorage</td>
<td>Bootstrap, login, logout, and future identity transitions</td>
<td>Turn AuthContext into a catch-all domain store</td>
</tr>
<tr>
<td>URL</td>
<td>Router owns route identity; list search params are a future choice</td>
<td>Parse navigation into canonical query inputs</td>
<td>Keep shareable filters only in component state</td>
</tr>
<tr>
<td>Shared client</td>
<td>Add a narrow provider/store only for a real cross-feature need</td>
<td>Explicit subscribers and actions</td>
<td>Introduce Redux because the app has multiple pages</td>
</tr>
</tbody></table>
<pre><code class="language-text">Server data  -&gt; TanStack Query
Session      -&gt; AuthProvider
URL          -&gt; Router / search parameters
Transient UI -&gt; Component / feature coordinator
Shared state -&gt; Only for a demonstrated cross-feature need
</code></pre>
<p>I promote a value only when another feature truly needs it, it must survive navigation, or there is a clear owner beyond its current component. If two neighboring components need a draft, their coordinator may be enough. Persistence also has several meanings: a value may survive a render, a route change, a reload, or a new login. Those are separate requirements, not reasons to put everything into the same store. Dashboard counts are derived from fetched tickets rather than stored as a second editable copy; the trade-off is that the chosen dataset limits the accuracy of those counts.</p>
<p>The same test helps with filters. A dropdown selection being shared by two components does not automatically make it application state. If the user expects to copy the URL, return with Back, or reload and see the same result, the committed filter belongs in navigation state. If it is simply a temporary draft before pressing Apply, local state is a better fit. I would avoid synchronizing two writable copies of the same filter because one will eventually lag the other.</p>
<h2>Routing, shell, and access boundaries</h2>
<p><strong>Current implementation:</strong> <code>/</code>, <code>/login</code>, and <code>/register</code> are public. Dashboard, ticket list, create, and detail routes are nested under <code>ProtectedRoute</code> and <code>AppLayout</code>. The guard waits for auth bootstrap, redirects when there is no user, and otherwise renders its outlet. It checks identity, not role. The create page separately redirects non-customers; ticket detail controls check roles before showing status management or AI replies.</p>
<p>The shell owns navigation, the user name, logout, and the nested page outlet. Pages own workflows and data. I would keep domain queries out of the shell so every authenticated route does not inherit unrelated fetching.</p>
<p><strong>How I would evolve it:</strong> make route requirements explicit as route count grows, preserve a requested destination through sign-in, and provide a deliberate unknown-route page. The active router has no wildcard; the test helper does. This is a small example of how test composition can drift from the application.</p>
<p>Authentication establishes identity; it does not authorize each resource action. The current <code>AuthProvider</code> owns user and loading state. If a stored token exists, it fetches <code>/auth/me</code>; any bootstrap error clears the token and user. The essential current behavior is:</p>
<pre><code class="language-tsx">      try {
        const currentUser = await fetchCurrentUser();
        setUser(currentUser);
      } catch {
        storage.removeToken();
        setUser(null);
      } finally {
        setIsLoading(false);
      }
</code></pre>
<p>Login and registration persist the returned token, set the user in context, and navigate to the dashboard. Logout clears the token and user. The broad bootstrap catch treats a temporary network failure like an invalid session. I would distinguish those outcomes and define what happens to in-flight requests during logout or account switching.</p>
<p>The UI hides or shows actions by role, but that is presentation. The inspected backend routes restrict ticket creation to customers and status changes to agents/admins. Ticket access is scoped by customer ownership or agent assignment, and the service rechecks access for cached tickets. The AI suggestion route also has an agent/admin gate. These checks show the authority boundary; they are not a full security audit. For a larger UI, I would centralize capability predicates to keep repeated presentation rules consistent, while leaving authorization decisions on the server.</p>
<h2>Server state, cache consistency, and the API boundary</h2>
<p>The current TanStack Query defaults are one retry, a one-minute stale time, and no refetch on window focus:</p>
<pre><code class="language-ts">export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      staleTime: 1000 * 60,
      refetchOnWindowFocus: false,
    },
  },
});
</code></pre>
<p>Ticket keys distinguish lists and details; message keys distinguish conversations by ticket id. Detail and message queries require a nonempty id. Feature API modules unwrap response data and return domain values. Stale time is a freshness policy; it does not make remote changes arrive immediately.</p>
<p>There is a meaningful session boundary to improve: the ticket and message keys do not include account identity, and logout does not clear the shared query cache. A later login in the same app lifetime can encounter cached data under the same keys. This is a code-level isolation risk, not a reported incident. I would scope protected queries to identity or tenant and clear or cancel them on session transitions, then test account switching with populated data.</p>
<p>Mutations make cache consistency explicit. Ticket creation invalidates ticket lists. Status updates patch the confirmed response into an existing detail cache, then invalidate list and detail queries. Message creation appends the server-confirmed response:</p>
<pre><code class="language-ts">    onSuccess: (newMessage) =&gt; {
      queryClient.setQueryData&lt;Message[]&gt;(
        messageKeys.ticket(ticketId),
        (existing) =&gt; (existing ? [...existing, newMessage] : [newMessage]),
      );
    },
</code></pre>
<p>This is not an optimistic update: the write occurs after success. If the conversation was not cached, the code seeds it with only the new message. The hook does not invalidate ticket detail or lists. Whether those views need refresh depends on what sending changes. As more views depend on a mutation, I would document affected queries and define reconciliation for incomplete caches or concurrent writers. I would add optimistic behavior only when its temporary identity, rollback, deduplication, and reconciliation costs are justified.</p>
<p>All inspected feature APIs use one Axios instance. It sets the API base URL, JSON headers, a ten-second timeout, and a request interceptor that adds the stored Bearer token. The AI suggestion call overrides the timeout to sixty seconds. The response interceptor removes the token on 401 and redirects to <code>/login</code>, except for <code>/auth/me</code> bootstrap failures:</p>
<pre><code class="language-ts">    if (error.response?.status === 401) {
      storage.removeToken();
      const requestUrl = error.config?.url ?? "";
      const isSessionBootstrapRequest = requestUrl.includes("/auth/me");
      if (!isSessionBootstrapRequest) {
        window.location.href = "/login";
      }
    }
</code></pre>
<p>That broad redirect also applies to a failed login request, while the login page has its own invalid-credentials message. I would assign session expiry and transport classification to an explicit session boundary, with feature-specific recovery messages. That boundary should not flatten every response into “try again”: invalid credentials, forbidden access, missing resources, offline failures, and server errors ask for different next steps. The API client can normalize transport details, while feature code still decides what recovery makes sense in context.</p>
<p>Typed response annotations do not validate payloads at runtime; runtime checks are a future option if contract drift warrants them. The localStorage token choice also needs to be evaluated against the deployment threat model rather than treated as proof of comprehensive security.</p>
<h2>The ticket, message, and AI reply workflow</h2>
<p>Ticket detail brings together three feature concerns: the ticket query, a message thread, and an AI suggestion. Server-owned ticket and message data stay in Query; the composer draft and coordination state stay local.</p>
<p><strong>Current implementation:</strong> generating a suggestion is an explicit mutation with pending, error, empty, and success states. Regenerate calls the mutation again; retry resets its error first. “Use Reply” places suggestion text into the composer. It does not send it. The user can review, edit, and submit separately. The handoff is:</p>
<pre><code class="language-tsx">                  onUseReply={(content) =&gt; {
                    setComposerDraft(content);
                    setComposerKey((current) =&gt; current + 1);
                  }}
</code></pre>
<p>Incrementing the key remounts the composer with the supplied initial text. That also resets its local state, so it replaces any existing edits. I would make draft replacement an explicit interaction contract, including a confirmation when preserving unsent text matters.</p>
<p>On message submit, the composer validates and trims the draft, disables input while the request is pending, and clears the field only after success. On failure it keeps the text and shows an error. The returned message is appended to the query cache. The detail page’s <code>messageRefreshToken</code> name might imply a refetch, but the thread uses it only as a scroll-effect dependency. The current workflow is request/response plus local cache updates; no subscription or polling mechanism was found for remote conversation changes.</p>
<p>This example shows why state ownership and feature boundaries meet at the page: the page coordinates a draft handoff, the feature hooks own data operations, Query owns server data, and the shared client carries authentication. That is enough coordination for the current flow, but draft lifetime and cross-view freshness need explicit rules as collaboration grows.</p>
<h2>Where the design would become fragile</h2>
<p>Several limitations are useful signals for future design, not evidence of production failures:</p>
<ul>
<li><p><strong>Account-independent keys and retained cache:</strong> protected records can remain cached across logout. Scope or clear that cache when identity changes.</p>
</li>
<li><p><strong>Sampled metrics:</strong> the dashboard requests page one with a limit of 100, derives counts from that array, and warns at 100 that results may be truncated. Those values are not a complete aggregate. I would use a server aggregate contract and a separate bounded recent-ticket query.</p>
</li>
<li><p><strong>No list controls:</strong> ticket types and API accept filters and pagination, but the screen calls <code>useTickets()</code> without parameters and exposes no pagination controls. I would make shareable filters and pages URL state when users need reload, sharing, or browser history.</p>
</li>
<li><p><strong>Loading and refresh semantics:</strong> list/detail/thread have loading, error, empty, and retry states. An error branch can replace content even if stale data exists. I would distinguish initial failure from background refresh failure and preserve usable content when appropriate.</p>
</li>
<li><p><strong>Feature coupling:</strong> message UI reuses ticket error wording, and dashboard derives from ticket hooks. I would keep transport errors shared while leaving domain messages with their owning feature.</p>
</li>
</ul>
<p>A common design smell is duplicating server records in Redux or another global store while TanStack Query already owns them. Another is making AuthContext a general-purpose store for tickets, filters, and drafts. Neither is necessary here. Likewise, React-only permission checks, scattered HTTP clients, or component-only filters that users expect to bookmark are future risks to guard against, not current claims about every part of this app.</p>
<p>A mutation can succeed while one view remains stale even when another view updates correctly. Invalidating a list does not automatically define what happens to its detail page, dashboard aggregate, or sort order. The reverse mistake is invalidating everything and triggering needless network work. I would start by naming the views affected by the server operation, then choose a narrow cache patch, invalidation, or refetch for each. If a workflow has delayed server-side processing, the response to the initial mutation may not contain the final state; the client needs a refresh policy that matches that lifecycle.</p>
<p>Loading, error, and empty are not interchangeable placeholders. An empty list means the request succeeded and returned no records. A failed request means the interface does not know whether records exist. A background refresh can fail while previously fetched data is still useful. Treating those cases separately preserves context and makes retry behavior clearer. The current screens cover initial loading, error, empty, and content; retaining stale content through a failed background refresh is a possible evolution, not behavior I am claiming today.</p>
<h2>Proposed architecture for a larger dashboard</h2>
<p>I would keep the existing feature organization and sharpen its boundaries. This is <strong>proposed architecture</strong>, not the repository’s current tree:</p>
<pre><code class="language-text">src/
  app/       providers, router, shell
  features/  auth, tickets, messages, ai, dashboard
  shared/    api transport, UI primitives, config
</code></pre>
<pre><code class="language-text">App -&gt; Router -&gt; Shell -&gt; Feature page/coordinator
                          |-&gt; local interaction state
                          |-&gt; feature hook -&gt; Query -&gt; API -&gt; Backend
AuthProvider -&gt; session identity and bootstrap
URL          -&gt; ticket id and proposed list parameters
</code></pre>
<p>The import direction matters more than the folder names. Features should own their queries, keys, API functions, and domain UI. Shared code should not import ticket policy. A page or feature coordinator should make cross-feature orchestration visible. The URL should become the canonical owner of committed search, filters, and pagination when those values should survive reloads or be shared; an uncommitted search draft can remain local.</p>
<p>For larger systems, I would keep list browsing separate from dashboard aggregates, define cache scope around identity, and write down each mutation’s affected views. Those choices address actual pressure points in the reference app rather than introducing a state library or folder structure for appearance.</p>
<h2>Performance, testing, and observability</h2>
<p><strong>Currently implemented:</strong> query caching and freshness defaults, parameterized keys, a bounded first-page dashboard request, component-local form state, and <code>useMemo</code> for dashboard metric calculation and recent-ticket slicing. The memoization is present; no measured speedup is established. Ticket rows and message arrays are mapped normally. Routes are eagerly imported; no route code splitting or list virtualization was found.</p>
<p>I would address performance in this order: avoid unnecessary requests and payloads; cache with deliberate freshness; bound lists through pagination; place state so edits do not update unrelated consumers; inspect rendering scope; then measure whether virtualization or route splitting is justified. For this app, the dashboard’s fixed 100-item request is a bound, but it is also the reason its derived counts are approximate. The ticket list itself has no controls to request a smaller page. Before tuning React renders, I would resolve the data contract and give users a way to navigate a bounded result set.</p>
<p>Memoization follows evidence of repeated expensive work. It cannot fix a wrong aggregate or an oversized response. Likewise, code splitting only helps if the delivered route bundle or initial load is a demonstrated cost, and virtualization only helps if a large rendered collection is the bottleneck. Each adds behavior and loading edges that need their own verification.</p>
<p><strong>Current tests:</strong> the frontend uses Vitest, React Testing Library, and MSW. Test sources cover routes, permission-sensitive UI, form validation, query states, mutations, auth interceptors, AI draft use, and accessibility regressions. Some helpers use real auth while many use mocked identity. I reviewed the test sources and configuration, not a fresh test run or coverage report; configured thresholds are not measured coverage. No browser e2e suite was found in the inspected inventory, and the accessibility tests do not establish complete accessibility.</p>
<p><strong>What I would add:</strong> exercise auth bootstrap failures and account switching with cached data; verify permission-sensitive actions, loading/error/empty states, query/mutation behavior, routing, and critical forms in browser journeys. I found no frontend telemetry integration in the inspected source. I would add sanitized error reporting, API failure context, performance monitoring, and user-impact signals while excluding tokens and message contents.</p>
<h2>Decisions I would keep explicit</h2>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Current approach</th>
<th>Larger-system direction</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Session/cache</td>
<td>Context, localStorage token, shared query client</td>
<td>Identity-scoped cache and explicit transition cleanup</td>
<td>Define protected data lifetime</td>
</tr>
<tr>
<td>Lists/metrics</td>
<td>Plain list; first-100 dashboard sample</td>
<td>URL pagination and aggregate endpoint</td>
<td>Separate browsing from complete totals</td>
</tr>
<tr>
<td>Mutations</td>
<td>Invalidation or confirmed append</td>
<td>Affected-view and reconciliation policy</td>
<td>Keep related views consistent</td>
</tr>
<tr>
<td>Drafts</td>
<td>Local fields; suggestion remount</td>
<td>Explicit replacement and ticket lifecycle</td>
<td>Preserve user intent</td>
</tr>
<tr>
<td>Performance</td>
<td>Cache, bounded dashboard request, some memoization</td>
<td>Measure payload/render scope before splitting</td>
<td>Spend effort on observed costs</td>
</tr>
<tr>
<td>Quality</td>
<td>jsdom/MSW regression tests</td>
<td>Session/cache cases and browser journeys</td>
<td>Exercise boundary failures</td>
</tr>
<tr>
<td>Observability</td>
<td>No integration found</td>
<td>Sanitized errors and user-impact metrics</td>
<td>Diagnose failures users encounter</td>
</tr>
</tbody></table>
<p>My practical rule is simple: give each value an owner, each mutation a consistency policy, each permission an authoritative server check, and each asynchronous boundary a recovery path. The reference frontend has the core pieces; making cache lifetimes, URL state, and interaction transitions explicit is how I would evolve it as the product grows.</p>
<hr />
<p>Originally published on my engineering blog: <a href="https://blog.ramjwork.in/frontend/react-dashboard-system-design">https://blog.ramjwork.in/frontend/react-dashboard-system-design</a></p>
]]></content:encoded></item><item><title><![CDATA[Redis + BullMQ for AI Workloads: Retries Are Not Enough — You Also Need Idempotency and Ordering]]></title><description><![CDATA[Moving AI analysis out of an HTTP request improves the request path. Queueing that work does not make it correct.
In my AI Support Assistant, a new message triggers BullMQ analysis of the ticket conve]]></description><link>https://rjt.hashnode.dev/redis-bullmq-for-ai-workloads-retries-are-not-enough-you-also-need-idempotency-and-ordering</link><guid isPermaLink="true">https://rjt.hashnode.dev/redis-bullmq-for-ai-workloads-retries-are-not-enough-you-also-need-idempotency-and-ordering</guid><category><![CDATA[Node.js]]></category><category><![CDATA[Redis]]></category><category><![CDATA[bullmq]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Ram Ji Tripathi]]></dc:creator><pubDate>Sun, 04 Oct 2026 16:58:12 GMT</pubDate><content:encoded><![CDATA[<p>Moving AI analysis out of an HTTP request improves the request path. Queueing that work does not make it correct.</p>
<p>In my AI Support Assistant, a new message triggers BullMQ analysis of the ticket conversation. The request waits for database writes and queue publication, then returns without waiting for the AI results.</p>
<p>But a retryable job can still run more than once, reload different context, race with another job, repeat provider work or overwrite newer results. The message can also be saved without its analysis job reaching Redis.</p>
<p>Examining the current implementation makes those boundaries concrete. The scenarios below are correctness risks exposed by the code, not production incidents; the proposed fixes are not implemented features.</p>
<h2>The System This Came From</h2>
<p><a href="https://blog.ramjwork.in/ai-engineering/ai-customer-support-architecture">The first article</a> described the broader React, Node.js, PostgreSQL, Redis and OpenAI architecture.</p>
<p>This article focuses on one workflow: a message is added to a support ticket, and background processing generates a summary, sentiment and priority. Those values are written back to the ticket. The worker also records an <code>AIInteraction</code> containing the conversation, summary response and summary token usage.</p>
<p>The queue is named <code>ai-processing</code>. Its job type is <code>generate-ticket-summary</code>, although the processor does more than summarize. It analyzes the whole ticket conversation, rather than just the message that triggered publication.</p>
<h2>Why the AI Work Went to BullMQ</h2>
<p>Saving a message and enriching a ticket have different completion requirements. The message is the user-authored record. AI metadata can arrive later.</p>
<p>The current boundary looks like this:</p>
<pre><code class="language-text">React client → Node.js API → PostgreSQL: Message + Ticket timestamp
                         → Redis/BullMQ: publish analysis job
                         → HTTP 201 after publication

Redis/BullMQ → separate worker → load ticket messages
                              → summary → sentiment → priority
                              → update Ticket → insert AIInteraction
</code></pre>
<p>Publication makes the job available to the worker. The worker may start before HTTP 201 is sent; the diagram shows two execution paths, not a guarantee that background processing begins after the response.</p>
<p>The important boundary is what the request awaits: persistence and publication, followed by a return to the caller. Summary, sentiment and priority belong to the worker's lifecycle.</p>
<h2>The Actual Producer Flow</h2>
<p>From <code>backend/src/modules/messages/message.service.ts</code>, lines 26–58. Authorization precedes this excerpt. The excerpt is otherwise complete; blank lines are condensed.</p>
<pre><code class="language-ts">const message = await createMessageRepository({
  content: data.content,
  ticketId: data.ticketId,
  senderId: data.senderId,
});

await updateTicketRepository(data.ticketId, {
  updatedAt: new Date(),
});

await aiQueue.add(
  GENERATE_TICKET_SUMMARY_JOB,
  {
    ticketId: data.ticketId,
    messageId: message.id,
  },
  {
    attempts: 3,
    backoff: {
      type: "exponential",
      delay: 5000,
    },
    removeOnComplete: true,
    removeOnFail: false,
  },
);

return message;
</code></pre>
<p>The repository helpers execute <code>prisma.message.create</code> and <code>prisma.ticket.update</code>. These are separate writes, followed by publication to Redis. There is no encompassing transaction or outbox in this flow.</p>
<p>The controller awaits this service before returning HTTP 201. Consequently, a message can already exist even if the service does not reach its successful return. Queue publication is part of request completion; AI processing is not.</p>
<p>The payload includes <code>messageId</code>, but the worker's processing logic only consumes <code>ticketId</code>. The message ID currently provides neither a processing boundary nor an idempotency key.</p>
<h2>What the Worker Actually Does</h2>
<p>The worker dispatches <code>generate-ticket-summary</code> to <code>processTicketSummaryJob</code> using only the ticket ID. Its configuration in <code>backend/src/queues/ai.worker.ts</code>, lines 71–75, is:</p>
<pre><code class="language-ts">{
  connection: redis as any,
  concurrency: 5,
},
</code></pre>
<p>This is the complete options object from the worker constructor, with blank lines condensed.</p>
<p>Five jobs can run concurrently within this worker instance, including jobs for the same ticket. This is a local worker limit, not a global or per-ticket limit. See BullMQ’s <a href="https://docs.bullmq.io/guide/workers/concurrency">worker concurrency guide</a>.</p>
<p>The processor loads messages with <code>where: { ticketId }</code>, includes sender role and name, and sorts by <code>createdAt: "asc"</code>. It returns early when there are no messages. Otherwise it formats the conversation and executes this sequence from lines 113–127:</p>
<pre><code class="language-ts">const aiResult = await generateTicketSummary(
  {
    conversation,
  },
  aiContext,
);

const sentimentResult = await generateSentiment(
  {
    conversation,
  },
  aiContext,
);

const priorityResult = await detectTicketPriority(conversation, aiContext);
</code></pre>
<p>These operations are sequential within one job. Different jobs can still overlap. The service routes generation through an OpenAI-primary orchestrator with optional Gemini fallback for eligible failures, so three logical operations do not necessarily mean exactly three external HTTP requests.</p>
<p>After sentiment and priority validation, lines 133–155 perform two separate writes:</p>
<pre><code class="language-ts">await prisma.ticket.update({
  where: {
    id: ticketId,
  },
  data: {
    aiSummary: aiResult.summary,
    sentiment,
    priority,
  },
});

await createAIInteraction({
  ticketId,
  prompt: conversation,
  response: aiResult.summary || "",
  tokensUsed: aiResult.tokensUsed,
});
</code></pre>
<p>Blank lines are condensed. <code>createAIInteraction</code> is a plain Prisma insert. There is no job identity or conversation version attached to that record, and no conditional version check on the ticket update.</p>
<p>The worker runs through a separate entry point in <code>backend/src/worker.ts</code>. That separates its execution lifecycle from HTTP handling; it does not add coordination between jobs for one ticket.</p>
<h2>Retries Solve Failure Recovery, Not Correctness</h2>
<p>The producer configures three total attempts, including the initial execution, with exponential backoff and a five-second base. For ordinary thrown-error retries, that gives nominal delays of five and ten seconds before the remaining attempts; scheduling can add waiting time. See BullMQ's <a href="https://docs.bullmq.io/guide/retrying-failing-jobs">retry documentation</a>.</p>
<p>Completed jobs are removed. Failed jobs are retained. Those choices affect recovery and diagnosis, but they do not make processing idempotent.</p>
<p>Retry asks, “Should I try this work again after failure?” Idempotency asks, “If I perform it again, will the resulting state remain correct?” Deduplication asks, “Should equivalent work be admitted or executed again at all?”</p>
<p>Here, retries restart a processor that calls providers and performs database writes. The retry configuration supplies no rule for deciding whether those effects have already happened.</p>
<h2>Failure Mode 1: The Database Commits but the Queue Does Not</h2>
<p>Consider the producer's actual order:</p>
<ol>
<li>The message insert succeeds.</li>
<li>The ticket timestamp update succeeds.</li>
<li>Publication rejects, or the process exits before publication completes.</li>
</ol>
<p>The database now contains the conversation change without assured corresponding queue work. Retrying a worker cannot recover a job that was never published.</p>
<p>A publication error can also leave an ambiguous outcome: Redis might have accepted the job before the caller lost its acknowledgement. Recovery must handle both missing work and repeated publication.</p>
<p>The earlier boundary also matters: if the ticket update fails after message creation, the message remains persisted and publication is never reached. Wrapping those two database writes in a transaction would address that database-only partial state. It would not atomically commit a Redis job with PostgreSQL data.</p>
<p>A <strong>proposed transactional outbox</strong> changes where publication intent becomes durable:</p>
<pre><code class="language-text">BEFORE — CURRENT
Message insert → Ticket update → Redis publication
     database writes complete       can fail separately

IMPROVED — PROPOSED
PostgreSQL transaction:
  Message insert + Ticket update + Outbox event
                         ↓ committed publication intent
Outbox dispatcher → Redis/BullMQ → worker
       ↓
mark event published after successful queue publication
</code></pre>
<p>The dispatcher retries unpublished events. If it publishes and crashes before marking the event, it may publish again. An outbox therefore needs stable event identity and duplicate-safe consumption; it does not confer exactly-once execution.</p>
<p>This adds a table, dispatcher lifecycle, backlog monitoring and retention policy. For optional enrichment in an early application, explicit reconciliation may be an interim choice. If every accepted message must eventually be analyzed, I would prioritize an outbox regardless of traffic volume. Reliability requirements, rather than scale alone, determine when it is needed.</p>
<h2>Failure Mode 2: Retrying AI Work Is Not Automatically Idempotent</h2>
<p>Suppose summary generation succeeds and sentiment generation fails with an error that escapes the provider layer. BullMQ can retry the processor. It starts again by loading messages and generating the summary. There is no checkpoint that reuses the previous summary.</p>
<p>Now move the failure later. Summary, sentiment and priority succeed; the ticket update commits; the interaction insert fails. The ticket already contains AI metadata, but the job has failed. A retry repeats the AI sequence and attempts both database writes again.</p>
<p>The resulting values need not match the previous output. More messages may also have arrived before the retry reloads the conversation. The same job payload therefore does not necessarily identify the same input snapshot.</p>
<p>A duplicate execution that reaches the insert can append another <code>AIInteraction</code>: its schema uses a generated UUID and has no unique analysis key. Interruption after application writes but before queue completion is recorded creates another window for repeated effects. The relevant effects here are provider requests, ticket metadata writes and interaction records.</p>
<h3>Deterministic job identity helps at publication</h3>
<p>A proposed identity such as <code>ticket-analysis-&lt;messageId&gt;</code> could suppress repeated publication for the same saved message while that queue job exists. It would not suppress jobs triggered by two different messages, nor deduplicate message creation if a repeated HTTP request creates a new message ID.</p>
<p>BullMQ's <a href="https://docs.bullmq.io/guide/jobs/job-ids">job ID documentation</a> explains that an existing custom ID prevents another job with that ID from being added. Once the job is removed, the ID no longer protects against duplicates. That is directly relevant to this application's <code>removeOnComplete: true</code> setting.</p>
<h3>Durable idempotency belongs at the application effect</h3>
<p>For this workflow, I would define a logical analysis key from ticket identity, conversation revision and analysis/prompt version. A durable record with a unique constraint could track whether that analysis has already been applied.</p>
<p>The ticket update, interaction insert and successful analysis marker should commit together, with concurrent duplicate attempts resolved by database constraints and conditional writes. A separate initial “already done?” read is insufficient because two workers can both pass it.</p>
<p>That would protect database effects. Provider requests made before commit could still repeat. Reusing stored generation results would require checkpointing and invalidation rules; I would start with atomic result persistence and durable identity.</p>
<h2>Failure Mode 3: Ordering Is a Business Constraint</h2>
<p>Two messages arrive quickly on ticket T:</p>
<ol>
<li>Message A is saved and job A starts. It reads the conversation containing A.</li>
<li>Message B is saved and job B starts in another available worker slot. It reads A and B.</li>
<li>Job B finishes first and writes metadata based on A and B.</li>
<li>Job A finishes later and overwrites that metadata using its older context.</li>
</ol>
<p>The current ticket update matches only <code>id: ticketId</code>. Nothing prevents step four. This is a possible stale, last-writer-wins result visible from the architecture.</p>
<p>Both jobs could also start after B exists, read overlapping or identical context, and perform redundant analysis. Because <code>messageId</code> is unused, neither job is restricted to the conversation as it stood when its triggering message was saved.</p>
<p>Sorting messages inside each job does not order jobs or their writes. The timestamp sort also has no explicit tie-breaker for equal timestamps.</p>
<p>Global throughput and per-ticket correctness are separate constraints. Lowering one worker's concurrency to one would reduce overlap there, but also serialize unrelated tickets and would not establish a cross-process ordering policy.</p>
<h2>Idempotency, Deduplication and Ordering Are Different</h2>
<table>
<thead>
<tr>
<th>Concept</th>
<th>Question it answers</th>
<th>Example in this system</th>
</tr>
</thead>
<tbody><tr>
<td>Retry</td>
<td>Should failed work be attempted again?</td>
<td>Retry a ticket analysis after a transient failure.</td>
</tr>
<tr>
<td>Deduplication</td>
<td>Should equivalent work be admitted again?</td>
<td>Proposed: suppress publication while the same job ID exists.</td>
</tr>
<tr>
<td>Idempotency</td>
<td>Will repeating the work preserve correct state?</td>
<td>Proposed: commit one analysis record for a ticket revision.</td>
</tr>
<tr>
<td>Ordering</td>
<td>In what sequence may work take effect?</td>
<td>Proposed: apply ticket analyses in conversation-revision order.</td>
</tr>
<tr>
<td>Concurrency</td>
<td>How much work may run at the same time?</td>
<td>Allow five active jobs within this worker instance.</td>
</tr>
</tbody></table>
<p>Only retries and worker concurrency are configured in the current path. The other examples describe proposed behavior. A version check can reject stale results without executing every job in strict order; serialization still needs a policy for retries and earlier failures.</p>
<h2>A Better Per-Ticket Processing Model</h2>
<p>The following is a <strong>proposed model</strong>, not current code. For summary, sentiment and priority, the business goal is usually a current conversation view. It may not require publishing every intermediate analysis.</p>
<pre><code class="language-text">Messages A, B, C → atomically advance ticket conversation revision
                                   ↓
                       record latest requested revision
                                   ↓
                 coalesce pending work for this ticket
                                   ↓
                 one active analysis per ticket, if needed
                                   ↓
                    read consistent snapshot at revision R
                                   ↓
                            generate AI results
                                   ↓
                  atomic apply only if revision is still R
                          ↙                      ↘
                stale: discard              current: commit
                    ↓                    results + analysis record
         ensure latest revision remains scheduled
</code></pre>
<p><strong>Version checks are the first correctness protection I would add.</strong> A dedicated conversation revision should advance atomically with message persistence. The worker must read the revision and messages as a consistent snapshot, then conditionally apply results in a short database transaction. A separate check followed by an unconditional update leaves a race.</p>
<p>I would avoid using <code>Ticket.updatedAt</code> as the conversation revision. Prisma marks it <code>@updatedAt</code>, so AI writes and unrelated ticket edits also change it. A dedicated revision makes the input contract explicit. The tradeoff is schema and transaction work; discarded stale analyses still consume processing. This protection is useful now because the current concurrency already permits stale writes.</p>
<p><strong>Coalescing can reduce unnecessary work.</strong> Several pending triggers can represent one request to analyze the latest conversation. A short debounce period trades freshness for fewer intermediate analyses. BullMQ offers <a href="https://docs.bullmq.io/guide/jobs/deduplication">deduplication modes</a>, but their lifetime semantics must match the application.</p>
<p>In particular, simply suppressing every new job while one analysis is active can miss updates arriving after that analysis loaded its messages. The design must durably track the latest requested revision and arrange another pass when needed. Checking once at the end without an atomic handoff can still lose a concurrent update. Coalescing is a pragmatic next step when message bursts cause redundant work; it is not a substitute for version checks.</p>
<p><strong>Per-ticket serialization can limit overlap.</strong> A coordinated lease or partitioned processing scheme can allow different tickets to progress concurrently while restricting one ticket's active analysis. An in-memory mutex covers only one process. Distributed leases introduce expiry, renewal and crash-recovery concerns; conditional version checks remain valuable when a lease expires while work is still running.</p>
<p>If a future job must act on every message in business order, it needs explicit sequence tracking and a policy for earlier failures. Coalescing intermediate analyses would no longer satisfy that requirement.</p>
<h2>What I Would Change First</h2>
<p>These are recommendations for this application, not features already implemented.</p>
<ol>
<li><strong>Define analysis identity and protect freshness together.</strong> Introduce a conversation revision, conditional result application and an atomic transaction for ticket metadata, interaction and an analysis marker. Then use deterministic publication identity for the chosen unit of work. This addresses correctness exposed by today's concurrency; it costs a schema change and careful transaction design.</li>
<li><strong>Coalesce pending ticket analysis where the product permits it.</strong> Treat the job as “refresh this ticket” rather than “produce every intermediate result,” while preserving a durable follow-up for changes during active processing. This costs scheduling complexity and may delay freshness slightly. It becomes more useful when bursts create redundant work. Add coordinated per-ticket serialization if overlap remains significant; version protection comes first.</li>
<li><strong>Close the database-to-queue gap when eventual analysis is required.</strong> Add an outbox and replay-safe dispatcher. Also review producer connection policy: the shared Redis module sets <code>maxRetriesPerRequest: null</code>, and the message service has no explicit publication deadline. BullMQ’s <a href="https://docs.bullmq.io/guide/connections">connection guidance</a> distinguishes worker recovery from bounded waiting in HTTP producers. The operational cost is real, but the need depends on the reliability contract, not a hypothetical high-traffic milestone.</li>
<li><strong>Build recovery on the visibility already present.</strong> The worker logs receipt, completion, failures, ticket/job IDs and duration. Before using the attempt fields for alerts, I would verify their meaning at receipt and failure events. Add exhausted-attempt alerts, queue age/backlog visibility, stale-result counters and revision correlation. These are useful now; a larger telemetry platform can wait.</li>
<li><strong>Add a deliberate replay policy.</strong> Retained failed jobs are a starting point, not an implemented dead-letter workflow. An operator should distinguish transient failures from invalid input, replay after repair, and know whether replay means the original snapshot or latest conversation. Controlled replay tooling and retention add maintenance work. A separate dead-letter queue is optional until volume or operational ownership warrants it.</li>
</ol>
<p>I would evaluate these changes through duplicate work, result freshness and publication failures. Infrastructure changes should follow a demonstrated constraint.</p>
<h2>What I Would Not Add Yet</h2>
<p>I would not introduce Kafka, Kubernetes or decompose the application into microservices solely because it has a queue.</p>
<p>The concrete concerns are durable publication intent, safe repeated writes and valid application of conversation-derived results. They can be addressed within PostgreSQL, Redis, BullMQ and the existing API/worker boundary.</p>
<p>More infrastructure may become appropriate under specific throughput, ownership or deployment constraints. It does not remove the need to define what makes an analysis current or safe to repeat.</p>
<h2>Why AI Workloads Make This More Visible</h2>
<p>In this worker, three sequential generation operations separate the initial conversation read from the final ticket update. While those external requests are in flight, another message can arrive and another job can complete. The queue can function normally while the result becomes obsolete.</p>
<p>Provider calls can be relatively slow, and successful generation consumes usage billed under the provider’s terms; OpenAI documents <a href="https://developers.openai.com/api/docs/pricing">token-based API pricing</a>. Repeating a completed summary because sentiment failed can therefore repeat meaningful work, even when no database row has yet changed. This is a reason to control repetition, not a measured cost or latency claim about this application.</p>
<p>Generated text can also differ between attempts. Overwriting <code>aiSummary</code> with a second output is not automatically idempotent just because both writes target the same row. Meanwhile, this processor reloads messages on every attempt, so a retry can change both its input and its output.</p>
<p>For this workload, the useful contract is: which conversation revision does this analysis describe, and when may it take effect? The queue schedules execution. The application must answer that question.</p>
<h2>Final Takeaway</h2>
<p>BullMQ gives this application a clear boundary between message creation and background AI enrichment. Correctness still needs an application-level design: durable publication intent, duplicate-safe effects and protection against stale conversation results.</p>
<p>Retries make another attempt possible. Idempotency and ordering rules determine whether that attempt is safe to apply.</p>
<p>Originally part of the engineering work behind my AI Support Assistant architecture.</p>
<p>Originally published on my engineering blog:
<a href="https://blog.ramjwork.in/ai-engineering/redis-bullmq-ai-background-jobs">https://blog.ramjwork.in/ai-engineering/redis-bullmq-ai-background-jobs</a></p>
]]></content:encoded></item><item><title><![CDATA[How I Architected an AI Customer Support System with React, Node.js, PostgreSQL, Redis & OpenAI]]></title><description><![CDATA[Adding AI wasn’t the difficult part. Deciding where AI belongs in the application architecture was.

Adding an LLM API to an application is relatively straightforward.
The harder questions begin after]]></description><link>https://rjt.hashnode.dev/how-i-architected-an-ai-customer-support-system-with-react-node-js-postgresql-redis-openai</link><guid isPermaLink="true">https://rjt.hashnode.dev/how-i-architected-an-ai-customer-support-system-with-react-node-js-postgresql-redis-openai</guid><category><![CDATA[React]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Ram Ji Tripathi]]></dc:creator><pubDate>Sun, 04 Oct 2026 05:19:43 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>Adding AI wasn’t the difficult part. Deciding where AI belongs in the application architecture was.</p>
</blockquote>
<p>Adding an LLM API to an application is relatively straightforward.</p>
<p>The harder questions begin after the first API call works.</p>
<p>Should AI run inside the user’s HTTP request? What happens when the provider is slow? Which AI operations should be asynchronous? What belongs in React state versus server state? Should an AI-generated support reply be sent automatically? What happens when a background job retries after partially completing?</p>
<p>I ran into these questions while building an AI-powered customer support application with React, TypeScript, Node.js, PostgreSQL, Redis, BullMQ and OpenAI.</p>
<p>The interesting part of the project wasn’t the prompt.</p>
<p>It was deciding <strong>where AI should—and should not—sit inside the system.</strong></p>
<p>This article walks through that architecture, shows selected code from the actual implementation, and discusses several things I would change before scaling the design further.</p>
<hr />
<h2>What the Application Does</h2>
<p>The application revolves around support tickets and conversations.</p>
<p>It has three roles:</p>
<ul>
<li><strong>Customers</strong> create tickets, read their own tickets and add messages.</li>
<li><strong>Agents</strong> work with assigned tickets, update their status and request AI-generated reply suggestions.</li>
<li><strong>Admins</strong> have broader ticket access and assignment capabilities.</li>
</ul>
<p>AI assists the workflow in two different ways.</p>
<p>First, when a new message is added, background processing analyzes the ticket conversation to generate:</p>
<ul>
<li>a ticket summary,</li>
<li>sentiment,</li>
<li>priority.</li>
</ul>
<p>Second, an agent or admin can explicitly request an AI-generated reply suggestion.</p>
<p>Those two AI workloads look similar at first—they both call an LLM—but they have very different execution requirements.</p>
<p>That distinction shaped much of the architecture.</p>
<hr />
<h2>High-Level Architecture</h2>
<p>At a high level, the system looks like this:</p>
<pre><code class="language-text">                    ┌─────────────────────────┐
                    │       React Client      │
                    │                         │
                    │ Auth / Tickets /        │
                    │ Messages / AI Replies   │
                    └────────────┬────────────┘
                                 │
                              REST API
                                 │
                    ┌────────────▼────────────┐
                    │     Node.js / Express   │
                    │                         │
                    │ Auth → Controller →     │
                    │ Service → Repository    │
                    └───────┬─────────┬───────┘
                            │         │
                            │         │ enqueue
                            │         ▼
                    ┌───────▼───┐  ┌──────────────┐
                    │PostgreSQL │  │ Redis/BullMQ │
                    └───────────┘  └──────┬───────┘
                                         │
                                         ▼
                                  ┌─────────────┐
                                  │  AI Worker  │
                                  │             │
                                  │ Summary     │
                                  │ Sentiment   │
                                  │ Priority    │
                                  └──────┬──────┘
                                         │
                                    LLM provider
</code></pre>
<p>The application is deliberately not split into many network services.</p>
<p>The API is a <strong>modular monolith</strong>, while AI background execution runs in a separate worker process.</p>
<p>That gives me an important boundary without introducing a distributed-service architecture prematurely:</p>
<p><strong>HTTP request handling and background AI processing have independent execution lifecycles.</strong></p>
<hr />
<h2>Frontend State: Don’t Put Everything in One Store</h2>
<p>One frontend decision was to separate state based on <strong>who owns it</strong>.</p>
<p>I use three broad categories:</p>
<ul>
<li>authentication/session state → React Context,</li>
<li>remote server state → TanStack Query,</li>
<li>transient UI state → local component state.</li>
</ul>
<p>For example, tickets are server-owned resources, so their fetching and cache lifecycle belong to TanStack Query:</p>
<pre><code class="language-ts">export function useTickets(params?: TicketListParams) {
  return useQuery({
    queryKey: ticketKeys.list(params),
    queryFn: () =&gt; getTickets(params),
  });
}

export function useTicket(ticketId: string) {
  return useQuery({
    queryKey: ticketKeys.detail(ticketId),
    queryFn: () =&gt; getTicketById(ticketId),
    enabled: Boolean(ticketId),
  });
}

export function useCreateTicket() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (payload: CreateTicketRequest) =&gt; createTicket(payload),

    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
    },
  });
}
</code></pre>
<p>The important part here isn’t the library.</p>
<p>It’s the ownership model.</p>
<p>A ticket fetched from the API is not fundamentally client state. The server remains authoritative.</p>
<p>TanStack Query handles fetching, caching and invalidation, while component-local state handles things such as editable drafts and temporary UI interactions.</p>
<p>This prevents a common React architecture problem: turning one global store into a mirror of the backend.</p>
<hr />
<h2>The Backend Is a Modular Monolith</h2>
<p>The backend follows a layered flow that is roughly:</p>
<pre><code class="language-text">Route
  ↓
Authentication / Authorization / Validation
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
Prisma
  ↓
PostgreSQL
</code></pre>
<p>This gives features such as tickets and messages clear boundaries without requiring each feature to become an independent service.</p>
<p>The service layer owns application workflows.</p>
<p>Repositories isolate most persistence operations.</p>
<p>Controllers remain focused primarily on translating HTTP requests and responses.</p>
<p>AI is a partial exception because some worker-side operations interact more directly with Prisma, but the overall HTTP architecture follows this separation.</p>
<p>For the current scope, I prefer this to creating multiple microservices.</p>
<p>A service boundary should solve an actual deployment, ownership or scaling problem—not exist simply because the system has multiple features.</p>
<hr />
<h2>Why I Moved AI Analysis Out of the Request Path</h2>
<p>Consider what happens when a customer adds a message.</p>
<p>The important user-facing operation is:</p>
<blockquote>
<p>Persist my message successfully.</p>
</blockquote>
<p>Summary, sentiment and priority enrichment are useful, but the user should not have to wait for several LLM operations to complete before the message request can finish.</p>
<p>The service therefore persists the message and publishes a BullMQ job:</p>
<pre><code class="language-ts">export const createMessageService = async (data: CreateMessageInput) =&gt; {
  await getAuthorizedTicketService(data.ticketId, data.accessContext);

  const message = await createMessageRepository({
    content: data.content,
    ticketId: data.ticketId,
    senderId: data.senderId,
  });

  await updateTicketRepository(data.ticketId, {
    updatedAt: new Date(),
  });

  await aiQueue.add(
    GENERATE_TICKET_SUMMARY_JOB,
    {
      ticketId: data.ticketId,
      messageId: message.id,
    },
    {
      attempts: 3,
      backoff: {
        type: "exponential",
        delay: 5000,
      },
      removeOnComplete: true,
      removeOnFail: false,
    },
  );

  return message;
};
</code></pre>
<p>There is an important architectural detail here.</p>
<p>The HTTP request <strong>does wait for queue publication</strong>, but it does <strong>not wait for the AI analysis itself</strong>.</p>
<p>Conceptually:</p>
<pre><code class="language-text">POST message
     │
     ▼
Authorize access
     │
     ▼
Persist message
     │
     ▼
Update ticket timestamp
     │
     ▼
Publish BullMQ job
     │
     ├──────────────► HTTP response
     │
     ▼
Background worker
     │
     ▼
AI processing
</code></pre>
<p>That removes LLM execution time from the main request path while still ensuring the API knows whether it successfully handed work to the queue.</p>
<p>It also allows the worker lifecycle to be operated separately from the HTTP server.</p>
<hr />
<h2>What the Background Worker Actually Does</h2>
<p>BullMQ uses Redis as the queue infrastructure.</p>
<p>A separate worker consumes AI-processing jobs:</p>
<pre><code class="language-ts">export const aiWorker = new Worker(
  AI_QUEUE,

  async (job) =&gt; {
    const startedAt = Date.now();
    jobStartTimes.set(String(job.id), startedAt);

    const ticketId =
      typeof job.data?.ticketId === "string" ? job.data.ticketId : undefined;

    workerLogger.info(
      {
        event: "ai_job_received",
        jobId: job.id,
        jobName: job.name,
        ticketId,
        attempt: job.attemptsMade + 1,
      },
      "Job received",
    );

    switch (job.name) {
      case GENERATE_TICKET_SUMMARY_JOB:
        await processTicketSummaryJob(ticketId ?? job.data.ticketId);
        break;

      default:
        workerLogger.warn(
          {
            event: "ai_job_unknown",
            jobId: job.id,
            jobName: job.name,
            ticketId,
          },
          "Unknown job",
        );
    }
  },

  {
    connection: redis as any,
    concurrency: 5,
  },
);
</code></pre>
<p>Inside the ticket-processing workflow, messages are loaded in conversation order and transformed into the conversation passed to the AI layer.</p>
<p>The relevant part of the processing pipeline is:</p>
<pre><code class="language-ts">const conversation = formatConversation(messages);

const aiResult = await generateTicketSummary(
  {
    conversation,
  },
  aiContext,
);

const sentimentResult = await generateSentiment(
  {
    conversation,
  },
  aiContext,
);

const priorityResult = await detectTicketPriority(
  conversation,
  aiContext,
);

const sentiment = validateSentiment(
  sentimentResult.sentiment || "",
) as any;

const priority = validatePriority(
  priorityResult.priority || "",
) as any;
</code></pre>
<p>Summary, sentiment and priority are currently generated sequentially inside a job.</p>
<p>The normalized results are then used to update ticket-level AI fields, and an AI interaction record is created for the summary workflow.</p>
<p>This design gives the application retryable asynchronous execution without coupling LLM latency directly to message creation.</p>
<p>But queues introduce their own problems.</p>
<p>I’ll return to those shortly.</p>
<hr />
<h2>Not Every AI Operation Belongs in a Queue</h2>
<p>Once background processing existed, it would have been easy to conclude:</p>
<blockquote>
<p>AI is slow, therefore every AI operation should be queued.</p>
</blockquote>
<p>I don’t think that is the right abstraction.</p>
<p>The correct execution model depends on what the user is doing.</p>
<p>Ticket analysis is enrichment. It can happen asynchronously.</p>
<p>An agent clicking <strong>Generate Reply</strong>, however, is explicitly waiting for an answer.</p>
<p>For that workflow, the backend generates the suggestion synchronously:</p>
<pre><code class="language-ts">const conversation = formatConversation(messages);

const aiResult = await generateReplySuggestion(conversation, {
  ticketId,
  requestId: typeof req.id === "string" ? req.id : undefined,
});

res
  .status(200)
  .json(new ApiResponse("Reply suggestion generated", aiResult));
</code></pre>
<p>The important distinction is:</p>
<pre><code class="language-text">Background analysis
Message → Queue → Worker → AI → Ticket metadata

Interactive assistance
Agent → Request suggestion → AI → Suggested text → Agent
</code></pre>
<p>The second flow doesn’t automatically create or send a support message.</p>
<p>That is intentional.</p>
<hr />
<h2>Human-in-the-Loop AI</h2>
<p>A generated support reply can be wrong, incomplete or inappropriate for the context.</p>
<p>So the system treats AI output as a <strong>suggestion</strong>, not an autonomous action.</p>
<p>On the frontend:</p>
<pre><code class="language-tsx">&lt;ReplySuggestion
  ticketId={data.id}
  onUseReply={(content) =&gt; {
    setComposerDraft(content);
    setComposerKey((current) =&gt; current + 1);
  }}
/&gt;

&lt;MessageComposer
  key={composerKey}
  ticketId={data.id}
  initialContent={composerDraft}
  onMessageSent={() =&gt;
    setMessageRefreshToken((current) =&gt; current + 1)
  }
/&gt;
</code></pre>
<p>When the user selects <strong>Use Reply</strong>, the suggestion is moved into the normal message composer.</p>
<p>The agent can then:</p>
<pre><code class="language-text">Generate
   ↓
Review
   ↓
Edit
   ↓
Send
</code></pre>
<p>The AI does not own the final action.</p>
<p>That boundary matters more to me than simply adding another confirmation dialog.</p>
<p>The application architecture itself separates <strong>generation</strong> from <strong>execution</strong>.</p>
<p>This is a pattern I would reuse in many AI-assisted workflows where the output affects another person or an important business process.</p>
<hr />
<h2>AI Provider Logic Belongs Behind an Application Boundary</h2>
<p>The application currently uses an OpenAI-backed adapter for operations such as:</p>
<ul>
<li>summarization,</li>
<li>sentiment analysis,</li>
<li>priority detection,</li>
<li>reply suggestions.</li>
</ul>
<p>There is also provider orchestration that can use Gemini as a fallback for selected classes of provider, quota, rate-limit, network or timeout failures.</p>
<p>I don’t want ticket or message business logic to know the details of every provider.</p>
<p>Conceptually:</p>
<pre><code class="language-text">Business workflow
       │
       ▼
AI operation
       │
       ▼
Provider orchestration
       │
       ├── OpenAI
       │
       └── Gemini fallback
</code></pre>
<p>This boundary becomes useful even if the application never changes providers.</p>
<p>It gives one place to reason about:</p>
<ul>
<li>prompts,</li>
<li>provider failures,</li>
<li>normalization,</li>
<li>fallback rules,</li>
<li>model configuration.</li>
</ul>
<p>Provider abstraction does not mean every model is interchangeable.</p>
<p>Different models behave differently.</p>
<p>The goal is simply to stop provider-specific concerns from leaking through the rest of the application.</p>
<hr />
<h2>Authentication Is Not Authorization</h2>
<p>The application uses JWT authentication, but authentication alone does not answer:</p>
<blockquote>
<p>Is this user allowed to access this particular ticket?</p>
</blockquote>
<p>A valid token identifies the user and role.</p>
<p>Resource authorization still has to be enforced.</p>
<p>For example:</p>
<ul>
<li>customers should only access their own tickets,</li>
<li>agents operate on tickets available to their role and assignment rules,</li>
<li>admins have broader access.</li>
</ul>
<p>The frontend also hides or exposes functionality according to role, but that is a UX decision—not a security boundary.</p>
<p>The backend remains authoritative.</p>
<p>This distinction is especially important in React applications because hiding a button is easy to mistake for enforcing permission.</p>
<p>It isn’t.</p>
<hr />
<h2>Redis Has More Than One Responsibility</h2>
<p>Redis is not used only because BullMQ needs it.</p>
<p>In this project it also supports:</p>
<ul>
<li>short-lived ticket caching,</li>
<li>distributed rate-limit counters.</li>
</ul>
<p>That makes Redis shared infrastructure serving different concerns:</p>
<pre><code class="language-text">Redis
├── BullMQ queue infrastructure
├── ticket cache
└── rate-limit counters
</code></pre>
<p>Those responsibilities should still remain conceptually separate even if they use the same underlying technology.</p>
<p>A queue is not a cache.</p>
<p>A cache is not a source of truth.</p>
<p>The database remains the authoritative store for ticket and conversation data.</p>
<hr />
<h2>What the Architecture Gets Right</h2>
<p>There are several boundaries in this design that I would keep.</p>
<h3>1. User-facing writes are separated from AI enrichment</h3>
<p>Message creation does not wait for summary, sentiment and priority generation.</p>
<h3>2. Interactive AI and background AI use different execution models</h3>
<p>The system chooses between synchronous and asynchronous execution based on the user workflow rather than treating all AI calls identically.</p>
<h3>3. Server state remains server state</h3>
<p>TanStack Query manages API-backed ticket state rather than copying everything into a global client store.</p>
<h3>4. AI suggestions do not automatically become actions</h3>
<p>Generated replies remain editable suggestions.</p>
<h3>5. HTTP and worker lifecycles are separate</h3>
<p>The API server and AI worker can fail, restart and evolve independently at the process level.</p>
<p>But none of those decisions makes the architecture finished.</p>
<p>Several weaknesses become more important as concurrency and operational requirements increase.</p>
<hr />
<h2>What I Would Change Before Scaling</h2>
<p>This is the part of the architecture I find most useful to examine.</p>
<p>Queues solve one category of problem while creating others.</p>
<h3>1. Fix the Database-to-Queue Consistency Gap</h3>
<p>Look again at message creation:</p>
<pre><code class="language-text">Write message to PostgreSQL
        ↓
Update ticket
        ↓
Publish BullMQ job
</code></pre>
<p>These are separate operations.</p>
<p>Imagine:</p>
<ol>
<li>PostgreSQL successfully saves the message.</li>
<li>Redis becomes unavailable before <code>aiQueue.add()</code> succeeds.</li>
</ol>
<p>The application now has committed business data without the corresponding background event.</p>
<p>Retries do not solve this consistency problem.</p>
<p>A stronger design would introduce a <strong>transactional outbox</strong>.</p>
<p>Conceptually:</p>
<pre><code class="language-text">             PostgreSQL transaction
        ┌────────────────────────────┐
        │ Save message               │
        │ Save outbox event          │
        └──────────────┬─────────────┘
                       │ commit
                       ▼
                Outbox publisher
                       │
                       ▼
                   BullMQ
                       │
                       ▼
                    Worker
</code></pre>
<p>The message and the intent to perform AI processing would be committed atomically in PostgreSQL.</p>
<p>A separate publisher could then reliably move pending outbox events into BullMQ.</p>
<p>That doesn’t make the entire system exactly-once.</p>
<p>It closes a specific and important consistency gap.</p>
<h3>2. Retries Need Idempotency</h3>
<p>The queue currently configures multiple attempts with exponential backoff.</p>
<p>That improves recoverability from transient failures.</p>
<p>But:</p>
<blockquote>
<p><strong>retryable does not automatically mean safe to retry.</strong></p>
</blockquote>
<p>Suppose a job:</p>
<ol>
<li>calls the AI provider,</li>
<li>updates the ticket,</li>
<li>writes an AI interaction,</li>
<li>fails somewhere around those operations,</li>
<li>retries.</li>
</ol>
<p>Without explicit idempotency, repeated attempts can repeat side effects.</p>
<p>A stronger implementation would give each logical processing operation a stable identity and make persisted effects safe to replay.</p>
<p>Possible approaches include:</p>
<ul>
<li>deterministic job IDs,</li>
<li>processing-version records,</li>
<li>unique database constraints,</li>
<li>upsert semantics,</li>
<li>explicit processed-event records.</li>
</ul>
<p>The exact mechanism depends on the workflow.</p>
<p>The architectural requirement is simpler:</p>
<p><strong>A retry should not accidentally turn one logical operation into multiple business effects.</strong></p>
<h3>3. Protect Against Same-Ticket Concurrency</h3>
<p>The worker has concurrency greater than one.</p>
<p>That’s useful for processing unrelated tickets.</p>
<p>But it also means two jobs for the same ticket can overlap.</p>
<p>Consider:</p>
<pre><code class="language-text">Ticket conversation V1
        │
        └── Job A starts

New message arrives
        │
Ticket conversation V2
        │
        └── Job B starts

Job B finishes first → stores V2 analysis
Job A finishes later → stores older V1 analysis
</code></pre>
<p>The final ticket can now contain stale AI metadata.</p>
<p>This is not solved by generic worker concurrency configuration alone.</p>
<p>Before scaling the workflow, I would introduce some combination of:</p>
<ul>
<li>per-ticket serialization,</li>
<li>conversation version numbers,</li>
<li>compare-before-write logic,</li>
<li>coalescing pending analysis jobs,</li>
<li>stale-result rejection.</li>
</ul>
<p>The important principle is that <strong>global concurrency and entity-level ordering are different problems</strong>.</p>
<h3>4. Give the UI an Explicit AI-Result Delivery Mechanism</h3>
<p>Background processing creates another question:</p>
<blockquote>
<p>How does the browser learn that AI analysis has finished?</p>
</blockquote>
<p>The current architecture does not implement a dedicated real-time result-delivery channel.</p>
<p>At larger scale or with a richer UI, I would make that explicit.</p>
<p>Depending on the product requirements:</p>
<pre><code class="language-text">Simplest
Polling

        ↓

More event-driven
Server-Sent Events

        ↓

Bidirectional realtime needs
WebSockets
</code></pre>
<p>I would not automatically choose WebSockets.</p>
<p>If the browser only needs server-to-client status updates, SSE may be enough.</p>
<p>Architecture should follow the interaction requirement, not the popularity of the technology.</p>
<h3>5. Tighten Cache Freshness Rules</h3>
<p>Caching ticket data can reduce repeated reads, but mutable support conversations create invalidation challenges.</p>
<p>Every path that changes data relevant to a cached ticket needs a clear freshness strategy.</p>
<p>Before relying more heavily on caching, I would explicitly document:</p>
<ul>
<li>what is cached,</li>
<li>the cache key,</li>
<li>TTL,</li>
<li>which writes invalidate it,</li>
<li>whether stale reads are acceptable,</li>
<li>what happens when invalidation fails.</li>
</ul>
<p>A short TTL is useful, but TTL alone isn’t a complete consistency model.</p>
<h3>6. Improve the AI Context and Evaluation Layer</h3>
<p>The current AI pipeline is intentionally straightforward.</p>
<p>There are several improvements I would investigate before treating AI behavior as a mature subsystem:</p>
<ul>
<li>explicit structured outputs,</li>
<li>conversation-size/token management,</li>
<li>better context construction,</li>
<li>prompt/model version tracking,</li>
<li>evaluation datasets,</li>
<li>regression testing for AI behavior,</li>
<li>provider health telemetry,</li>
<li>clearer usage accounting,</li>
<li>privacy and retention policies for model inputs and outputs.</li>
</ul>
<p>I would add these based on actual product requirements rather than building an elaborate AI platform prematurely.</p>
<p>The important shift is from:</p>
<blockquote>
<p>Does the model return something?</p>
</blockquote>
<p>to:</p>
<blockquote>
<p>Can I measure whether this AI behavior remains useful and reliable as the application changes?</p>
</blockquote>
<hr />
<h2>What I Would Not Add Yet</h2>
<p>Scaling discussions can easily turn into architecture shopping lists.</p>
<p>I would <strong>not</strong> automatically add:</p>
<ul>
<li>Kafka,</li>
<li>Kubernetes,</li>
<li>multiple microservices,</li>
<li>vector databases,</li>
<li>WebSockets,</li>
<li>event sourcing,</li>
<li>multiple caches.</li>
</ul>
<p>None of those technologies is inherently an upgrade.</p>
<p>For this system, I would first strengthen the guarantees around the architecture that already exists:</p>
<pre><code class="language-text">DB/queue consistency
        ↓
Idempotent processing
        ↓
Per-ticket ordering/versioning
        ↓
Observable job state
        ↓
AI evaluation
        ↓
Scale infrastructure when evidence requires it
</code></pre>
<p>Complexity should be purchased with a requirement.</p>
<hr />
<h2>The Most Important Lesson: AI Is a Workload, Not the Architecture</h2>
<p>It is tempting to describe this application as an OpenAI integration.</p>
<p>That misses most of the engineering.</p>
<p>The LLM is one dependency inside a larger system.</p>
<p>The architecture still has to answer ordinary software-engineering questions:</p>
<ul>
<li>Who owns state?</li>
<li>Which operations block the user?</li>
<li>What should happen asynchronously?</li>
<li>Where is authorization enforced?</li>
<li>What happens after partial failure?</li>
<li>Are retries safe?</li>
<li>Can concurrent work overwrite newer state?</li>
<li>How does the UI learn that background work finished?</li>
<li>Where does human approval belong?</li>
</ul>
<p>Those questions existed before LLMs.</p>
<p>AI simply makes several of them more visible because model calls are external, comparatively slow, probabilistic and failure-prone.</p>
<hr />
<h2>Final Architecture</h2>
<p>The current design can be summarized as:</p>
<pre><code class="language-text">                           React + TypeScript
                                  │
                 ┌────────────────┼────────────────┐
                 │                │                │
            Auth Context     TanStack Query    Local UI State
                 │                │                │
                 └────────────────┼────────────────┘
                                  │
                                  ▼
                           Express REST API
                                  │
                    Auth / AuthZ / Validation
                                  │
                                  ▼
                       Application Services
                         │               │
                         │               │
                         ▼               ▼
                    PostgreSQL      Redis / BullMQ
                                         │
                                         ▼
                                     AI Worker
                                         │
                             ┌───────────┼───────────┐
                             │           │           │
                          Summary    Sentiment    Priority
                             │           │           │
                             └───────────┼───────────┘
                                         │
                                    AI Providers


Interactive reply path:

Agent → API → AI reply suggestion → editable composer → human sends
</code></pre>
<p>It is intentionally simpler than a large distributed system.</p>
<p>And it has known limitations.</p>
<p>I consider both of those facts healthy.</p>
<p>Good architecture is not about pretending the current system can handle every future requirement.</p>
<p>It is about knowing <strong>which guarantees the system currently provides, which ones it doesn’t, and where the next architectural pressure points will appear.</strong></p>
<hr />
<h2>Closing</h2>
<p>Building this project changed how I think about adding AI to existing applications.</p>
<p>I wouldn’t start by asking:</p>
<blockquote>
<p>Where can I call the LLM?</p>
</blockquote>
<p>I would start with:</p>
<blockquote>
<p>What role should AI have in this workflow?</p>
</blockquote>
<p>Then decide:</p>
<ul>
<li>synchronous or asynchronous,</li>
<li>advisory or autonomous,</li>
<li>user-facing or background,</li>
<li>retryable or idempotent,</li>
<li>cached or authoritative,</li>
<li>human-reviewed or automatically executed.</li>
</ul>
<p>Once those boundaries are clear, choosing the API is usually the easier part.</p>
<p>In the next article in this architecture series, I’ll go deeper into <strong>BullMQ background jobs for AI workloads—especially retries, idempotency and ordering</strong>, because adding a queue is only the beginning of making background processing reliable.</p>
<hr />
<h2>About the author</h2>
<p>I’m Ram Ji Tripathi, a senior frontend-focused full-stack engineer working across React, TypeScript, Node.js and AI-enabled applications.</p>
<p>I write about frontend architecture, backend systems, AI engineering, system design and the engineering decisions behind building real software.</p>
<p>More engineering notes and projects are available on <a href="https://ramjwork.in">my portfolio</a>.</p>
<hr />
<p>Originally published on my engineering blog:
<a href="https://blog.ramjwork.in/ai-engineering/ai-customer-support-architecture">https://blog.ramjwork.in/ai-engineering/ai-customer-support-architecture</a></p>
]]></content:encoded></item></channel></rss>