Design Trello or Jira
A project tracker where the same issues show up as a board, a backlog, a table, a timeline, and a personal inbox. The hard parts are keeping every view in sync from one issue cache, rendering fields and workflows that each project defines for itself, a query language that runs on the server, and staying fast with hundreds of thousands of issues.
The question
Design the frontend of a project tracker like Jira, Linear, or Trello. Teams create issues, move them through a workflow, plan sprints, and look at their work as a board, a backlog list, a table, or a timeline. Each project can add its own fields and its own workflow.
Some projects hold hundreds of thousands of issues, and many people edit at once. Walk me through how the views share data, how the issue page works, how filtering works at that size, and how bulk changes and live updates behave.
Real-life examples: Jira, Trello, Linear, Asana, GitHub Projects, ClickUp
Requirements, and what makes a tracker hard
Ask ten engineers to picture Jira and most of them picture the board: columns, cards, drag and drop. The board is a small part of the product. People spend just as much time in the backlog, in a filtered list, in the issue page, and in a personal "my work" list. This guide designs that product: the app around the board.
Several pieces already have their own guides, so this one names them and does not repeat them. Say this split out loud in an interview too. It shows you know where the hard parts are, and it saves minutes for the parts that are unique to a tracker.
| Piece of the product | Where it is designed | What this guide adds |
|---|---|---|
| Board columns, drag and drop, drop targets, drag performance | kanban-board guide | Paging a column with 1,000+ cards, and the board as one view among many |
| Card order (fractional ranks, LexoRank), optimistic moves, two people moving cards | kanban-board guide | Ranking many issues at once, and ranking across sprints in the backlog |
| Big tables, column picking, resizing, sorting UI | data-table guide | Which columns exist (custom fields) and where the rows come from (a server query) |
| Roles, hidden buttons, disabled actions | permission-based-ui guide | Workflow rules: which status changes are allowed, which is a different question from who may change them |
| Cmd+K command menu, the shortcut layer, and its WCAG rules | command-palette guide | Which commands a tracker needs, and how they act on the focused issue or the selection |
| Comments, @mentions, rich descriptions | comments-thread, mention-input, rich-text-editor guides | The activity history and the watchers list |
| Bell icon, unread counts, inbox | notifications-center guide | Which events produce a notification and who gets it |
| Facets, a search box, results state in the URL | search-results-page guide | A typed query language over fields each project defines |
| Queues, select all matching, bulk jobs with undo, rows that stop matching | support-dashboard guide (and data-table for selection) | Bulk changes that must pass workflow rules, and ranking across sprints |
| Several tabs open, offline edits | multi-tab-sync and offline-sync-engine guides | Nothing: we assume an online app and reuse those designs |
Questions to ask first
| Question | Assumed answer | What changes if the answer is different |
|---|---|---|
| Trello style (simple boards) or Jira style (projects, sprints, custom workflows)? | Jira style, with Trello as the simple case of it | Trello style needs no schema engine, no query language, no sprints. Most of this guide would disappear. |
| How big is the biggest project? | 500,000 issues in one project; a typical team has 2,000 to 20,000 | Under about 5,000 issues you could load a whole project and filter in the browser. At 500,000 every list must come from a server query. |
| Which views? | Board, backlog, table (list), timeline, calendar, and "my work" | One view means one data shape. Six views force a shared cache, or six copies of the same issue. |
| Can each project define fields and workflows? | Yes: custom fields, statuses, allowed transitions, per issue type | Fixed fields could be hard-coded components. Custom ones need a renderer driven by a schema. |
| How many people edit at once? | Up to about 200 people active in one large project | With one editor there is no live sync. With 200, events arrive every few seconds and must not disturb what you are reading. |
| Do power users want the keyboard? | Yes: create, assign, change status, move between issues without a mouse | A mouse-only app is simpler, but power users of Linear and Jira expect single-key shortcuts. |
| Mobile? | Responsive web for reading and quick edits; planning is a desktop job | A full mobile planning experience changes the layout of every view. |
Functional requirements
- Show the issues of a project, or of a saved filter, as a board, backlog, table, timeline, calendar, or personal list, and switch between them without a full reload.
- Open any issue in a side panel over the current view, with its own URL that can be shared, bookmarked, and opened as a full page.
- Render and edit the fields each project defines (select, user, date, number, story points, labels) and only offer the status changes its workflow allows.
- Filter with a text query language with autocomplete, or with a visual builder that writes the same query; save filters and share them.
- Rank the backlog, plan sprints with story point totals, start and complete sprints.
- Select hundreds of issues, even across pages, and change them in one bulk action with progress, partial failures, and undo.
- See other people's changes live in every view, and keep a history of every change to an issue.
Non-functional requirements
- Fast views: a view switch under 200 ms when the data is cached, and the first 50 rows of a new query in under 1 s at the 75th percentile.
- Fast edits: a field edit shows on screen in under 100 ms (an optimistic update), so it counts as instant.
- Scale: memory and DOM stay flat whether the project has 2,000 or 500,000 issues: we never hold more than a few thousand issues in the browser.
- Consistency: an edit made anywhere (detail panel, board, bulk action, another person) shows in every open view within about 2 s.
- Accessible: every action works with the keyboard and a screen reader; single-key shortcuts can be turned off (WCAG 2.1.4).
Out of scope
- Server internals: the search index, the workflow engine, and the job runner are black boxes with an API.
- Billing, sign-up, admin screens for editing workflows (we render workflows, we do not design the editor).
- Integrations and plugins (Trello Power-Ups, Jira apps): mention them as an iframe sandbox and move on.
Check your understanding · Exercise 1
Which answers to your scope questions change the client architecture the most? Select all that apply.
What makes a tracker hard
A tracker looks like a CRUD app: issues have fields, you list them and edit them. Five traits make it hard, and each later chapter answers one of them.
- One record, many shapes. The same issue is a card, a backlog row, a table row, a timeline bar, and a line in "my work", and all of them must agree after any edit.
- A user-defined data model. Each project brings its own fields and workflow, so the client renders forms and status menus from a schema, not from hard-coded components.
- Big and unbounded. 3,000 issues fit in memory; 500,000 do not. Every list is a window into a server query, and even "how many match?" is expensive.
- Many writers. Teammates, bulk jobs, automation rules, and integrations (a merged pull request that closes an issue) change issues while you look, and some changes push an issue out of your view.
- Power users. People keep the app open all day, notice a 300 ms lag on every status change, and want everything on the keyboard.
Here is the screen we are designing: a left sidebar of projects and saved filters, a top bar with the view tabs and the query, the current view in the middle, and the issue detail as a panel on the right, over a view that stays where it was.
Notice what the wireframe implies. The view and the panel are on screen together, so an edit in the panel must update the card behind it with no refetch. The URL must describe both the view with its filter and the open issue. And the query bar sits above every view, so filtering belongs to all of them, not to the board or the table.
Think first
Trello and Jira look very different. Which of the five traits does Trello avoid, and which does it still have?
Classic Trello avoids most of trait 2: a board has lists and cards, and there is no per-project workflow engine (Custom Fields exist as a feature, but there are no allowed transitions). Its boards are also usually small, so trait 3 is milder.
It still has traits 1, 4, and 5. Trello's paid Premium plan adds table, calendar, timeline, dashboard, and map views of a board, many people edit the same board, and card moves arrive live. So the shared cache and live events in this guide apply to Trello too; the schema engine and query language are the Jira-specific parts.
One issue, many views
Start with the naive build. Each view is its own page with its own endpoint: the board calls GET /board, the backlog calls GET /backlog, the table calls GET /issues. Each keeps the issues it got in component state. It works until the first edit.
Walk through it. You are on the board with the panel open on PROJ-123, and you change the assignee from Ana to Ben. The panel shows Ben, but the card behind it still shows Ana, because the board holds its own copy.
You switch to the table: it refetches 2,000 rows (say 1.5 s) and now shows Ben. You go back to the board, and it refetches too. Two copies of the truth, two spinners, and a moment where the screen contradicts itself.
Decision How should six views share issue data?
What we pick: We pick the normalized cache with id lists. It gives the same instant agreement between views as a replica, but it holds only what the user has looked at, so it works the same for a 2,000-issue team project and a 500,000-issue company project. A replica is a strong choice when a workspace is small enough to download; outside write-ups of Linear's engine describe partial and lazy loading for exactly this reason. Mention it as the alternative and say why our size assumption rules it out.
The cache: entities and id lists
The cache has two parts. Entities are issues, users, statuses, and sprints, each in a map by id. Lists hold one query's answer, keyed by the query and the view's sort: ordered ids, the total count, and a cursor for the next page. A board is a set of lists, one per column; the normalization lesson covers the general technique.
- Partial entities. A card needs about 8 fields; the detail panel needs 40 plus the description. Store which field sets an entity has (
card,full). The panel reads the cached card fields at once and fetchesfullin the background, so it opens with the title and status already drawn. - Merge, do not replace. A list response with card fields must not wipe the
descriptiona detail fetch put there earlier. Merge field by field, and keep the higherversionnumber when two responses disagree. - Garbage collection. When no list and no open panel references an issue for a few minutes, drop it. Otherwise a day of browsing grows the cache without limit.
View configuration: saved, personal, and in the URL
A view is more than its type. The board groups by status with swimlanes by assignee; the table shows 9 columns sorted by priority; the timeline zooms to quarters. Users expect their tweaks to stick, teams want a shared "Sprint board" view, and anyone should be able to paste a link and see the same thing.
So the config has three layers, merged in order: the project default, the saved view (shared, edited by its owner), and the user's unsaved changes. The URL carries only what differs from the saved view, so a link is exact but stays short. The url-as-state lesson covers the general pattern.
type ViewConfig = {
kind: "board" | "backlog" | "table" | "timeline" | "calendar";
query: string; // "project = PROJ AND sprint in openSprints()"
groupBy?: "status" | "assignee" | "epic" | "none";
swimlanes?: "assignee" | "epic" | "none";
sort: { field: string; dir: "asc" | "desc" }[];
columns?: string[]; // table only: field ids
zoom?: "week" | "month" | "quarter"; // timeline only
};
// Only what differs from the saved view goes into the URL.
function toSearchParams(saved: ViewConfig, current: ViewConfig) {
const params = new URLSearchParams();
if (current.kind !== saved.kind) params.set("view", current.kind);
if (current.query !== saved.query) params.set("q", current.query);
if (current.groupBy !== saved.groupBy) params.set("group", current.groupBy ?? "none");
if (JSON.stringify(current.sort) !== JSON.stringify(saved.sort))
params.set("sort", current.sort.map((s) => `${s.field}:${s.dir}`).join(","));
return params; // /projects/PROJ/views/42?q=...&group=assignee
}
Try it below: thirty issues live in one cache, and Sprint 15 is the view's filter. Edit an issue in the panel, then switch between board, backlog, and table: each shows the change, and the request counter does not move. Then send a teammate's edit that makes an issue stop matching the filter, and see the view keep it in place, marked. The URL field shows how view settings round-trip.
Sprint 15 holds 12 of the 30 cached issues. Edit one in the panel, then switch views: nothing is refetched.
Check your understanding · Exercise 2
With a normalized cache, you change PROJ-123's assignee in the detail panel while the board and table are both loaded. What has to happen? Select all that apply.
The issue detail is a route
The naive panel is a piece of React state: const [openId, setOpenId] = useState(null). Click a card, set the id, show the panel. Then people use it.
A developer copies the address bar to send PROJ-123 to a colleague and sends the board instead. Someone presses the browser Back button to close the panel and leaves the board entirely, and a refresh closes the panel. The detail is the most shared thing in a tracker, so it needs a real URL.
Decision How should the open issue be reflected in the URL?
What we pick: We use the canonical route shown over the view. The issue key is the thing people paste into chat, commits, and pull request titles, so /browse/PROJ-123 must always work and always mean the same issue. The query parameter style (which Jira's own boards use for their side panel) is a fine fallback if the router cannot render a background route. Either way, the view stays mounted under the panel.
History rules: what goes on the Back stack
- Open from a view: push a history entry with
{ background: viewUrl }in its state. Back pops it and the panel closes. - Close with Esc or the X: if this entry was pushed by us, call
history.back()so the stack stays clean. If the user landed here directly (no background), navigate to the project's default view instead. - Move to the next issue with j or k inside the panel: replace, do not push. Otherwise flipping through 30 issues means pressing Back 30 times to leave.
- Open full page (a button in the panel, or Cmd+click on a card): a normal navigation to
/browse/PROJ-123with no background.
pushState on open means one Back to close, and the board under the panel was never unmounted, so its scroll position is intact.Prefetch on hover, keep the list where it was
Two details make the panel feel instant. First, the panel never starts empty: the card fields are already in the cache, so title, status, assignee, and points draw in the first frame. Second, prefetch the rest on intent: when the pointer rests on a card for about 100 ms, or keyboard focus lands on a row, start fetching the full issue.
A hover that leaves after 20 ms costs nothing; a click after 300 ms finds the description already there. The prefetching lesson covers the timing trade-offs.
Closing must put the user back exactly where they were. Because the view stays mounted under the panel, its scroll position and virtualized window survive. Focus goes to the row of the issue the panel last showed, which is usually the one that opened it (the focus-management lesson explains why).
For the full-page case, store the list's scroll offset and the id of the top visible row in history.state before leaving. On Back, the id list is still in the cache, so the view restores the offset in the first render instead of jumping to the top.
Check your understanding · Exercise 3
A user opens PROJ-123 from the backlog, presses j five times to reach PROJ-128, then presses the browser Back button. What should happen with our rules? Select all that apply.
Custom fields and workflows
In a fixed product you write IssueForm with an assignee picker, a priority select, and a due date. In Jira, project A adds Severity and Customer, project B adds Story points and Components, and next week someone adds another field without any frontend release. The client has to render from a description of the fields: a schema.
The schema for a project has four parts. Fields: id, name, type, options, and whether it is required. Issue types: Bug, Story, Task, Epic. Screens: for each issue type and each moment (create, view, edit, a given transition), which fields show and in what order.
The fourth part is workflows: for each issue type, the statuses and the allowed transitions between them. Jira's REST API exposes the same ideas per issue (editmeta for which fields are editable, transitions for which status changes are available), which is a good sign the split is right.
Decision How should the client render fields it has never seen?
What we pick: A registry of field types. The variety lives in the field list, not in the kinds of fields, so a dozen well-built types (each accessible, each with a good editor) cover thousands of custom fields. Screens decide which fields show and in what order. The config-driven-ui lesson has the general pattern; here the config is the project schema.
type FieldType = "text" | "number" | "select" | "multiselect" | "user"
| "date" | "labels" | "storypoints" | "richtext";
type FieldDef = { id: string; name: string; type: FieldType;
required: boolean; options?: { id: string; label: string }[] };
type FieldKit = {
Display: (p: { value: unknown; field: FieldDef }) => JSX.Element;
Editor: (p: { value: unknown; field: FieldDef;
onCommit: (next: unknown) => void }) => JSX.Element;
validate: (value: unknown, field: FieldDef) => string | null;
};
const registry: Record<FieldType, FieldKit> = {
select: { Display: SelectChip, Editor: SelectEditor, validate: oneOfOptions },
storypoints: { Display: PointsBadge, Editor: NumberEditor, validate: nonNegative },
// ...one entry per type
} as Record<FieldType, FieldKit>;
export function Field({ field, value, onCommit }: FieldProps) {
const { Display, Editor } = registry[field.type] ?? registry.text;
return useEditing() ? <Editor {...{ field, value, onCommit }} />
: <Display {...{ field, value }} />;
}
Workflows are state machines
A workflow is a set of statuses and named transitions between them. In the figure, an issue in To do can go to In progress, but not straight to Done; In review can go back to In progress when changes are requested. Jira also gives each status a category (To Do, In Progress, or Done), and views use the category to decide what counts as finished.
Some transitions carry a screen: "Resolve" asks for a resolution field. Some carry conditions the server checks: only the assignee may close, or every subtask must be done first. This is a state machine per project and issue type, so the state-machines lesson applies directly.
The status menu (press s, or click the status chip) must offer only the moves the workflow allows. Two sources give that list. The cached workflow graph answers at once for every issue on screen, which is also what the board asks when it marks columns a dragged card cannot enter.
The per-issue transitions list from the server also applies conditions such as "only the assignee". Use both: draw the menu from the graph at once, then refine it when the issue's full data (which includes its transitions) arrives.
Cache the schema per project with an ETag and a version number. A schema changes rarely and is small next to the issue data (tens to a few hundred KB is a fair estimate), so fetch it once when the project opens, revalidate in the background, and drop it when a schema.changed event arrives. Every issue event carries the schema version it was written with. If that version is newer than the cached one, refetch the schema before rendering the issue, or a new field would show as unknown.
Check your understanding · Exercise 4
An issue is In review. The workflow allows In review to In progress and In review to Done, and Done requires the Resolution field. Which client behaviors are right? Select all that apply.
The query language and saved filters
Every view starts from a set of issues, and that set is a query: a sprint board is "project PROJ, in the open sprint", and my work is "assigned to me, not done". Jira made this explicit with JQL (Jira Query Language), and power users write it by hand: project = PROJ AND status in ("In Progress", "In Review") AND assignee = currentUser() ORDER BY priority DESC. Functions like currentUser() and openSprints() are resolved on the server when the query runs.
Why not filter on the client?
The naive design loads the project and filters with issues.filter(...). At 2,000 issues that is fine. At 50,000, assume about 1 KB of JSON per issue with card fields: 50 MB to download and parse before the first row, and often two or three times that in memory once parsed into objects.
Worse, the result is wrong in ways you cannot see. The server knows things the client does not: which issues this user may see, full-text search over descriptions and comments, history operators like status WAS "In Review", and functions like openSprints(). Re-implementing all that in the browser means two query engines that disagree. So queries run on the server, against a search index, and the client asks for one page at a time.
Decision Should people filter with typed text or with a visual builder?
What we pick: Both, over one parsed query. The text form is the source of truth because it is what the server runs and what the URL stores. The builder is a view of the AST when the AST has a shape it can show: a flat AND of simple clauses. If the user writes an OR across fields, the builder says it cannot show this query and keeps text mode. Jira works the same way: it will not switch a query that is too complex back to basic search.
Autocomplete that knows where the cursor is
Autocomplete is context-aware. Tokenize the text up to the cursor and look at the last complete tokens. At the start of a clause, suggest field names from the schema; after a field, suggest operators valid for its type: =, !=, in, is EMPTY for a select, > and < for dates and numbers, ~ (contains) for text.
After an operator, suggest values. Select options come from the cached schema; users and labels come from the server with a debounced request (about 150 ms) that you cancel with AbortController when the user types again. Jira exposes the same split as two endpoints: one for field and function data, one for value suggestions.
Validate as you type, but softly. An unknown field is underlined with "No field named Severty. Did you mean Severity?" and the Run button stays enabled, because the server is the final judge (a field might exist in a project the client has not loaded). Never auto-run a half-typed query on every keystroke: run on Enter, or after the builder changes a chip.
In the lab below, edit the chips and watch the text change, or type a query and press Enter to see the chips follow. Add an OR to see the builder step aside. A query with errors is still sent, and the simulated server rejects it with its own message.
No errors.
The builder and the text show the same query. Edit either one.
Saved filters, sharing, and paging
- A saved filter is a name, an owner, a query, and sharing rules. Its URL is
/issues?filter=10042; adding&q=...means "based on 10042, with unsaved changes", shown as a dot next to the name and a Save button. - Boards and views are built on filters. A board's query is a saved filter, so the same parser, autocomplete, and paging code serve every view.
- Paging uses a cursor, not an offset, because issues are created and re-ranked while you page, and offsets then skip or repeat rows. Jira Cloud made the same move in 2025: its old
startAtsearch endpoint is deprecated and being removed, and/rest/api/3/search/jqlpages with anextPageToken, returns only ids unless you ask for fields (up to 5,000 ids per page), and leaves counting to a separate approximate-count call. - Search lags writes. A search index is updated a moment after the issue itself, so a query run right after an edit can miss it. Jira's search API documents this and accepts up to 50
reconcileIssuesids to get read-after-write results for them; our client gets the same effect by trusting its cache for issues it just wrote.
Check your understanding · Exercise 5
A user types assignee = currentUser() OR reporter = currentUser() and then clicks the Basic (builder) tab. What should happen? Select all that apply.
The backlog and sprints
The backlog is a long ranked list: the active sprint at the top, then planned future sprints, then everything not yet planned. Under our assumptions a healthy team backlog holds 500 to 3,000 issues, and a neglected one 20,000. Product managers spend hours here, dragging issues up and down, pulling them into sprints, and checking that the sprint is not overloaded.
Ranking one card uses the same fractional keys as the board: the moved item gets a key between its two neighbors, so nothing else changes. The kanban-board guide covers that algorithm, Jira's LexoRank, and the optimistic move, so this chapter does not repeat them. Two things are specific to the backlog: one rank shared across all sprints, and moving many issues at once.
One rank, many sprints
Sprint membership and order are separate fields. Each issue has a sprintId (or none) and one project-wide rank, and a sprint section, like that sprint's board, shows its issues sorted by the shared rank. So "move PROJ-88 into Sprint 15 between PROJ-12 and PROJ-40" is two changes in one command: set sprintId to 15, and give PROJ-88 a rank between those two.
PROJ-12 and PROJ-40 may be far apart in the global order, with hundreds of backlog issues between them. That is fine: a fractional key only needs to fall between its new neighbors.
Decision A user selects 40 issues and drags them to the top of the next sprint. How do we save it?
What we pick: Send the intent. The client shows the final order at once, sends the ordered ids with an anchor, and swaps in the server's ranks when the reply arrives. Above the batch cap, the move becomes a bulk job like the ones in the next chapter.
Planning with capacity
Each sprint header shows its issue count and its story point total, next to the team's capacity: for example "34 of 30 points", in a warning color with the words "over capacity" (never color alone). Sprints are small, 20 to 80 issues in our assumptions, so the client loads each planned sprint fully and sums points itself. The totals then update in the same frame as a drag.
The unplanned backlog is paged, so its total comes from the server as an aggregate. Velocity, the average points completed in the last few sprints, also comes from the server: it is a report, not something to compute from cached pages.
A sprint has a small lifecycle: future, then active, then closed (the same three states Jira's agile API reports). Starting a sprint sets dates and a goal, and completing it asks one question: should unfinished issues go to the next sprint, a new sprint, or the backlog? That is a server command, because it touches many issues and writes history for each. The client shows the result ("7 issues moved to Sprint 16") and invalidates the affected sprint lists.
Check your understanding · Exercise 6
Which statements about the backlog design are true? Select all that apply.
Bulk edit: hundreds of issues, one action
Release day: a manager filters "fix version 4.2, not done", gets 2,431 issues, and wants to move them all to version 4.3. The table shows 50 rows. She ticks the header checkbox, accepts the offer to "Select all 2,431 matching issues", unticks 3 she wants to keep, picks a field and a value, and presses Apply.
Three questions follow: what exactly is selected, who does the work, and what happens when some of it fails. The data-table guide answers the first, and the support-dashboard guide answers the other two for support tickets. A tracker adds one twist: each issue must also pass its own workflow, so some failures are expected, not rare.
What "selected" means
The client never holds 2,431 issues, so the selection cannot be a list of loaded rows. The data-table guide designs the model: a set of ticked ids for small selections, or the query text plus a set of excluded ids for "all matching, except these". Here that is the query plus 3 excluded ids, which is tiny no matter how many issues match, and it is what goes to the server.
One subtlety matters more in a tracker than in most tables: the set changes while you look at it, because teammates and automation edit issues all day. The server resolves the query when the job starts, not when the user clicked, and if the set moved, the result says so: "3 issues no longer matched and were skipped".
Decision Who applies a change to 2,428 issues?
What we pick: A server job for anything beyond a handful of issues. For 1 to 50 loaded issues the same endpoint simply finishes in a second or two, so the UI stays one code path: start a job, show progress, show the result. The job id is also the handle for undo.
202 Accepted; the work and its record live on the server. The failure path is normal here: 17 issues fail on workflow rules or permissions, and the user sees them grouped, with a way to select just those.Progress, partial failure, and undo
- No optimistic update for bulk. Some issues will fail (workflow rules, permissions, locked issues), so do not paint 2,428 rows as changed. Show a progress bar ("1,200 of 2,428") and let each issue update as its own change event arrives; rows on screen show a small pending badge until then.
- Group failures by reason, in workflow terms. "14 issues: the transition to Done is not allowed from Blocked. 3 issues: you cannot edit issues in project OPS." Offer "Select these 17" so the user can fix and retry just those.
- Undo is a second job. The server stores each issue's previous value and restores it only where the field still holds the value the job set, so a teammate's edit made in between is not overwritten. For a status change, undo must itself be a legal transition back; where the workflow has none, the issue is reported as skipped.
- Leaving is fine. The job keeps running if the user navigates away; a toast with the result and an Undo button appears wherever they are, and the job stays in a recent jobs list.
Check your understanding · Exercise 7
A user selects all 2,431 issues matching a query, unticks 3, and starts a bulk transition to Done. Which design choices are right? Select all that apply.
Scale and live updates across views
Two forces push on every view at once: the data is too big to hold, and it keeps changing. The browser holds a window, never the project; and a change can move an issue out of the view the user is looking at, which needs more care.
Never load the whole project
- Lists and tables fetch 50 rows per page by cursor and keep only the visible rows in the DOM; the data-table guide covers that rendering.
- Board columns are paged one by one. A Done column with 4,000 cards loads its first 50 and fetches more as that column scrolls; each column is its own list in the cache, keyed by query plus status. Boards can also hide old finished work: Jira's Kanban boards hide work items that have sat in a Done-category status longer than a chosen 1, 2, or 4 weeks, which keeps Done short.
- Timeline and calendar fetch by date range: the visible quarter plus one on each side.
- Counts are approximate when big. An exact count over 500,000 issues is expensive, so headers show "2,431" when the server knows it cheaply and "10,000+" or "about 48k" when it does not. A count that drives a bulk action is computed exactly by the job when it starts.
- A cache budget. Keep at most a few thousand issue entities (an estimate: about 5,000 at 2 to 4 KB each is 10 to 20 MB), using the garbage collection rule from the cache chapter.
Live events into a shared cache
Decision How should open views learn about changes made by others?
What we pick: A project event stream patched into the cache. Field changes are exact: patch the entity and every view updates. List membership is where it gets fuzzy, and we handle it with one honest rule: never move things under the user's cursor; mark them and let the next refresh settle them. The websockets lesson covers the connection itself (heartbeats, reconnect with backoff).
Applying an event takes four steps. First, check the version: if the event's version is not newer than the cached entity, drop it (an echo of your own edit, or an old event), and if it skips a version, refetch that issue instead of patching, because an event was missed. Second, patch the entity's changed fields.
Third, for each list holding this id, check membership with a small client matcher that understands simple clauses (project, status, assignee, sprint, labels). Fourth, act on the answer, using the same rule the support-dashboard guide uses for ticket queues: nothing moves under the user's pointer.
- Still matches: nothing more; the card or row has re-rendered with the new fields.
- No longer matches: keep the row where it is, dim it, and label it "No longer matches this filter". It leaves on the next refresh or when the user changes the view. Deleting a row under someone's pointer makes them click the wrong issue.
- Might match (a new issue, or a query too complex for the matcher): do not insert it. Show a pill, "3 updates, click to refresh", that refetches the list when the user chooses.
- Your own change (the event's author is you, in this tab): already applied optimistically, so only confirm the version.
After a disconnect, the client does not know what it missed. Each event carries a per-project sequence number; on reconnect, ask the server for events after the last one seen. If the server no longer has them (the gap is too old), mark every list stale and refetch only the ones on screen. The cache-invalidation lesson covers the general trade-off between patching and refetching.
Check your understanding · Exercise 8
You are viewing "assignee = currentUser() AND status != Done". A teammate reassigns PROJ-77, which is on your screen, to someone else. What should your view do? Select all that apply.
Keyboard-first work, history, and watchers
Linear and Jira both lean on single keys. Linear's docs list c to create an issue, j and k to move through a list, x to select the highlighted issue, a to open the assignee menu, and i to assign it to yourself; Jira Cloud uses c, j, k, a, and i the same way. For someone triaging 60 issues a morning, that turns a 3-click edit into 2 keystrokes.
The shortcut layer itself is designed in the command-palette guide: one listener, keys ignored while typing in text fields, Cmd+K, and the WCAG rules. The support-dashboard guide applies it to a ticket queue. A tracker adds three things.
- Commands act on the focused issue or the selection. The list keeps one active row (a roving
tabindex, as the keyboard-navigation lesson shows).aopens the assignee menu for that row, or for every selected row if there is a selection, in which case the change runs as a bulk job. - The status menu is the workflow.
sopens a filterable combobox of the transitions allowed from the current status; type "rev" and Enter to move to In review. Focus returns to the row afterwards, and a polite live region announces "PROJ-77 moved to In review". - Keys keep working in the panel.
jandkmove the open panel to the next or previous issue of the view behind it, replacing the history entry as the detail chapter showed.
The activity history
Every change to an issue is recorded on the server as an entry: who, when, which field, old value, new value. Jira exposes this as the issue changelog. The detail panel shows it as a tab next to comments; the comment thread, the @mention picker, and the rich description editor are their own guides (comments-thread, mention-input, rich-text-editor).
The history is append-only and can be long, hundreds of entries on an old bug. So page it newest first, 20 at a time, and render values with the same field registry as the rest of the app, so a select field's history shows option labels, not ids.
History entries also tell a story the live events cannot. A bulk job writes one entry per issue with the job id, so the panel can say "Changed by a bulk edit by Ana, 2 hours ago (undo)". Automation rules and integrations get named actors, such as "GitHub: pull request 512 merged", so nobody wonders who moved their issue to Done.
Watchers and notifications
The reporter, the assignee, and anyone who comments or presses Watch become watchers. When a history entry is written, the server notifies watchers, minus the person who made the change, and groups bursts (5 field edits in a minute become one notification). In our design an @mention notifies that person once without adding them as a watcher. The bell, the inbox, and unread counts are the notifications-center guide; the client's job here is only a Watch toggle with a watcher count, applied optimistically, and the w shortcut for it.
Check your understanding · Exercise 9
Which designs for the tracker's single-key shortcuts are right? Select all that apply.
Architecture, data model, and API
Put the pieces from the earlier chapters together. The rule that shapes the diagram: views never fetch issues themselves and never store them. They ask the query layer for a list, read entities from the cache, and send edits through one mutation layer, and that single path is what keeps six views in agreement.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Router | Maps URLs to a view, its config, and an optional issue over it; keeps the background view mounted | The URL is the shareable state: view, query, and open issue |
| View shells (board, backlog, table, timeline, calendar, my work) | Layout, virtualization, selection, drag (kanban-board component) | Each view knows how to draw, not where data comes from |
| Query layer | Turns query text plus sort into cached id lists; paging by cursor; counts | One place for paging and staleness rules |
| Issue cache | Normalized entities by id, versions, field sets, eviction | One copy of each issue for every view and the panel |
| Schema store | Fields, screens, workflows per project; field registry | Rarely changes, needed everywhere, cached with ETag |
| Mutation layer | Optimistic edits, transitions, rank commands, bulk jobs, rollback | Every write takes the same path, so undo and errors are uniform |
| Live sync | WebSocket per tab, event router, version checks, resync after gaps | Remote changes enter the cache the same way local ones do |
| Shortcut layer | Key to command mapping, the focused item, the off switch | Commands are shared by keys, menus, and the command palette |
Follow one screen end to end. A request for the sprint board goes through the BFF (backend for frontend), which runs the query against the search index, applies permissions, and returns card fields for the first page of each column. The query layer writes entities into the cache and id lists into list entries, and the board renders from both.
On a write, the mutation layer patches the cache first, then calls the API; the server writes the issue, its history entry, and an event. The event fans out to every subscribed tab, including ours, where the version check turns it into a quiet confirmation.
PATCH requests carrying the version the client saw. Both paths end in the same cache, so the view has one source to render from.Think first
Why put a BFF between the client and the issue, search, and workflow services, instead of calling each service from the browser?
A board's first paint needs data from three places: the schema, the query results, and the sprint. A BFF fetches them together on the server, close to the services, and returns one response shaped for the screen, which saves round trips on slow networks.
It is also where permissions and field-level security are applied once, so the client never receives fields the user may not see. The cost is one more service to run, so keep it thin: composition and shaping, no business rules.
Data model: where each piece of state lives
Each piece of state lives where its lifetime and audience fit. Server data goes in the normalized cache, and shareable view state goes in the URL. Personal preferences go to the server (so they follow the user across devices) with a local copy for the first paint. Transient UI state, such as a half-typed query or the current selection, stays in components.
| State | Lives in | Example |
|---|---|---|
| Issues, users, sprints | Normalized cache (memory) | issues["PROJ-123"] with version: 41 |
| Query results | Cache list entries | ids, cursor, approximate total per query and sort |
| Project schema | Schema store, revalidated with ETag | fields, screens, workflow graph |
| View type, query, grouping, open issue | URL | /projects/PROJ/views/42?group=assignee |
| Saved views and filters | Server | owner, sharing, config |
| Personal defaults (last view, density, shortcuts on or off) | Server plus localStorage copy | shortcuts: false |
| Selection, draft query, open menus | Component state | Selection in query mode with 3 excluded ids |
type Issue = {
id: string; key: string; // "10423", "PROJ-123"
projectId: string; typeId: string; // Bug, Story...
statusId: string; sprintId: string | null;
rank: string; // fractional key, project-wide
summary: string; assigneeId: string | null;
fields: Record<string, unknown>; // custom fields by field id
version: number; updatedAt: string;
loaded: ("card" | "full")[]; // which field sets we have
};
type IssueList = {
key: string; // hash of query + sort (+ column)
ids: string[]; cursor: string | null;
total: { value: number; exact: boolean };
stale: Set<string>; // ids marked "no longer matches"
};
type Workflow = { statuses: { id: string; name: string; category: "todo" | "doing" | "done" }[];
transitions: { id: string; name: string; from: string[]; to: string; screen?: string[] }[] };
The API
REST with a few command endpoints works well here: the resources are simple, and the hard operations (transition, rank, bulk) are commands, not field updates. GraphQL is a fair alternative when views need very different field sets; the graphql lesson covers that trade-off. Either way, writes carry the version the client last saw, so the server can reject a stale write with 409 Conflict and the current issue.
POST /api/search { "query": "project = PROJ AND sprint in openSprints()",
"fields": "card", "limit": 50, "cursor": null }
-> 200 { "issues": [...], "nextCursor": "eyJr...", "total": { "value": 2431, "exact": true } }
GET /api/issues/PROJ-123?fields=full -> issue + transitions + editable fields
PATCH /api/issues/PROJ-123 { "version": 41, "fields": { "assigneeId": "u7" } }
POST /api/issues/PROJ-123/transitions { "version": 41, "transitionId": "31",
"fields": { "resolution": "fixed" } }
POST /api/rank { "ids": ["PROJ-88","PROJ-90"], "before": "PROJ-12", "sprintId": "15" }
POST /api/bulk { "selection": { "query": "...", "excluded": ["PROJ-7"] },
"change": { "fixVersion": "4.3" } } -> 202 { "jobId": "j_81" }
GET /api/projects/PROJ/schema (ETag) GET /api/issues/PROJ-123/history?cursor=
WS <- { "type": "issue.updated", "projectSeq": 99812, "id": "10423", "version": 42,
"actor": "u3", "changed": { "statusId": "s4" } }
Check your understanding · Exercise 10
Where should each piece of state live? Select all that apply.
Running it in the interview
This prompt is huge, and the trap is spending 25 minutes on drag and drop. Say early that the board component is its own design, name what you will cover instead, and let the interviewer redirect you. A plan for 60 minutes:
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions: size, views, custom fields, live editing | "Biggest project is 500k issues, so nothing is filtered on the client." |
| 6 to 10 | Wireframe of the view with the panel open | Sidebar, view tabs, query bar, view, detail panel |
| 10 to 22 | One cache, many views; the detail route | Entities plus id lists; /browse/PROJ-123 over the view, Back closes |
| 22 to 32 | Schema and workflows; the query language | Field registry; workflow as a state machine; text and builder over one AST; server paging |
| 32 to 44 | Bulk jobs and live updates | Selection by query; 202 plus job; event, version check, "no longer matches" marker |
| 44 to 52 | Architecture and API | Design diagram; three endpoints and one event |
| 52 to 60 | Follow-ups | Keyboard and WCAG 2.1.4, backlog ranking, offline |
Say the trade-offs out loud as you go: "A local replica like Linear's would make filtering instant, but at 500,000 issues the first sync is too big, so I'm caching only what's on screen." Interviewers listen for the naive option, why it breaks, and what you chose instead. Expect these follow-ups:
Think first
The interviewer asks: what if users want to triage on a plane?
Keep the design and add a persistence layer: write the normalized cache for recently viewed lists and issues to IndexedDB, and queue edits in an outbox with their base versions. On reconnect, replay the outbox; conflicts come back as 409 with the current issue, and the UI asks the user to resolve.
Queries cannot run offline against data we never downloaded, so offline means "what you last looked at", clearly labelled. The offline-sync-engine guide has the outbox design.
Think first
The interviewer asks: a field is hidden from contractors. How does the client handle it?
The server leaves the field out of every response for that user: search results, the issue, history entries, and events. The client renders only what the schema and data contain, so there is nothing to hide. Hiding in the client would still send the value over the network.
Buttons and menus that depend on roles follow the permission-based-ui guide; the workflow's allowed transitions are a separate check the server also applies.
Check your understanding · Exercise 11
Which moves make a strong answer to this prompt? Select all that apply.
Summary
- The board is one view of many; all views read one normalized issue cache and hold only lists of ids, so an edit anywhere shows everywhere in one render.
- The issue detail is a route (
/browse/PROJ-123) drawn over the view it was opened from: Back closes it, a direct link opens it full page, and j or k replace history instead of pushing. - Custom fields render from a schema through a small registry of field types; workflows are per-project state machines that the client follows for UX and the server enforces.
- Queries run on the server with cursor paging; text and the visual builder edit one parsed query, and the text is what the URL and server see.
- Bulk edits select by query, run as server jobs with progress, report failures grouped by reason, and undo only what nobody has changed since.
- Live events patch entities by version; an issue that leaves the filter is marked, not removed, so nothing moves under the user's cursor.
- Single-key shortcuts speed up power users and must ignore text fields and be possible to turn off (WCAG 2.1.4).
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Jira Cloud REST API: issue search (JQL, nextPageToken, approximate count) Token paging, id-only pages of up to 5,000, a separate approximate count, and reconcileIssues for read-after-write.
- Jira Cloud REST API: issues (transitions, editmeta, changelog) Per-issue allowed transitions and editable fields, the server side of the schema chapter.
- Jira Cloud REST API: issue bulk operations Bulk edits and transitions of up to 1,000 issues that return a task id to poll.
- Jira Software REST API: rank issues Rank before or after an issue, at most 50 issues per call.
- Atlassian: Kanban board shows or hides completed work Status categories decide what is done, and the board hides old done work after 1, 2, or 4 weeks.
- Atlassian: Use advanced search with Jira Query Language (JQL) Fields, operators, functions, and how basic and advanced search relate.
- Jira Cloud: use keyboard shortcuts Single-key shortcuts for lists and boards, and the setting to turn them off.
- Linear Docs: custom views Saved, shareable filtered views in a keyboard-first tracker.
- WCAG 2.1 Understanding 2.1.4: Character Key Shortcuts Why single-key shortcuts must be possible to turn off or remap.
- WAI-ARIA Authoring Practices: Combobox pattern The pattern behind the status and assignee menus and the query autocomplete.
Concepts behind it
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- 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.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- 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.
- 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.
- 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.
Try next
- Design a Kanban boardHard
- Design a data tableHard
- Design permission-based UIMedium
- Design a command paletteMedium
- Design a nested comments threadMedium
- Design an @-mention inputMedium
- Design a rich text editorHard
- Design a notification centerMedium
- Design a search results pageMedium
- Design a customer support ticket dashboardHard