cf.completefrontend
My progress
SYSTEM DESIGN · #117

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

QuestionAnswer we assumeWhat 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 onePresence 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,000Server-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 queueWe need claiming with conflict handling, not just a list
Are there SLAs?Yes: first response and resolution targets per priority, counted in business hoursTimers come from server data and a business calendar, not a simple countdown
Which channels?Email and web form now; chat laterThe timeline must hold many message kinds; chat adds sub-second typing
Do agents work from the keyboard?Yes, power users rarely touch the mouseA 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 screenMemory, socket reconnects and event volume become real problems
Is there AI help?Suggested replies, as a draft the agent must reviewA 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. It runs all day at speed. Agents keep one tab open for a whole shift and move through tickets with j, k and e. 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.

PropertyWhat goes wrong if ignoredChapter that answers it
Shared and liveRows jump while the agent clicks; counts lieQueues and views; Live updates
ContendedTwo replies to one customer; lost assignmentsAssignment, presence and collisions
Timed by rulesSLA shows breached on Monday morning for a Friday-night ticketSLA timers
Mistakes are publicInternal note emailed to the customerReplying
All day at speedTab at 1.5 GB by 4 pm; shortcuts lose focusKeyboard, 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?

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.

Wireframe of the agent workspace in three columns. Left, the views rail: Urgent 12 (selected), Unassigned 48, Mine 7, SLA soon 3. Middle, the ticket list with a 3 new tickets pill on top and four rows: Maria, refund (selected), Leo, 2FA reset, Kim, invoice (dimmed, solved by Ana), and Raj, shipping. Right, the open ticket: a header Refund not received, urgent, first reply in 12 minutes; a customer message; a hatched internal note; a system event; and the composer in public reply mode with a Send to Maria button. Ana is viewing.Agent workspaceViewsUrgent 12Unassigned 48Mine 7SLA soon 33 new ticketsMaria, refundLeo, 2FA resetKim: solved by AnaRaj, shippingRefund not receivedurgent, first reply in 12mMaria: where is it?Internal note: VIPAna set priority urgentPublic replybutton: Send to MariaAna is viewingviews, countsstable listticket, composer
Three panes, three jobs: choose a queue, pick a ticket, work it. The pill and the dimmed row are how the list stays still while events arrive; on screens 1440 px and wider a customer sidebar opens on the right.

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?

Load everything, filter on the client

Fetch all open tickets once, keep them in memory, run the view's filters and sort locally.

Pros

  • Instant view switching and re-sorting
  • Simple server

Cons

  • Megabytes of data and seconds of parsing for big teams
  • Permissions and filters duplicated in two places
  • Memory grows all day

Server filtering, offset pages

GET /views/:id/tickets?offset=50&limit=50; the server runs the query and skips rows.

Pros

  • Easy to build; page numbers are possible

Cons

  • A new ticket at the top pushes every row down one, so page 2 repeats a row; a closed ticket pulls rows up, so one is skipped
  • Deep offsets get slow in the database

Server filtering, cursor (keyset) pages

The sort key of the last row becomes the cursor: after=(sla_due_at, id). The server asks for rows strictly after it.

Pros

  • No duplicates or gaps when rows are added or removed above
  • Same speed at row 50 and row 4,950

Cons

  • No jumping to "page 37"
  • The sort key must be unique, so we add the ticket id as a tie-breaker

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.

ts
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.

