Design a customer support ticket dashboard
The agent workspace of a help desk: live queues of tickets, a ticket view with the whole conversation, and a keyboard-first workflow for agents who handle a hundred tickets a day. The hard parts are a list that updates in real time without moving under the cursor, two agents never answering the same customer, SLA timers that pause outside business hours, a reply box that can never leak an internal note, and an app that stays fast after ten hours open.
The question
Design the agent workspace for our customer support product. Agents work through queues of tickets, open a ticket to read the conversation with the customer, reply or leave an internal note, and move on. A busy agent closes 80 to 150 tickets a day, mostly with the keyboard.
Walk me through the queues and saved views, how new tickets and replies show up live, how you stop two agents from answering the same ticket, how SLA timers work, the reply box, bulk actions, and how the app stays fast when it is open all day.
Real-life examples: Zendesk Agent Workspace, Intercom Inbox, Freshdesk, Front, Help Scout, Linear triage
Requirements: what does an agent do all day?
A support ticket is one customer problem, such as "my order never arrived". It arrives by email, chat, a web form or social media and becomes a thread of messages between the customer and the company. A support agent answers it, and the agent workspace is the screen they live in: a list of tickets on the left, the open ticket on the right, and a reply box at the bottom.
For a support team this screen is the factory floor. One extra second per ticket costs an agent with 120 tickets two minutes a day, and a team of 400 agents more than 13 hours a day. If two agents answer the same customer, the customer gets two different answers and trusts neither. The design is judged by speed, correctness under concurrency, and comfort over a 9-hour shift.
Neighbouring guides cover parts of this, and we link to them instead of repeating them: Gmail (threaded lists, mail rendering), Slack (real-time messaging), the data table, the command palette (Cmd+K search), the rich text editor (the composer), the countdown timer (drift-free countdowns), the admin CMS (edit conflicts with versions) and the notifications center. This guide is about the agent workspace on top of them.
Questions to ask first
| Question | Answer we assume | What it changes |
|---|---|---|
| Who uses it, and how many at once? | Agents and team leads; up to 2,000 agents online in one large company, 50 in a small one | Presence and live counts must scale per company, not per ticket |
| How many tickets? | Up to 60,000 open tickets per company; a busy view holds 5,000 | Server-side filtering and cursor pagination; never load a whole queue |
| Who decides who gets which ticket? | Both: the server routes (round-robin or by skills), and agents can also pick from a shared queue | We need claiming with conflict handling, not just a list |
| Are there SLAs? | Yes: first response and resolution targets per priority, counted in business hours | Timers come from server data and a business calendar, not a simple countdown |
| Which channels? | Email and web form now; chat later | The timeline must hold many message kinds; chat adds sub-second typing |
| Do agents work from the keyboard? | Yes, power users rarely touch the mouse | A full shortcut layer and careful focus rules |
| How long is the app open? | All day, 8 to 10 hours, in one tab, often on a 1080p laptop screen | Memory, socket reconnects and event volume become real problems |
| Is there AI help? | Suggested replies, as a draft the agent must review | A hedged, clearly labelled suggestion; never auto-send |
Functional requirements
- Saved views (queues) such as "Unassigned, urgent" or "My open tickets", each a set of filters on status, priority, assignee, group, tags and SLA, with a live count next to each.
- A split view: the ticket list and the open ticket side by side, with the selection kept in the URL so it survives reload and can be shared.
- New tickets and replies appear live; agents see who else is viewing or replying to a ticket.
- Claim or assign a ticket, change its status, priority and tags.
- SLA timers for first response and resolution, shown as a countdown with urgency colors.
- A conversation timeline with customer messages, public replies, internal notes and system events, plus a customer sidebar (orders, previous tickets).
- Reply or add an internal note, with macros, attachments, drafts saved per ticket, send-and-close, and undo send.
- Bulk actions on many tickets at once, with a progress report.
- Keyboard shortcuts for everything common, listed with
?.
Non-functional requirements
- Fast switching: opening the next ticket in the list feels instant, under 100 ms when it is cached and under 400 ms cold on a normal office connection.
- Live but calm: a new event shows up within 1 to 2 seconds, but the list never moves the row the agent is about to click.
- Correct under concurrency: two agents never both own one ticket, and an agent is warned before replying to a ticket someone else is answering.
- All-day stable: memory stays flat (say under 300 MB for the tab) after 10 hours and 150 tickets; the socket recovers from sleep and network changes without losing events.
- Safe: an internal note can never be sent to a customer by accident; customer content is untrusted HTML and must be sanitized.
- Accessible: fully usable with keyboard and screen reader; live changes announced politely, timers never announced every second.
Out of scope
- The routing engine and SLA engine on the server: we design how the client talks to them, not their algorithms.
- The customer-facing side (the help widget and email the customer sees).
- Reporting dashboards for managers, and the admin screens that define SLA policies and business hours.
- The rich text editor internals and voice calls.
Check your understanding · Exercise 1
Which answers change the frontend design the most? Select all that apply.
What makes this hard
At first sight this is a CRUD app: a list, a detail page, a form. Five properties turn it into a hard problem, and every later chapter answers one of them.
- It is shared and live. Fifty agents watch the same queue while tickets arrive, customers reply and colleagues close things. The list is a moving target under a hand that is about to click a row.
- It is contended. Two agents can grab the same ticket within 200 ms of each other, or type replies to the same customer at once. A collision costs not a merge conflict but a confused customer with two answers.
- It is timed by rules, not by clocks. "Respond within 4 business hours" means the timer stops at 18:00 Friday and starts again at 09:00 Monday in the team's time zone, and the resolution clock also pauses while the team waits on the customer. A naive countdown is wrong most of the week.
- Mistakes are public and permanent. An email that went out cannot be recalled, and an internal note ("this customer is abusive, refund anyway") sent to the customer is a real incident. The reply box must make the safe action the easy one.
- It runs all day at speed. Agents keep one tab open for a whole shift and move through tickets with
j,kande. Every small delay is multiplied by thousands of actions, and every small leak grows for ten hours.
Each neighbour has only some of these properties. Gmail is personal, nobody owns a Slack message the way an agent owns a ticket, and a data table holds still while you read it. The support workspace has all five at once.
| Property | What goes wrong if ignored | Chapter that answers it |
|---|---|---|
| Shared and live | Rows jump while the agent clicks; counts lie | Queues and views; Live updates |
| Contended | Two replies to one customer; lost assignments | Assignment, presence and collisions |
| Timed by rules | SLA shows breached on Monday morning for a Friday-night ticket | SLA timers |
| Mistakes are public | Internal note emailed to the customer | Replying |
| All day at speed | Tab at 1.5 GB by 4 pm; shortcuts lose focus | Keyboard, focus and accessibility; Performance |
Think first
An interviewer says: "Isn't this just a list page and a detail page with polling every 30 seconds?" What breaks first if you build it that way?
Collisions break first: with 30-second polling, two agents can both see an unassigned ticket as free and both reply, many times a day on a busy queue. SLA urgency is up to 30 seconds stale, and the list jumps each time a new page replaces the old one. Polling is a fine fallback when the socket is down, but the design needs pushed events, claiming with a server check, and a list that applies changes without reshuffling.
Queues and views: which tickets, in which order
A view (Zendesk and Freshdesk call them views, Help Scout calls them folders) is a saved question: "open tickets, priority urgent or high, not assigned, in group Billing, sorted by SLA due time". The left rail lists 10 to 30 views, each with a count; views are stored on the server, some shared by the team and some personal.
The screen is a split view: views on the far left (about 220 px), then the ticket list (about 380 px), then the open ticket filling the rest. Each list row shows the requester, subject, a short preview, priority, SLA badge and assignee avatar. A customer sidebar opens on the right on screens 1440 px and wider; on a 1280 px laptop it folds into a tab.
The selection lives in the URL, for example /agent/views/urgent-unassigned/tickets/48213, so reload, back and a link pasted in team chat all land on the same place. The url-as-state lesson covers why.
Where does filtering happen?
The naive design loads all open tickets and filters in the browser. For 60,000 open tickets at about 1.5 KB each as JSON (the row plus the custom fields that filters need), that is 90 MB before compression, seconds of parsing, and a stale copy the moment it lands. The real choice is how the server pages the results.
Decision How does the client page through a view of 5,000 tickets that changes every few seconds?
What we pick: Cursor pages, 50 rows at a time, loaded as the agent scrolls near the end. Agents want the top of a queue, not "page 37", and cursors keep scrolling correct while live events add and remove rows. The pagination lesson goes deeper on keyset cursors.
Sorting by SLA due time has two traps. Tickets with no SLA (low priority, or already answered) have no due time, so the server puts them last by an explicit rule, not by accident of NULL ordering. And the due time can change: raising a ticket from normal to urgent moves it up, which is a live event like any other.
type ViewDefinition = {
id: string; // "urgent-unassigned"
name: string;
shared: boolean; // team view or personal
filters: {
status?: ("new" | "open" | "pending" | "on_hold" | "solved")[];
priority?: ("low" | "normal" | "high" | "urgent")[];
assignee?: "me" | "none" | string[];
groupIds?: string[];
tagsAll?: string[];
slaBreachWithinMinutes?: number; // "breaching in the next 60 min"
};
sort: { field: "sla_due_at" | "updated_at" | "created_at"; dir: "asc" | "desc" };
};
// GET /api/views/urgent-unassigned/tickets?limit=50&after=eyJkdWUiOi...
type TicketPage = {
rows: TicketRow[];
nextCursor: string | null; // opaque, base64 of (sla_due_at, id)
asOfSeq: number; // event sequence this page reflects
};
Notice asOfSeq. Every change on the server gets a number in one ordered stream per company, and a page says "I reflect everything up to event 918,204". When the socket later delivers event 918,203, the client skips it because the page already includes it; event 918,205 is new and gets applied. This one number lets a fetched page and a live stream fit together without gaps or double counting.
Live counts per view
Each view in the rail shows a count: "Urgent, unassigned: 12". Running 25 COUNT(*) queries per agent on every change is expensive, and polling them every 10 seconds for 2,000 agents is 5,000 count queries per second.
Instead the server keeps a counter per view, updates it as tickets change, and pushes a coalesced view.counts message at most every 2 seconds per company. The client just displays the latest number, and shows 999+ above 999, because nobody acts on the difference between 4,812 and 4,813.
asOfSeq), so the live stream can continue from there without gaps or repeats.Check your understanding · Exercise 2
An agent has scrolled to row 100 of a view sorted by SLA due time. Three new urgent tickets arrive at the top, then the agent scrolls further. With offset pagination, what happens? Select all that apply.
Live updates without moving the row under the cursor
Agents need to see new tickets and customer replies within a second or two, so the server pushes events over a WebSocket (the websockets lesson covers the protocol, heartbeats and reconnects). Each tab opens one socket and subscribes to what it shows: the company's view counts, the views in the list, and the open ticket. The server filters the company stream down to those topics, so a tab receives perhaps 1 to 5 events per second, not hundreds.
type ServerEvent =
| { seq: number; type: "ticket.created"; ticket: TicketRow }
| { seq: number; type: "ticket.updated"; id: string; patch: Partial<TicketRow>; version: number }
| { seq: number; type: "ticket.message"; ticketId: string; message: Message }
| { seq: number; type: "view.counts"; counts: Record<string, number> }
| { seq: number; type: "presence"; ticketId: string; agents: PresenceEntry[] };
// every message also carries n: a per-connection counter that rises by exactly 1
// client -> server, after connecting or reconnecting
type Subscribe = { type: "subscribe"; topics: string[]; lastSeq: number };
Every event carries two numbers. seq is its place in the company stream, the number pages report as asOfSeq. n counts the messages on this one connection: 1, 2, 3. Because the server filters by topic, seq jumps are normal, but an n jump means a message was lost, for example because the gateway dropped it under load or a pub/sub node failed over.
On an n gap the client does not guess: it refetches the first page of the current view and the counts, then continues. On reconnect it sends lastSeq as a resume cursor; the server replays what was missed if it still has it (say the last 5 minutes), or answers "too old, resync" after a laptop was closed overnight. The Slack guide uses the same cursor-and-replay pattern for channels.
The problem: a list that moves while you aim
The naive approach applies every event to the list at once: a new urgent ticket is inserted at the top, a ticket whose due time changed jumps to its new position, a closed ticket vanishes. Each one is correct. Together they are hostile.
The agent moves the mouse to row 4, a ticket arrives, row 4 becomes the old row 3, and the click opens the wrong ticket. With j and k it is worse: the agent presses j expecting the next ticket and lands on one they never saw. Cumulative Layout Shift, one of the Core Web Vitals, penalizes this kind of unexpected movement for the same reason.
Decision How should live events change a list the agent is working in?
What we pick: Stable ordering: content can change, position cannot, unless the agent asks. The pill applies the buffer and scrolls to the top; the buffer is also applied when the agent switches views or the list is empty, because then there is nothing to aim at.
Focus the list, then press j, k, a or e, or use the buttons. Press Next event to let the world change.
Rows that leave the view need care. If Ana closes ticket 48213 two rows below Ben's selection, removing it would shift Ben's targets, so the row stays, dimmed, with "Solved by Ana" in place of the SLA badge, and j skips over it. Dimmed rows are dropped when the pill is applied, the view reloads, or the selection has moved past them. If Ben has 48213 itself open, the ticket view shows a banner instead.
function applyEvent(list: ListState, e: ServerEvent, view: ViewDefinition): ListState {
if (e.seq <= list.lastSeq) return list; // already in our page
const next = { ...list, lastSeq: e.seq };
if (e.type === "ticket.created") {
return matches(e.ticket, view) ? { ...next, pending: [...next.pending, e.ticket.id] } : next;
}
if (e.type === "ticket.updated") {
const row = next.rows.get(e.id);
if (!row || e.version <= row.version) return next; // stale or unknown
const updated = { ...row, ...e.patch, version: e.version };
next.rows = new Map(next.rows).set(e.id, updated);
if (!matches(updated, view)) next.leaving = new Set(next.leaving).add(e.id);
else if (positionChanged(row, updated, view)) next.pending = [...next.pending, e.id];
}
return next;
}
The sync layer has already checked n before this function runs. version is per ticket and rises by one on each write, so a late or repeated event never overwrites newer data, whether it arrives by socket or by refetch. The claim and edit endpoints use the same version for conflicts.
n jumps, the sync layer stops guessing and refetches; events at or below the new page's asOfSeq are then dropped as duplicates.Event volume
A view of 5,000 tickets during an outage can receive 50 events per second, which rendered one by one would mean 50 React commits per second. So the sync layer collects events for one animation frame (about 16 ms), or up to 1 second when the tab is hidden, and applies each batch in one store update. When a bulk job finishes, its 200 ticket.updated events become a handful of store updates, each touching only the changed rows.
Check your understanding · Exercise 3
Ben's selection is on row 5 of "Urgent, unassigned". Which events should change what is at row 5 without Ben doing anything? Select all that apply.
Assignment, presence and collision avoidance
Who answers which ticket is decided in two ways. Routing happens on the server: a new ticket is pushed to an agent by round-robin, by load (the agent with the fewest open tickets), or by skills (Spanish speakers get Spanish tickets). The client's part is small: it shows the agent status (online, away, offline) that routing reads, and marks a routed ticket "assigned to you" the moment the event arrives.
Claiming a ticket
Picking happens in shared queues, where agents take tickets themselves, and that is where collisions happen. The naive claim is PATCH /tickets/48213 { assignee: "me" }. Ana and Ben both see 48213 as unassigned and press the claim key 150 ms apart; both requests succeed, and the second silently overwrites the first. This is the classic lost update, and the fix, as in the admin CMS guide, is to make the write conditional on what the client saw.
Decision How do we make sure exactly one agent wins a claim?
What we pick: Conditional claim, with an optimistic UI on top. The row and the header show "You" at once, so the agent can start reading and typing without waiting, and the request confirms or rolls back in the background. Hard locks are the wrong tool for something people abandon without warning; the soft lock below covers the part they were trying to solve.
On a 409, the client rolls back (the assignee shows Ana), says "Ana claimed this ticket a moment ago", and offers the next unassigned ticket with one key. It keeps any draft the agent typed. The optimistic-updates lesson covers rollback in general; the twist here is that rollback also changes what the agent should do next.
Presence: who is here, who is typing
Claiming solves ownership, but not the second collision: a lead and the owner both open the ticket, or a team shares it, and two people start replying. So the ticket header shows presence: small avatars of everyone viewing it, and a stronger line when someone is writing a public reply: "Ana is typing a reply". The presence-and-cursors lesson covers the general mechanics; the table below gives the numbers we pick.
Real help desks ship the same idea. Zendesk calls the problem agent collision and shows who is viewing or editing a ticket, and Freshdesk's version is called Agent Collision Detection. Help Scout's Collision Detection and Freshdesk's Traffic Cop go one step further: they stop a reply if the conversation changed while it was being written, which is the send-time check below.
| Signal | When the client sends it | When the server forgets it |
|---|---|---|
viewing | On opening a ticket, then a heartbeat every 20 s while the tab is visible | 45 s after the last heartbeat, or at once on leave or socket close |
typing | On keystrokes in the composer, at most once every 3 s | 6 s after the last one |
replying (soft lock) | When a public reply draft becomes non-empty | 90 s after the last keystroke, on send, or on discard |
The replying signal is a soft lock: a lock that warns but does not block. When Ben opens the composer where Ana holds it, he sees "Ana is replying (started 40 s ago)" and must confirm to continue, which he can, because Ana might have walked away. The typing signals double as the lock's heartbeat, and it expires after 90 seconds without one, so a closed laptop never leaves a ticket stuck. Internal notes take no lock at all: two notes never confuse a customer.
Presence is a hint; the last line of defence is a check at send time. The reply carries basedOnMessageId, the newest message the agent had seen, and if a public reply or customer message arrived after it, the server answers 409 with the new messages. The client shows them above the composer ("Ana replied 30 seconds ago") with "Send anyway" and "Edit first". Presence prevents most collisions; this check catches the rest, including those missed while a socket was down.
Check your understanding · Exercise 4
Which statements about this collision design are true? Select all that apply.
SLA timers that pause, resume and breach correctly
An SLA (service level agreement) is a promise about speed, set per priority. A typical policy: urgent tickets get a first reply within 1 business hour and are resolved within 8; normal tickets get a first reply within 8 business hours. The clocks count only business hours, for example Monday to Friday, 09:00 to 18:00 in Europe/Berlin, minus public holidays.
Help desks measure several clocks. First reply time stops at the first public reply, next reply time restarts each time the customer writes again, and a resolution clock runs until the ticket is solved and usually pauses while it is pending (waiting on the customer).
Whether on-hold (waiting on a third party) also pauses depends on the metric: Zendesk's requester wait time keeps running on hold while its agent work time pauses, and Freshdesk lets admins choose per status. We show two timers, first response and resolution; the design works the same for any of them.
The naive timer is createdAt + 4 hours, counted down in the browser. Take a high-priority ticket created Friday at 16:30 in Berlin with a 4-hour first-response target: the naive timer breaches at 20:30 Friday. In business time, 90 minutes count on Friday, the clock stops for the weekend, and the other 150 minutes run out Monday at 11:30. The naive badge is red all weekend, and agents learn to ignore red badges, the worst outcome a warning system can have.
Decision Who computes the SLA timer, and what does the client receive?
What we pick: The snapshot. The key observation: inside one open business period, business time and wall-clock time move at the same speed. So the client only needs to know how much business time was left at a moment, and when the current open period ends. The server, which owns the calendar, handles everything else.
type SlaSnapshot = {
metric: "first_response" | "resolution";
state: "running" | "paused" | "fulfilled";
pauseReason?: "outside_hours" | "waiting_on_customer" | "on_hold";
businessMsLeft: number; // may be negative: already breached
computedAt: number; // server epoch ms
runsUntil: number | null; // end of the current open period
dueAt: number; // wall-clock instant it breaches, for "Due Mon 11:30"
policyVersion: number;
};
function msLeft(s: SlaSnapshot, serverNow: number): number {
if (s.state !== "running") return s.businessMsLeft;
const end = s.runsUntil === null ? serverNow : Math.min(serverNow, s.runsUntil);
return s.businessMsLeft - Math.max(0, end - s.computedAt);
}
serverNow is Date.now() plus an offset measured when the socket connects: the server sends its time in the hello message, and the client keeps the offset from the round trip with the smallest delay. Without it, a laptop clock 3 minutes fast would show every ticket 3 minutes closer to breach (the countdown timer guide covers drift and throttled background tabs). When serverNow passes runsUntil, the badge turns grey, "Paused: outside business hours", until the server pushes the next snapshot at 09:00.
Time zones and daylight saving time stay on the server too. It walks the calendar in the team's IANA time zone, because a calendar computed with a fixed UTC offset puts every 09:00 an hour off after the clocks change. The snapshot carries only epoch milliseconds, which never jump, and the client formats dueAt with Intl.DateTimeFormat in the agent's own zone, which may differ from the team's.
A high-priority ticket arrives Friday 13 November at 15:30 UTC. First reply target: 4 business hours. Resolution: 16.
Urgency states and ticking
| State | Rule (example) | How it shows |
|---|---|---|
| On track | More than the warning threshold left | Neutral badge, "3h 10m" |
| Warning | 25% of the target or 1 hour left, whichever is less | Amber badge with a clock icon |
| Critical | Under 15 minutes | Red outline, "12m", sorted near the top by due time anyway |
| Breached | Below zero | Solid red with an alert icon, counts up: "Breached 8m ago" |
| Paused | Outside hours or waiting on the customer | Grey with a pause icon and the reason in the tooltip |
Color is never the only signal (WCAG 1.4.1, Use of Color): each state has an icon and words. Rows tick once a minute through one shared ticker aligned to the minute, and only the open ticket's header shows seconds, in the last 10 minutes. Fifty badges changing every second would serve no decision the agent makes; once a minute is what a human acts on.
Policies change: a lead shortens urgent first response from 1 hour to 30 minutes, or adds a holiday next Monday. The server recomputes the affected open tickets and pushes ticket.updated events with new snapshots and a higher policyVersion, which the client replaces whole. In a view sorted by due time, the resulting moves go into the pill buffer like any other.
Check your understanding · Exercise 5
A ticket's resolution timer has 2 business hours left at 17:00 Thursday. Business hours are 09:00 to 18:00. Which are true? Select all that apply.
The ticket view: timeline, context and attachments
Opening a ticket shows three things: a header (subject, requester, status, priority, assignee, tags, SLA badges, presence), the timeline (everything that happened, oldest first, newest at the bottom next to the composer), and the customer sidebar (who this person is, their orders, their previous tickets). The agent reads the newest message, glances at the sidebar and replies, so loading order matters.
One timeline, four kinds of entry
| Entry | Who sees it | How it looks |
|---|---|---|
| Customer message | Everyone | Left aligned, customer avatar, channel icon (email, form, chat) |
| Public reply | Agents and the customer | Agent avatar, "Replied by email" |
| Internal note | Agents only | Tinted background, a lock icon and the words "Internal note", indented |
| System event | Agents only | One small grey line: "Ana changed priority to urgent, 10:42" |
Internal notes get three independent signals (tint, icon, text), because an agent skimming fast must never mistake a note for something the customer saw, and color alone fails for color-blind agents and in high contrast mode. The server is the real guard: notes are a different message type that the email sender never reads.
Long threads are common: a billing dispute can reach 80 entries, and email replies drag along the whole quoted history. The client loads the first message and the newest 20, collapses the middle into "59 earlier messages", folds quoted email text behind a "..." toggle, and groups runs of system events ("5 changes by Ana"). This keeps a typical ticket under 30 rendered entries.
Loading the ticket and its context
The sidebar pulls from places that are slower and less reliable than the ticket store: the customer database, an order system (often a third-party integration like Shopify), and a search for previous tickets. If the client waits for all of them before showing anything, the slowest integration (say 1.5 s at the 95th percentile) sets the speed of every ticket.
Decision How should the ticket view load its data?
What we pick: Ticket first, context in parallel. The conversation is what the agent reads first, and the order card can arrive 800 ms later without anyone noticing. Each request uses fetch with an AbortController, aborted when the selection moves, so pressing j five times quickly does not leave 20 requests racing to update the screen.
To meet the 100 ms switching target, the client prefetches the next ticket in the list (the one j will open) once the current one has loaded, and keeps the last 20 opened tickets in its cache. One ahead costs about 30 KB; a whole page would load 50 tickets the agent never opens, multiplied across 2,000 agents.
Attachments show as chips with name, type and size; images get small thumbnails loaded lazily. Downloads go through short-lived signed URLs (5 minutes), and files the server's virus scan has not cleared yet show as "Scanning" and cannot be opened. HTML and SVG attachments are downloaded, never rendered inline in the app's own origin, because they can run script.
Check your understanding · Exercise 6
The orders integration times out on 2% of requests. With the chosen design, what does the agent see on those tickets? Select all that apply.
Replying: modes, macros, AI drafts and undo send
The composer is a rich text editor (the rich text editor guide covers the document model, paste handling and mentions). This chapter covers what a support composer adds on top.
The guard between a note and a reply
Agents write two kinds of text in the same box: public replies to the customer and internal notes for colleagues. Sending a note publicly is the most damaging mistake this app allows: "Customer is lying about the delivery, approve anyway" in a customer's inbox is a screenshot on social media by lunch. The mode must be impossible to miss and the wrong send hard to do.
Decision How do we stop an internal note from being sent to the customer?
What we pick: Two clearly different modes plus smart checks. The button label is the strongest signal, because it is where the eye and the finger land at the moment of sending. Confirmations that fire every time stop working, so the warning fires only when something looks wrong, a few times a week at most.
Macros and placeholders
A macro (canned response) is a saved reply plus actions: text such as "Hi {{requester.first_name}}, your refund for order {{order.number}} is on its way", and actions such as "set status to pending, add tag refund". Agents apply them with m, which opens a searchable list (the command palette guide covers the search UI).
The client fills the placeholders it knows for the preview and highlights any it cannot resolve, for example when the ticket has no order. A public reply with an unresolved {{order.number}} cannot be sent, because the customer would see the raw placeholder. The server renders the final text again on send, so the preview and the email always agree.
AI-suggested replies
Most help desks now offer a suggested reply generated from the conversation and the help center articles. The suggestion appears in a separate card above the composer, labelled as AI-generated, streamed so it starts showing within a second or two, with links to the articles it used. It enters the composer only when the agent presses "Use draft", and it is never sent automatically from the agent view.
Suggestions can be confidently wrong about refund rules or dates, so the card says "Check before sending". Logging whether agents used, edited or discarded each suggestion gives the team its best quality signal.
Drafts that survive everything
Agents switch tickets mid-sentence all the time, so drafts are saved per ticket and per mode. Every change is written to IndexedDB at once (cheap, local, survives a crash or reload) and synced to the server 2 seconds after typing stops, so a draft follows the agent to another device and powers the replying presence signal. Opening a ticket restores its draft and mode; sending deletes it in both places.
Send, send-and-close, undo
The most used action is send and close: reply, mark the ticket solved and move to the next ticket with one keystroke (Cmd+Enter sends; adding Shift also closes, in our design). It is a single request, so the server never records the reply without the status change or the other way round. Undo send gives a 10-second window, like Gmail's undo send (which offers 5, 10, 20 or 30 seconds).
Where should the delay live? If the client held the message for 10 seconds before posting, closing the tab in that window would lose a reply the agent believed was sent. So the client posts at once with sendAfter set 10 seconds ahead; the server stores it as scheduled and the email worker waits.
Undo is DELETE /messages/:id/schedule, which succeeds only before dispatch. The timeline shows "Sending in 8 s, Undo", and after send-and-close the agent moves to the next ticket at once while the Undo toast follows them there.
sendAfter), so closing the tab during it neither loses the reply nor sends it early. Every path keeps the agent's words.Check your understanding · Exercise 7
Which design choices actually reduce the chance of a note reaching a customer? Select all that apply.
Bulk actions with partial failures
Some work comes in waves: a shipping outage creates 400 near-identical tickets, and a lead wants to tag them outage-oct, apply a macro that explains the delay, and set them pending. One ticket at a time takes an hour; a bulk action takes a minute but raises three questions: selecting tickets that are not all loaded, sending the change, and reporting partial failure.
Selecting across pages
Agents select with checkboxes, x on the focused row, and Shift+click for a range. Once a whole page is selected, a bar offers "Select all 412 tickets in this view", the pattern Gmail made familiar. Only 50 rows are loaded, so "all 412" cannot be a list of ids in the browser. The selection is stored as one of two shapes:
type Selection =
| { kind: "ids"; ids: Set<string> } // picked by hand, up to 500
| { kind: "query"; viewId: string; filters: ViewDefinition["filters"];
asOfSeq: number; excluded: Set<string> }; // "all in view" minus unchecked rows
Decision How does the client send a change for 412 tickets?
What we pick: Asynchronous jobs for everything above a handful of tickets; a 3-ticket job finishes in under a second and looks synchronous, so the client needs no second path. The idempotency key (a UUID made when the agent presses Apply) means a retry after a dropped response never closes tickets or sends a macro reply twice.
Reporting partial failure
In a batch of 412, some will fail, and that is normal: a ticket was merged a minute ago, another was reassigned to a team the agent cannot edit, another has a required field ("product area") empty, so it cannot be solved. The worst UI is a single "Something went wrong" with no idea what happened to the other 400. The job reports per ticket, and the summary groups by reason:
- "Updated 405 of 412 tickets."
- "4 not updated: product area is required to solve." with "Select these 4" so the agent can fix and retry just them.
- "2 skipped: no longer match the view (already solved)." for query selections, because the server re-checks the filter at run time instead of trusting a stale list.
- "1 not updated: you don't have permission."
While the job runs, the list stays usable. Affected rows show a small busy mark and update in place as ticket.updated events arrive, so the stable-ordering rules from earlier apply. A slim progress bar sits above the list ("Updating 230 of 412"), not a modal. If the agent closes the tab, the result appears in their notifications later (the notifications center guide covers that inbox).
Undo needs care at this scale. The job records each ticket's previous values, and the summary offers Undo for a few minutes; undo is itself a job that restores those values only where the ticket's version has not moved since, and lists the rest as changed by someone else. Public replies from a bulk macro use the same server-side sendAfter delay as single replies, so undo stops those emails only inside that window.
Check your understanding · Exercise 8
The agent presses Apply on a 412-ticket job, the network drops before the 202 response, and the client retries. Which are true? Select all that apply.
Keyboard, focus and accessibility
Fast agents barely use the mouse: reaching for it costs roughly a second, and at 120 tickets with 5 actions each that is about 10 minutes a day. The workspace borrows the single-key style of Gmail and Linear: one key per common action, active whenever the agent is not typing in a text field.
| Key | Action | Notes |
|---|---|---|
j / k | Next / previous ticket | Opens it in the detail pane; skips dimmed rows |
r / n | Reply / internal note | Opens the composer in that mode and focuses it |
a | Claim (assign to me) | Optimistic, with the conflict path |
e | Solve and move on | The next ticket opens |
x | Select for bulk actions | Shift+x selects a range |
m | Apply a macro | Opens a searchable list |
Cmd+Enter | Send (in composer) | Shift adds close |
Esc | Leave composer, back to list | Draft is kept |
? | Show all shortcuts | Grouped by where they work |
Cmd+K | Command palette | Every action, searchable, with its shortcut shown |
One listener on document handles single-key shortcuts. It ignores events when the target is an input, a textarea or anything contenteditable, when event.isComposing is true (Japanese or Chinese input is mid-word), and when Ctrl, Alt or Meta is held, unless the shortcut asks for them. It matches on event.key, not the deprecated keyCode. The keyboard-navigation lesson covers the general rules.
Discoverability turns shortcuts into habits. The ? overlay lists them by context, every button's tooltip shows its key ("Solve (e)"), and the command palette shows the shortcut next to each action, so an agent who searches "solve" three times learns e.
The current ticket is state, not DOM focus
A subtle choice: is the open ticket the one whose list row has DOM focus? If so, every j moves focus in the list, and reading the ticket with a screen reader means moving focus away from the list, which breaks j. Our design keeps the current ticket as app state (in the URL) and makes j, k and e global.
DOM focus stays where the agent put it: on a list row, in the timeline, or in the composer. The list marks the current row with aria-current="true" and scrolls it into view. Focus moves only when the agent asks (r focuses the composer, Esc returns to the current list row) or when the focused element disappears.
When the selected ticket disappears
Pressing e solves the ticket, and in a view of open tickets it no longer belongs; the same happens when a colleague claims the ticket the agent has open in "Unassigned". If nothing is decided, focus falls to document.body and the next j starts from the top, or from a ticket the agent already handled.
The rule, like Gmail's auto-advance setting: move to the ticket that was below it in the displayed order, or up if it was the last one. If the view is now empty, show the empty state and focus its heading. Either way the live region says where the agent landed ("Solved. Now on: Refund not received"); the focus-management lesson covers moving focus without losing the screen reader's place.
function nextAfterRemoval(order: string[], removedId: string, skip: Set<string>): string | null {
const i = order.indexOf(removedId);
if (i === -1) return null;
for (let j = i + 1; j < order.length; j++) if (!skip.has(order[j])) return order[j];
for (let j = i - 1; j >= 0; j--) if (!skip.has(order[j])) return order[j];
return null; // view is empty: focus the empty-state heading
}
order is the order on screen, not the server's latest order. With stable ordering those can differ, and the agent's expectation is about what they see: "the one below". skip holds dimmed rows and tickets currently claimed by others.
Check your understanding · Exercise 9
The agent is on ticket C in the visible order A, B, C, D, E, and D is dimmed because Ana just solved it. The agent presses e. Which are true? Select all that apply.
Accessibility: a split view that talks at the right moments
Support teams employ blind and low-vision agents, and many companies hold internal tools to WCAG 2.2 AA. A live, dense screen is either excellent with a screen reader or unusable because it never stops talking. Screen readers in browse mode also claim single letters for their own navigation (h jumps to the next heading in NVDA and JAWS), so these agents often reach actions through the command palette.
Structure and landmarks
navlabelled "Views" for the rail; each view is a link with its count in the name: "Urgent, unassigned, 12 tickets", witharia-current="page"on the active one.- A
sectionlabelled by the view heading for the list, andmainfor the open ticket, whoseh1is the subject. Screen reader users jump between them with landmark keys; everyone else gets a skip link and theEscandrshortcuts. - An
asidelabelled "Customer" for the sidebar, with oneh2per card. - Each list row is one link whose accessible name reads like a sentence: "Maria Lopez, Refund not received, urgent, first reply due in 12 minutes, unassigned". Checkboxes for bulk selection are separate controls with their own labels.
- The composer's label changes with its mode: "Public reply to Maria Lopez" or "Internal note, not visible to the customer", and the mode switch is a pair of buttons with
aria-pressed.
Announcing live changes politely
The naive approach puts aria-live on the list, so every inserted row, count change and SLA tick is read aloud. In a busy queue that is a voice talking over the agent all day. Instead the app has one polite live region (role="status"), a single message queue in front of it, and strict rules about what earns a sentence:
| Change | Announced? | How |
|---|---|---|
| New tickets in the current view | Yes, coalesced | "3 new tickets in Urgent, unassigned", at most once every 15 s |
| Count changes in other views | No | Visible in the rail; read when the agent visits it |
| Customer replied on the open ticket | Yes | "New message from Maria Lopez" |
| Someone starts replying to the open ticket | Yes, once | "Ana is replying", not on every typing signal |
| SLA countdown ticks | Never | Text in the badge, read when focused |
| Open ticket's SLA becomes critical | Yes, once | "First reply due in 15 minutes" |
| Claim failed or bulk job done | Yes | The same text as the visible message |
ARIA's timer role has an implicit aria-live of off, the right default for something that changes every second. List badges also change their text only once a minute, so a screen reader user resting on one is not bombarded.
The macro list and the shortcut overlay are dialogs that trap focus and return it to where it was on close. New-row highlights and the pill's slide-in are skipped under prefers-reduced-motion: reduce. At 200% zoom or below about 900 px wide, the split view becomes one pane at a time with a back button.
Check your understanding · Exercise 10
Which announcements fit this design? Select all that apply.
Architecture, data model and API
The app is a client-rendered single-page app: it sits behind a login, needs no SEO, and lives in one long session, so server rendering buys little. The first load (about 250 KB of compressed JavaScript for the shell, list and ticket view, with the editor and reports in separate chunks) happens once a day; after that, everything is about fast updates.
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Router and URL | Holds view id, ticket id and list filters | Reload, back and shared links restore the exact place |
| Views rail | Lists views with live counts | Reads counts only, so count updates re-render nothing else |
| List controller | Visible order, pending buffer, dimmed rows, selection | Stable ordering is a view concern, not a data concern |
| Ticket list (virtualized) | Renders about 20 visible rows | Pure rendering from the controller's order and the store |
| Ticket view | Header, timeline, sidebar cards, composer | Each card loads and fails on its own |
| Entity store | One normalized record per ticket, keyed by id, with version | The same ticket shows in a row, the header and the bulk bar; one event updates all three |
| Server cache | Pages, ticket details, customer data; request dedupe and cancellation | Owns fetching, caching and refetch on reconnect |
| Sync layer | One WebSocket, subscriptions, n gap check, batching per frame, presence heartbeats | Every event enters the app through one door, in order |
| Clock service | Server time offset and one shared ticker for all timers | No timer per row, no drift between rows |
| Draft store | Drafts in IndexedDB, synced to the server | Survives reload, crash and device change |
| Shortcut manager | One document listener, context rules, the ? overlay | Shortcuts never fire inside text fields, and can be turned off in one place |
| Job tracker | Bulk jobs and their progress | Jobs outlive the screen that started them |
The most important boundary is between the entity store and the list controller. The store always holds the latest truth for each ticket, so when an event says 48213 is now assigned to Ana, the store changes at once. The controller holds the order the agent sees and the buffer behind the pill. A row reads its fields from the store and its position from the controller, so it can show "Assigned to Ana" without moving; mixing the two makes lists either stale or jumpy.
The second boundary is the sync layer, the one door for events. Components never open sockets; the sync layer applies the n, seq and batching rules and writes to the store. On reconnect it asks the server cache to refetch the current view's first page, the counts and the open ticket, so reconnecting is one function, not a property of every component.
Agents often keep two or three tabs open. Each tab can keep its own socket, but presence must not double count, so each tab sends a tab id and the server merges them per agent. Drafts live in IndexedDB, which all tabs share, and a BroadcastChannel message tells other tabs a draft changed, so one ticket open in two tabs never shows two different drafts.
Check your understanding · Exercise 11
A ticket.updated event says ticket 48213 changed priority from normal to urgent, which moves it up in a view sorted by due time. Which pieces change right away? Select all that apply.
Data model: what lives where
For each entity, ask who owns the truth (almost always the server), where the client keeps its copy, and how that copy stays fresh.
type TicketRow = {
id: string;
version: number; // +1 on every write; guards stale events and claims
subject: string;
preview: string; // first 140 chars of the newest message, plain text
requester: { id: string; name: string };
status: "new" | "open" | "pending" | "on_hold" | "solved";
priority: "low" | "normal" | "high" | "urgent";
assigneeId: string | null;
groupId: string;
tags: string[];
sla: SlaSnapshot[]; // first_response and resolution
updatedAt: number;
unreadByMe: boolean;
};
type Message = {
id: string;
ticketId: string;
kind: "customer" | "public_reply" | "internal_note" | "system";
authorId: string;
html: string; // already sanitized by the server
attachments: { id: string; name: string; bytes: number; scan: "pending" | "clean" | "blocked" }[];
createdAt: number;
scheduledSendAt?: number; // undo window for public replies
};
| Entity | Lives in | Kept fresh by | Size |
|---|---|---|---|
| View definitions | Server cache | Loaded at start; view.changed events | About 30 per agent, under 10 KB |
| View counts | Small store slice | view.counts every 2 s at most | A number per view |
| Ticket rows | Normalized entity store | Pages plus ticket.updated events, checked by version | About 600 bytes each; 50 to 500 loaded |
| List order and buffer | List controller | Local rules (stable ordering) | Ids only |
| Ticket detail and timeline | Server cache, last 20 tickets | Fetch on open; ticket.message events | About 30 KB per ticket |
| Customer context | Server cache, 5-minute freshness | Fetch on open, in parallel | 5 to 20 KB |
| Presence | Small store slice, open ticket only | Socket presence events with expiry | A few entries |
| Drafts | IndexedDB, synced to server | Saved on change, synced after 2 s idle | Text plus attachment ids |
| Current view, ticket, filters | URL | Navigation | A path and a query string |
| Bulk selection, composer mode, open menus | Component or UI store | User actions | Tiny |
Why normalize ticket rows? Ticket 48213 can appear at once in two view pages, the open ticket header and the bulk bar. With a copy in each, one event must patch four places and a forgotten copy shows two assignees; with one record per id, pages store only ids.
The detail cache is capped at 20 tickets (about 30 KB each) and evicts the least recently used one together with its subscription. An agent opens 150 a day, and twenty covers the "go back to the ticket I just saw" habit.
Check your understanding · Exercise 12
Which pieces of state belong in the URL? Select all that apply.
Interface: the API between the workspace and the server
The API is plain REST over HTTPS for reads and writes, plus one WebSocket for pushed events and presence. GraphQL would fit the nested ticket view too, but the decisions that matter here (versions, cursors, idempotency keys, conflict answers) are the same either way.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/views | View definitions for this agent |
| GET | /api/views/:id/tickets?limit=50&after=... | A page of rows, with nextCursor and asOfSeq |
| GET | /api/tickets/:id | Header, the first message and the newest 20 timeline entries |
| GET | /api/tickets/:id/messages?before=... | Older timeline entries |
| GET | /api/customers/:id, /orders, /tickets | Sidebar cards, each separate |
| POST | /api/tickets/:id/claim | Conditional claim with expectedVersion; a lost race gets 409 |
| PATCH | /api/tickets/:id | Status, priority, tags, with If-Match; a stale version gets 412 |
| POST | /api/tickets/:id/messages | Reply or note, with basedOnMessageId and sendAfter |
| DELETE | /api/messages/:id/schedule | Undo send, only before dispatch |
| PUT | /api/tickets/:id/draft | Sync the draft |
| POST | /api/bulk-jobs | Start a bulk job, with an Idempotency-Key header |
| WS | /ws | Subscribe, events, presence, heartbeats |
POST /api/tickets/48213/messages
Idempotency-Key: 6f1c2e0a-0b7e-4c1f-9a51-1d2f3e4a5b6c
{ "kind": "public_reply", "html": "<p>Hi Maria, ...</p>",
"basedOnMessageId": "m_9912", "sendAfter": 10,
"then": { "status": "solved" } }
201 Created
{ "message": { "id": "m_9920", "scheduledSendAt": 1792583330000 },
"ticket": { "id": "48213", "version": 18, "status": "solved" } }
409 Conflict
{ "error": "newer_messages",
"newMessages": [{ "id": "m_9915", "kind": "public_reply", "authorName": "Ana" }] }
Errors use one shape: a stable machine code (newer_messages, already_claimed, version_conflict, field_required, forbidden) plus details the UI can act on, never shown as raw text. A lost claim is 409 Conflict with already_claimed: the client rolls back and offers the next ticket. A reply that missed new messages is also a 409, with newer_messages.
A field edit with a stale If-Match gets the standard 412 Precondition Failed (as in the admin CMS guide), and the client refetches and reapplies the agent's change if it does not overlap. The split follows HTTP semantics: 412 means a condition in a request header failed, while a version or rule checked in the body is a conflict with the resource's current state, which is 409.
// Presence messages on the socket
type PresenceOut =
| { type: "presence.view"; ticketId: string; tabId: string } // on open + every 20 s
| { type: "presence.typing"; ticketId: string; mode: "reply" | "note" } // at most every 3 s
| { type: "presence.leave"; ticketId: string; tabId: string };
type PresenceEntry = {
agentId: string;
name: string;
state: "viewing" | "typing" | "replying";
since: number; // server time
expiresAt: number;
};
The main components have small, explicit APIs. TicketList takes the ordered ids, the current id and the pending count, and emits onOpen(id), onToggleSelect(id, range) and onApplyPending(); it knows nothing about sockets. Composer takes ticketId, mode and the draft and emits onSend({ html, close }), and SlaBadge takes a snapshot and a tick source. Each can be tested with plain data.
Check your understanding · Exercise 13
Which request fields protect against a real-world failure? Select all that apply.
Performance for an app that is open all day
Here the first load happens once a morning; what matters is hour nine. We track the time to switch tickets (under 100 ms from cache, measured with performance.mark around the switch), Interaction to Next Paint for keys and clicks (under 200 ms at the 75th percentile, the Core Web Vitals "good" line), and tab memory over a shift.
Memory over ten hours
A leak of 1 MB per ticket opened is invisible in a demo and is 150 MB by evening. The usual sources in this app are easy to name: a socket subscription for each opened ticket that is never removed, keydown listeners added by each composer and never cleaned up, timers per SLA badge, editor instances that keep detached DOM alive, and caches that only grow. The memory-leaks lesson covers how to find them with heap snapshots.
Decision How do we keep memory flat across a full shift?
What we pick: Bounded by design and tested. The nightly long-session test is the piece most teams skip and most need: leaks are found by running the app the way agents use it, not by reading code.
useEffect(() => {
const ac = new AbortController();
const unsubscribe = sync.subscribe(`ticket:${ticketId}`);
document.addEventListener("visibilitychange", onVisibility, { signal: ac.signal });
const stopTick = clock.onMinute(updateBadges); // shared ticker, not setInterval
return () => {
ac.abort(); // removes every listener registered with this signal
unsubscribe();
stopTick();
};
}, [ticketId]);
Long lists and many events
The list may hold 500 loaded rows after some scrolling, but with virtualization only about 20 visible rows plus a few above and below are in the DOM (the virtualization lesson covers measuring and overscan). Rows have a fixed height (72 px), which keeps the math trivial. Each row subscribes only to its own ticket in the store, so an event for 48213 re-renders one row, not 20.
New releases in the middle of a shift
Teams ship several times a day while the agent's tab still runs the morning's code. When that tab later fetches a code-split chunk, the old file name may be gone from the server, and a lazy-loaded editor fails to load. So old chunks stay on the CDN for at least a day.
The socket hello message also carries the current build id. When it differs, the app shows a quiet "New version available" and reloads by itself at a safe moment: no draft open, no bulk job running, the agent idle for a minute.
Check your understanding · Exercise 14
After four hours, an agent's tab uses 1.2 GB. Which causes are likely in this app? Select all that apply.
Running it in the interview
Sixty minutes is not enough to say everything in this guide. The interviewer wants to see you find the hard parts quickly, make clear decisions with reasons, and go deep on two or three.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions | Shared queues or routing only? SLAs in business hours? How many agents and tickets? How long is the app open? |
| 6 to 10 | Name what makes it hard | Shared and live, contended, timed by rules, mistakes are public, all day at speed |
| 10 to 18 | Wireframe and architecture | The split view; entity store vs list controller; one sync layer |
| 18 to 40 | Two or three deep dives | Pick from live list stability, claiming and collisions, SLA snapshots, the note guard |
| 40 to 50 | API and data model | Cursor pages with asOfSeq, version, basedOnMessageId, idempotency keys |
| 50 to 60 | Performance, accessibility, follow-ups | Memory over a shift, batching, polite announcements |
Draw the wireframe first, because every later idea points at a part of it: the pill above the list, presence in the ticket header, the SLA badge on the row. Then draw the architecture with its two doors to the server (requests and events) and the one entity store. When the interviewer asks "what happens when...", trace the answer through that drawing out loud.
Follow-up questions to expect
Think first
The WebSocket is blocked by a corporate proxy for some customers. What happens?
The sync layer falls back to Server-Sent Events or long polling over plain HTTPS, with the same message format and the same seq and n checks, so nothing above it changes. Presence heartbeats go over HTTP POST, and latency rises to perhaps 1 to 3 seconds, which is acceptable. The send-time basedOnMessageId check still prevents double replies, which is why correctness never depends on presence alone.
Think first
A company grows to 10,000 agents. What changes on the client?
Very little, by design: the client pages with cursors, subscribes only to its views and its open ticket, and receives coalesced counts. The pressure is on the server's fan-out and count maintenance. One client-visible change is worth making: views shared by thousands of agents get count updates less often, every 5 to 10 seconds instead of 2.
Think first
Product wants live chat in the same workspace. What breaks?
Chat needs replies in seconds, so SLA timers in minutes and seconds, customer typing indicators, and a composer where Enter sends. Agents handle several chats at once, so the split view needs tabs or a stack of active conversations with audible notifications. The data model mostly holds, but presence and the timeline need sub-second updates; the Slack guide covers the messaging side.
Think first
A team lead reassigns 30 tickets from Ben to Ana while Ben is replying to one of them. What should Ben see?
Ben's rows for the 29 others dim with "Reassigned to Ana" in place, without moving. The open ticket shows a banner, "This ticket was reassigned to Ana", and keeps his draft. If he sends anyway, the server accepts or rejects by policy, and the send-time check shows him if Ana already replied. Ana sees "Ben is replying", so nothing is silently lost on either side.
Check your understanding · Exercise 15
You have 20 minutes for deep dives. Which choices make the strongest use of them? Select all that apply.
Summary
- The agent workspace is shared, live, contended, timed by business rules and used all day at speed. Each property drives one part of the design.
- Page views on the server with keyset cursors and an
asOfSeq, so fetched pages and the live event stream fit together with no gaps or repeats. - Stable ordering: content changes in place, position changes only when the agent asks. Keep the entity store (truth) separate from the list controller (order).
- Exactly one owner per ticket comes from a conditional claim checked atomically on the server, with optimistic UI and a rollback that offers the next ticket.
- Presence and soft locks with expiry prevent most double replies; the send-time
basedOnMessageIdcheck catches the rest. - SLA timers are server snapshots (business time left, when the clock stops) ticked by the client with a server-corrected clock: no calendar code in the browser.
- Make the note-versus-reply mode impossible to miss, warn only in risky cases, and back it with a server-side undo window.
- Bulk actions are idempotent background jobs with per-ticket results grouped by reason, and an undo that is itself a version-checked job.
- Shortcuts act on the current ticket as app state; after a ticket leaves the view, move to the next one in the order the agent sees.
- Design for hour nine: bounded caches, cleaned-up subscriptions, batched events, a long-session leak test, and polite, coalesced announcements.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: The WebSocket API The browser API behind the sync layer.
- MDN: AbortController Cancelling fetches when the agent moves on, and removing listeners in one call.
- web.dev: Cumulative Layout Shift (CLS) Why content that moves under the pointer is measured as a problem.
- web.dev: Interaction to Next Paint (INP) The responsiveness metric for keys and clicks.
- IETF draft: The Idempotency-Key HTTP Header Field The contract behind safe retries of replies and bulk jobs.
- WCAG 2.2 Understanding 2.1.4: Character Key Shortcuts Why single-key shortcuts need an off switch.
- WCAG 2.2 Understanding 4.1.3: Status Messages Announcing changes without moving focus.
- WAI-ARIA 1.2: the timer role Its implicit live setting is off.
- RFC 9110: HTTP Semantics, 409 Conflict and 412 Precondition Failed Which status a lost claim and a stale If-Match should get.
- Zendesk developer docs: Ticket metric event types Which SLA metrics pause on pending and on-hold.
- Help Scout: Prevent duplicate replies with Collision Detection Presence indicators plus a send-time check in a shipping product.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.