How a view loads. The saved view's filters (status open or new, priority urgent, assignee none, sort by SLA due) go to the View API as a keyset query with limit 50 and a cursor. It returns 50 rows, a nextCursor and asOfSeq 918204. Separately, ticket changes update a count keeper on the server, which pushes view counts over the socket at most every 2 seconds: Urgent 12, Unassigned 48.status: open, newpriority: urgentassignee: nonesort: SLA dueView APIkeyset, limit 5050 rowsnextCursorasOfSeq 918204ticket changesCount keeperone per viewUrgent: 12Unassigned: 48socket, every 2 s
Rows and counts travel on different paths. The page says which event it already includes (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.

ts
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?

Apply everything immediately

Insert, move and remove rows the moment events arrive.

Pros

  • Always exactly correct
  • Simplest code

Cons

  • Rows move under the pointer and under the keyboard selection
  • Busy queues are impossible to work in

Freeze the list, refresh by hand

Load a snapshot; show nothing new until the agent presses refresh.

Pros

  • Perfectly stable

Cons

  • Stale statuses and assignees: agents open tickets a colleague already took
  • Urgent tickets wait unseen

Stable ordering: update in place, buffer moves

Field changes (status, assignee, SLA, preview) update the row where it is.

New rows and position changes go into a buffer shown as a pill: "3 new tickets".

Rows that no longer match the view stay in place, dimmed with a reason, until the agent moves on.

Pros

  • Rows never move on their own
  • Nothing important is hidden: the row shows "Assigned to Ana" right away

Cons

  • The visible order can be briefly out of date
  • More client logic: a buffer and a merge step

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.

Ticket list in stable ordering mode. Rows in order: Maria, 12m, unassigned; Leo, 35m, unassigned; Kim, 50m, unassigned; Raj, 90m, unassigned; Sam, 140m, unassigned. Current ticket: Leo. Open count 5. No pill. Times the current row moved under you: 0.Maria, 12m, unassignedLeo, 35m, unassignedKim, 50m, unassignedRaj, 90m, unassignedSam, 140m, unassignedview count: 5waiting: 0moved under you:0 timespositions frozen
The orange row is the open ticket, hatched rows are dimmed (no longer in the view), and a green row just changed in place. In stable mode nothing moves until you press the pill; in live mode count how often your row jumps.

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.

ts
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.

Sequence diagram of a live event, with columns Server, Sync layer, Store and List controller. The server sends ticket.updated with n 41. The sync layer applies the batch to the store at the next frame. The store tells the list controller that row 48213 changed; the row updates in place, and if its position changed it goes into the pill buffer. Failure path: the server sends n 44 after n 41, so two messages were lost. The sync layer asks for the first page and the counts again, and the server answers with a page and its asOfSeq.ServerSync layerStoreListupdated, n=41batch per framerow changedfields: in placemoved: pilln=44: gapGET page, countspage, asOfSeq
The happy path changes fields, never positions. When 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?

Last write wins

Plain update of the assignee field.

Pros

  • Trivial

Cons

  • Two owners in the window between the writes
  • The loser is never told

Conditional claim with a version

POST /tickets/:id/claim with expectedVersion, the version the client saw.

The server runs one conditional update (... WHERE id = ? AND assignee_id IS NULL AND version = ?). If no row changed, it answers 409 Conflict with the current owner and version.

Pros

  • Exactly one winner, decided by the database in one atomic step
  • The loser learns who won and can move on

Cons

  • Needs a version on every ticket and conflict handling in the UI
  • A harmless change in between (a tag added) also fails the check, so on a 409 for a ticket that is still unassigned the client retries once with the new version

Hard lock on open

Opening a ticket locks it for that agent until they leave.

Pros

  • No two agents ever work the same ticket

Cons

  • Locks are left behind by closed laptops and lunch breaks
  • Team leads can't even read a locked ticket
  • A lost unlock request blocks the queue

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.

Sequence diagram of a claim race, with columns Ben, Ben's app, Server and Ana's app. Ben presses a. Ben's app shows You as the assignee at once. Ana's app sends claim with version 17 and reaches the server first. Ben's app sends claim with version 17. The server answers Ana with 200 and version 18. It answers Ben with 409: Ana owns it. Ben's app rolls back, keeps his draft, and offers the next ticket. The socket then confirms the ticket is assigned to Ana.BenBen's appServerAna's apppress ashows Youclaim, v17claim, v17200, v18409: Ana owns itroll back, next?event: Ana
The server compares and sets in one step, so exactly one claim wins. Ben saw "You" for about 200 ms, then a clear message and a one-key way forward; his draft is kept.

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.

SignalWhen the client sends itWhen the server forgets it
viewingOn opening a ticket, then a heartbeat every 20 s while the tab is visible45 s after the last heartbeat, or at once on leave or socket close
typingOn keystrokes in the composer, at most once every 3 s6 s after the last one
replying (soft lock)When a public reply draft becomes non-empty90 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.

State machine of the soft reply lock on one ticket. It starts free. When Ana's public draft becomes non-empty, it is held by Ana and renewed on every keystroke. If Ben opens the composer, he is warned; if he confirms, the lock is contested. From held, sending, discarding, or 90 seconds without keys releases it. From contested, the first send wins and the other agent gets a 409 with the new reply. Released goes back to free when the server clears it.freeheld by Anarenewed on keyscontestedBen confirmedreleasedsent or discardeddraft startsBen: reply anywaysend or 90 s idlefirst send winsserver clears
The lock warns but never blocks, and it always ends: by a send, a discard, or 90 seconds of silence. That is what makes it safe with real people who close laptops mid-sentence.

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.

Timeline of a high-priority ticket created Friday at 16:30 with a 4 business hour first-response target, business hours 09:00 to 18:00. Friday 16:30 to 18:00 counts 1 hour 30 minutes. Nights and the weekend count nothing: the timer is paused. Monday 09:00 to 11:30 counts the remaining 2 hours 30 minutes, so it breaches Monday at 11:30. A naive wall-clock timer would show it breached at Friday 20:30 and red all weekend.Fri 16:30, high priority, 4 business hoursFri 16:30-18:00nights, weekend: pausedMon 09:00-11:301h 30m countnothing counts2h 30m countnaive: breach Fri 20:30red all weekendbreach Mon 11:30
Business time only moves inside open hours. The naive timer is wrong for 63 hours, long enough to teach agents to ignore red badges.

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?

The client computes everything

Send the policy, the business calendar, holidays and the ticket's status history; the browser walks the calendar.

Pros

  • Fully live, no server calls while ticking

Cons

  • Calendar logic in two languages that must agree to the minute
  • Time zone and holiday bugs differ between server and client
  • Every policy change must reach every browser

The server sends "seconds left" on every tick

Poll or push the remaining time each second or minute.

Pros

  • One source of truth

Cons

  • A flood of messages for 5,000 rows
  • Stale between messages, and jumpy over a slow network

The server sends a snapshot; the client ticks it

The server computes a small snapshot: business time left at a given instant, and until when the clock keeps running.

The client subtracts elapsed time, capped at that instant, using a server-corrected clock.

The server sends a new snapshot on every relevant change.

Pros

  • Exact while the clock runs, with no calendar code in the browser
  • One message per real change, not per tick

Cons

  • Needs a new snapshot when a business period starts, so the server must schedule those events

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.

ts
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.

SLA lab. Hours Mon-Fri 09-18, zone Europe/Berlin. Local time Fri 16:30, ticket open. First reply: 4h 00m left, running, due Mon 11:30. Resolution: 16h 00m left, running, due Tue 14:30.local Fri 16:30, created Fri 16:30, ticket openFirst reply4h 00m left, runningResolution16h 00m left, runningreply due Mon 11:30resolve due Tue 14:30
Business time counts only inside open hours, and resolution also stops while the team waits on the customer. Change the zone: the same UTC moment is mid-morning in New York but night in Kolkata, so the timers behave very differently. Amber means inside the warning threshold, hatched means paused, red means breached.

Urgency states and ticking

StateRule (example)How it shows
On trackMore than the warning threshold leftNeutral badge, "3h 10m"
Warning25% of the target or 1 hour left, whichever is lessAmber badge with a clock icon
CriticalUnder 15 minutesRed outline, "12m", sorted near the top by due time anyway
BreachedBelow zeroSolid red with an alert icon, counts up: "Breached 8m ago"
PausedOutside hours or waiting on the customerGrey 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

EntryWho sees itHow it looks
Customer messageEveryoneLeft aligned, customer avatar, channel icon (email, form, chat)
Public replyAgents and the customerAgent avatar, "Replied by email"
Internal noteAgents onlyTinted background, a lock icon and the words "Internal note", indented
System eventAgents onlyOne 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?

One aggregated call

A backend-for-frontend endpoint gathers the ticket, customer, orders and history, and returns them together.

Pros

  • One round trip
  • Easy to cache as one object

Cons

  • As slow as its slowest part
  • One failing integration fails the whole ticket

Sequential calls

Load the ticket, then the customer from its requester id, then orders from the customer id.

Pros

  • Simple code with obvious dependencies

Cons

  • A waterfall: three round trips stacked, 600 ms or more before the sidebar is complete

Ticket first, context in parallel

The list row already carries the requester id, so the ticket and every sidebar card start at the same moment.

Each card has its own skeleton, timeout and error state.

Pros

  • The conversation shows as soon as the ticket arrives
  • A broken integration breaks one card, not the page

Cons

  • Several requests per ticket; needs request cancellation when the agent moves on

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.

Loading a ticket, two ways, on one time scale. Aggregated: one call returns everything after 1.5 seconds, so nothing shows until then. Ticket first with context in parallel: the ticket arrives at 300 ms, the customer at 350 ms, previous tickets at 500 ms and orders at 800 ms, all started at the same moment. The agent is reading at 300 ms.aggregatedeverything, 1.5 sticket first, context in parallelticket300 mscustomer350 msorders800 mshistory500 msreading at 300 msorders at 800 ms
With parallel requests, the slowest integration only delays its own card. The agent starts reading at 300 ms instead of 1.5 s, and a failed orders call shows a retry in one card, not an error page.

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?

A small toggle above the editor

"Public reply" or "Internal note" switch; the same Send button for both.

Pros

  • Compact

Cons

  • Easy to miss when moving fast
  • The mode can carry over from the last ticket without the agent noticing

Confirm every public send

A dialog: "Send this to the customer?"

Pros

  • Forces a moment of thought

Cons

  • Asked 150 times a day, the dialog is answered by reflex within a week
  • Slows every single reply

Two clearly different modes plus smart checks

The whole composer changes: tinted background and a lock icon for notes, and the button says "Send to Maria" or "Add internal note".

Separate shortcuts open each mode (r reply, n note); the mode is saved with the draft per ticket and never carried over.

A warning only in risky cases: a public reply that mentions an agent with @, or text pasted from a note.

Pros

  • The safe path is the default path; the warning is rare, so it is read

Cons

  • More design work; the risk checks need tuning to stay rare

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.

State machine of a public reply. Draft goes to scheduled on send; scheduled can be undone back to draft within 10 seconds, and becomes sent when the 10 seconds pass. If the server answers 409 because a newer message arrived, the draft goes to conflict, where the agent reviews the new reply and can send anyway. If dispatch fails, the message goes to failed, where it is kept and can be retried.draftscheduledundo for 10 ssentconflictnew reply: reviewfailedkept, retrysend10 s passundo409send anywaydispatch error
The undo window lives on the server (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:

ts
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?

Loop over single-ticket requests

Fire 412 PATCH requests from the browser, a few at a time.

Pros

  • No new server endpoint

Cons

  • Closing the tab stops it halfway with no record of where
  • 412 round trips and rate limits
  • Progress and failures live only in one browser tab

Synchronous batch endpoint

POST /tickets/batch with up to 100 ids; the response lists the result per ticket.

Pros

  • One request, per-item results right away

Cons

  • Long requests time out for big batches
  • Cannot express "all in view"

Asynchronous bulk job

POST /bulk-jobs with the selection and the change, and an Idempotency-Key.

The server answers 202 Accepted with a job id, runs it in the background, and streams progress over the socket.

Pros

  • Survives tab close and network drops
  • Handles both id lists and "all in view"
  • Safe to retry: the same key never runs the job twice

Cons

  • More moving parts: a job store, progress events and a results screen

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.

Sequence diagram of a bulk job, with columns Lead's app, Bulk API and Worker. The app posts the job with idempotency key K1. The 202 response is lost on the network. The app retries with the same key K1, and the API answers 202 with the same job 77 instead of starting a second one. The API runs the job once on the worker. The worker streams progress 230 of 412 over the socket, then the result: 405 updated, 7 failed, grouped by reason.Lead's appBulk APIWorkerPOST job, key K1202 lostretry, key K1202, same job 77run onceprogress 230/412405 ok, 7 failed
Same key, same job: the retry cannot close tickets twice or email 412 customers twice. Progress and results arrive as socket events, so the lead can keep working, or close the tab and read the result in notifications.

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.

KeyActionNotes
j / kNext / previous ticketOpens it in the detail pane; skips dimmed rows
r / nReply / internal noteOpens the composer in that mode and focuses it
aClaim (assign to me)Optimistic, with the conflict path
eSolve and move onThe next ticket opens
xSelect for bulk actionsShift+x selects a range
mApply a macroOpens a searchable list
Cmd+EnterSend (in composer)Shift adds close
EscLeave composer, back to listDraft is kept
?Show all shortcutsGrouped by where they work
Cmd+KCommand paletteEvery 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.

ts
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.

Two lists, before and after pressing e. Before: rows A, B, C which is open, D dimmed because Ana solved it, and E. After: A, B, C now dimmed as solved by you, D still dimmed, and E open. No row moved; the selection skipped the dimmed D and landed on E.before: on CABC (open)D: solved by AnaEafter e: on EABC: solved by youD: solved by AnaE (open)nothing moved; D was skipped
Rows stay where they were, so the agent's sense of place survives. The next ticket is the next usable row in the order on screen, not in the server's newest order.

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

  • nav labelled "Views" for the rail; each view is a link with its count in the name: "Urgent, unassigned, 12 tickets", with aria-current="page" on the active one.
  • A section labelled by the view heading for the list, and main for the open ticket, whose h1 is the subject. Screen reader users jump between them with landmark keys; everyone else gets a skip link and the Esc and r shortcuts.
  • An aside labelled "Customer" for the sidebar, with one h2 per 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:

ChangeAnnounced?How
New tickets in the current viewYes, coalesced"3 new tickets in Urgent, unassigned", at most once every 15 s
Count changes in other viewsNoVisible in the rail; read when the agent visits it
Customer replied on the open ticketYes"New message from Maria Lopez"
Someone starts replying to the open ticketYes, once"Ana is replying", not on every typing signal
SLA countdown ticksNeverText in the badge, read when focused
Open ticket's SLA becomes criticalYes, once"First reply due in 15 minutes"
Claim failed or bulk job doneYesThe 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.

Architecture of the agent workspace. In the browser tab, the top row holds the UI: Views rail, Ticket list and Ticket view. Below them: Shortcuts, the List controller with order and pill, and the Server cache for pages and details, which fetches from the REST API on the server. Below that: the Draft store in IndexedDB and the Entity store holding tickets by id with versions. At the bottom: the Clock with server offset and ticker, and the Sync layer, which talks to the WS gateway on the server and writes batched events into the Entity store. The Server cache writes rows into the Entity store, the Entity store feeds the List controller, and the List controller feeds the Ticket list.Browser tabServerViews railTicket listTicket viewShortcutskeys, ?List controllerorder, pillServer cachepages, detailsDraft storeIndexedDBEntity storetickets by id, versionClockoffset, tickerSync layersocket, batchingREST APIpages, writesWS gatewayeventsrowsbatched
Two doors to the server: the Server cache for requests and the Sync layer for events. Both write into one Entity store, and the List controller alone decides where rows appear.
PieceResponsibilityWhy it is separate
Router and URLHolds view id, ticket id and list filtersReload, back and shared links restore the exact place
Views railLists views with live countsReads counts only, so count updates re-render nothing else
List controllerVisible order, pending buffer, dimmed rows, selectionStable ordering is a view concern, not a data concern
Ticket list (virtualized)Renders about 20 visible rowsPure rendering from the controller's order and the store
Ticket viewHeader, timeline, sidebar cards, composerEach card loads and fails on its own
Entity storeOne normalized record per ticket, keyed by id, with versionThe same ticket shows in a row, the header and the bulk bar; one event updates all three
Server cachePages, ticket details, customer data; request dedupe and cancellationOwns fetching, caching and refetch on reconnect
Sync layerOne WebSocket, subscriptions, n gap check, batching per frame, presence heartbeatsEvery event enters the app through one door, in order
Clock serviceServer time offset and one shared ticker for all timersNo timer per row, no drift between rows
Draft storeDrafts in IndexedDB, synced to the serverSurvives reload, crash and device change
Shortcut managerOne document listener, context rules, the ? overlayShortcuts never fire inside text fields, and can be turned off in one place
Job trackerBulk jobs and their progressJobs 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.

Data flow of the workspace in three lanes. Reads: the ticket database (DB) feeds the View API, which returns 50 rows to the Server cache, which normalizes them into the Entity store, which renders the list rows. Events: the Event log feeds the WS gateway, which sends events with seq and n to the Sync layer, which writes batched events into the Entity store. Writes: the agent presses a; Shortcuts send a claim to a Store patch that shows the change at once; the API client posts it with version 17 to the Claim API, which writes and adds an event to the Event log, which comes back through the socket to confirm.DBView APIServer cacheEntity storeList rows50 rowsrenderEvent logWS gatewaySync layerbatchedseq, nClaim APIAPI clientStore patchShortcutsAgenteventclaimPOST, v17key a
Every write comes back as an event. The optimistic patch is confirmed, or rolled back, by the same stream that brings other agents' changes, so there is only one way data enters the store.

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.

ts
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
};
EntityLives inKept fresh bySize
View definitionsServer cacheLoaded at start; view.changed eventsAbout 30 per agent, under 10 KB
View countsSmall store sliceview.counts every 2 s at mostA number per view
Ticket rowsNormalized entity storePages plus ticket.updated events, checked by versionAbout 600 bytes each; 50 to 500 loaded
List order and bufferList controllerLocal rules (stable ordering)Ids only
Ticket detail and timelineServer cache, last 20 ticketsFetch on open; ticket.message eventsAbout 30 KB per ticket
Customer contextServer cache, 5-minute freshnessFetch on open, in parallel5 to 20 KB
PresenceSmall store slice, open ticket onlySocket presence events with expiryA few entries
DraftsIndexedDB, synced to serverSaved on change, synced after 2 s idleText plus attachment ids
Current view, ticket, filtersURLNavigationA path and a query string
Bulk selection, composer mode, open menusComponent or UI storeUser actionsTiny
Where client data lives, in six places. URL: view urgent and ticket 48213, restored on reload and share. Entity store: TicketRow records with id, version and SLA, patched by events. Server cache: the 20 most recent ticket details, customer cards, and pages as lists of ids, evicted least recently used. IndexedDB: drafts by ticket id, which survive a crash. Socket slices: presence, which expires by itself, and view counts, which each push replaces. Component state: bulk selection and open menus, lost on reload.URLview: urgentticket: 48213reload, shareEntity storeTicketRowid, version, slapatched by eventsServer cachedetails (20)customer cardspages: idsLRU evictionIndexedDBdrafts, by idwith modesurvives a crashSocket slicespresenceview countsexpire or get replacedComponent statebulk selectionopen menuslost on reload
Each place matches how long the data should live and who needs it. Pages hold only ids; the row data lives once, in the entity store, so one event updates every view of it.

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.

MethodPathPurpose
GET/api/viewsView definitions for this agent
GET/api/views/:id/tickets?limit=50&after=...A page of rows, with nextCursor and asOfSeq
GET/api/tickets/:idHeader, the first message and the newest 20 timeline entries
GET/api/tickets/:id/messages?before=...Older timeline entries
GET/api/customers/:id, /orders, /ticketsSidebar cards, each separate
POST/api/tickets/:id/claimConditional claim with expectedVersion; a lost race gets 409
PATCH/api/tickets/:idStatus, priority, tags, with If-Match; a stale version gets 412
POST/api/tickets/:id/messagesReply or note, with basedOnMessageId and sendAfter
DELETE/api/messages/:id/scheduleUndo send, only before dispatch
PUT/api/tickets/:id/draftSync the draft
POST/api/bulk-jobsStart a bulk job, with an Idempotency-Key header
WS/wsSubscribe, events, presence, heartbeats
http
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.

ts
// 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?

Trust the garbage collector

Write normal code; fix leaks when agents complain.

Pros

  • No extra work up front

Cons

  • Agents complain as "the app gets slow after lunch", which is hard to trace
  • Leaks come back with every release

Reload the page every few hours

A timer reloads the app at a quiet moment.

Pros

  • Caps any leak

Cons

  • Interrupts work, loses scroll position and UI state
  • Hides leaks instead of fixing them

Bounded by design, tested for leaks

Every cache has a size limit (20 tickets, 500 rows per view).

Every subscription, listener and timer is tied to a component and removed on unmount, with AbortController signals on listeners.

A nightly test opens 300 tickets and compares heap size at ticket 50 and ticket 300.

Pros

  • Memory stays flat; regressions are caught before release

Cons

  • Needs discipline and a test that runs long

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.

ts
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.

MinutesWhat to doWhat to draw or say
0 to 6Scope questionsShared queues or routing only? SLAs in business hours? How many agents and tickets? How long is the app open?
6 to 10Name what makes it hardShared and live, contended, timed by rules, mistakes are public, all day at speed
10 to 18Wireframe and architectureThe split view; entity store vs list controller; one sync layer
18 to 40Two or three deep divesPick from live list stability, claiming and collisions, SLA snapshots, the note guard
40 to 50API and data modelCursor pages with asOfSeq, version, basedOnMessageId, idempotency keys
50 to 60Performance, accessibility, follow-upsMemory 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?

Think first

A company grows to 10,000 agents. What changes on the client?

Think first

Product wants live chat in the same workspace. What breaks?

Think first

A team lead reassigns 30 tickets from Ben to Ana while Ben is replying to one of them. What should Ben see?

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 basedOnMessageId check 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next