cf.completefrontend
My progress
SYSTEM DESIGN · #105

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 productWhere it is designedWhat this guide adds
Board columns, drag and drop, drop targets, drag performancekanban-board guidePaging a column with 1,000+ cards, and the board as one view among many
Card order (fractional ranks, LexoRank), optimistic moves, two people moving cardskanban-board guideRanking many issues at once, and ranking across sprints in the backlog
Big tables, column picking, resizing, sorting UIdata-table guideWhich columns exist (custom fields) and where the rows come from (a server query)
Roles, hidden buttons, disabled actionspermission-based-ui guideWorkflow 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 rulescommand-palette guideWhich commands a tracker needs, and how they act on the focused issue or the selection
Comments, @mentions, rich descriptionscomments-thread, mention-input, rich-text-editor guidesThe activity history and the watchers list
Bell icon, unread counts, inboxnotifications-center guideWhich events produce a notification and who gets it
Facets, a search box, results state in the URLsearch-results-page guideA typed query language over fields each project defines
Queues, select all matching, bulk jobs with undo, rows that stop matchingsupport-dashboard guide (and data-table for selection)Bulk changes that must pass workflow rules, and ranking across sprints
Several tabs open, offline editsmulti-tab-sync and offline-sync-engine guidesNothing: we assume an online app and reuse those designs

Questions to ask first

QuestionAssumed answerWhat 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 itTrello 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,000Under 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 typeFixed 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 projectWith 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 mouseA 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 jobA 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Wireframe of the tracker. The address bar shows /browse/PROJ-123 over the sprint board. A left sidebar lists Projects, PROJ, Filters, and My work. The top bar has view tabs: Board (selected), Backlog, Table, and Timeline. Under it, a query bar holds sprint in openSprints(). The board has three columns, To do, In review, and Done, with two or three cards each; card PROJ-123 in In review is highlighted. On the right, the issue panel for PROJ-123 shows a status menu set to In review, assignee Ben, 5 points, Sprint 15, a description, and history and comments tabs./browse/PROJ-123 over Sprint boardProjectsPROJFiltersMy workBoardBacklogTableTimelinesprint in openSprints()To doPROJ-88PROJ-12In reviewPROJ-123PROJ-40PROJ-7DonePROJ-3PROJ-9PROJ-123In reviewAssigneeBenPoints: 5Sprint 15Description...HistoryComments
The view and the issue panel share the screen, so an edit in the panel must show on the card behind it, and the URL must describe both. The query bar sits above every view: filtering belongs to all of them, not to the board.

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?

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?

Each view caches its own query, invalidate on edit

Server-state library (TanStack Query, SWR) with one cache entry per view query.

After any edit, invalidate every issue query so each refetches.

Pros

  • Simplest to build: no shared store.
  • Each view gets exactly the shape its endpoint returns.

Cons

  • Every edit triggers N refetches; with a 500-row table that is real load on the server.
  • Between edit and refetch the views disagree on screen.

Normalized issue cache, views hold id lists

One map of issues by id. A query result is only an ordered list of ids plus paging info.

Views render by looking up each id in the map; an edit writes to one entity.

Pros

  • An edit shows in every view in the same render.
  • Switching views reuses entities already loaded; only the id list is new.

Cons

  • More code: merging partial entities, garbage-collecting entities no view uses.
  • An edit can make an id list wrong (the issue no longer matches the filter) and that needs its own rule.

Full local replica of the project

Download every issue into IndexedDB on first load, then keep it current with a change stream.

Views query the local database: filtering and grouping happen in the browser.

Pros

  • Every view switch and filter is instant, even offline.
  • Linear's talks describe a sync engine of this kind: a local copy in IndexedDB, kept current over a WebSocket.

Cons

  • At 500,000 issues the first sync is hundreds of megabytes and minutes of work.
  • Permissions and a rich query language must be re-implemented on the client.

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.

The normalized cache. On the left, three lists hold only ids: board To do holds 88 and 12; board In review holds 123 and 40; table my work holds 123, 7, and 88. On the right, entities hold the fields, one per id: PROJ-123 assigned to Ben at version 41, PROJ-88 to Ana at version 12, PROJ-12 to Chen at version 3, PROJ-40 and PROJ-7. Arrows from the In review list and the my work list both point at the single PROJ-123 entity, and the To do list points at PROJ-88.Lists: ids per queryboard: To do[88, 12]board: In review[123, 40]table: my work[123, 7, 88]Entities: one per idPROJ-123 Ben v41PROJ-88 Ana v12PROJ-12 Chen v3PROJ-40 Ana v8PROJ-7 Ben v20one write to PROJ-123 updates every list that holds 123
A list is a query's answer: ordered ids, a cursor, a total. The fields live once, in the entity. Change PROJ-123 and both the board and the table re-render from the same object.
  • 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 fetches full in 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 description a detail fetch put there earlier. Merge field by field, and keep the higher version number 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.

ts
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
}
A view config and how its unsaved changes go into the URL.

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.

ViewGroupSort

Sprint 15 holds 12 of the 30 cached issues. Edit one in the panel, then switch views: nothing is refetched.

The board view of Sprint 15 from one cache: 12 matching issues, 0 marked as no longer matching, 0 new issues waiting for a refresh. PROJ-6 is open in the panel: To do, Ana, 2 points, version 1. Requests so far: 1.To do (4)PROJ-3 Ana 5pPROJ-6 Ana 2pPROJ-18 Ana 5pPROJ-21 Ana 2pDoing (4)PROJ-1 Ben 2pPROJ-13 Ben 5pPROJ-16 Ben 2pPROJ-28 Ben 5pDone (4)PROJ-8 Chen 5pPROJ-11 Chen 2pPROJ-23 Chen 5pPROJ-26 Chen 2prequests: 1 PROJ-6 v1 no new issues
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?

Component state only

The open issue id lives in React state or a store; the URL never changes.

Pros

  • No router work at all.

Cons

  • No links, no Back to close, lost on refresh.
  • Screen reader and keyboard users lose their place when the page reloads.

Query parameter on the view URL

Opening adds ?selectedIssue=PROJ-123 to the current view's URL.

The view reads the parameter and renders the panel.

Pros

  • Easy: one parameter, the view stays the same route.
  • A link opens the same view with the same panel.

Cons

  • Every view must handle the parameter.
  • The shared link drags the sender's view and filter along, which the receiver may not have access to.

Canonical issue route shown over the view

The issue always lives at /browse/PROJ-123.

When opened from a view by a click, the router keeps the view rendered as the background and shows the issue as a panel. A direct visit renders it full page.

Pros

  • One permanent link per issue, whoever sends it.
  • Back closes the panel; refresh or a new tab gives the full page.

Cons

  • Needs router support for a background route: React Router's background location pattern or Next.js intercepting and parallel routes.
  • Two layouts of the same page to design and test.

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-123 with no background.
Sequence for opening and closing the issue panel, between the User, the Router, the Panel, the Cache, and the API. The user clicks a card. The router pushes /browse/PROJ-123 and renders the panel over the board. The panel reads the card fields from the cache and draws title and status in the first frame. The panel asks the API for the full issue, which returns the description and transitions. Later the user presses Back; the router gets popstate, unmounts the panel, and focus returns to the PROJ-123 card.UserRouterPanelCacheAPIclick cardpush /browseread carddraw frame 1GET full issuedescriptionBackclose, refocus
The panel never starts blank: the card fields are already cached. One 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?

A component per field, written by hand

Each known field gets its own component; unknown fields are not shown.

Pros

  • Full control over each field's look.

Cons

  • Every new custom field needs a frontend release.
  • Thousands of customers define thousands of fields, so this cannot keep up.

A registry of field types

The schema says a field has type select, user, date, number, labels...

A registry maps each type to a display component, an editor, a parser, and a client-side validator.

Pros

  • A new field of a known type works with zero frontend code.
  • A dozen or so types cover most fields; each is built and tested once.

Cons

  • A new field type still needs a release.
  • Per-field polish (a special icon for priority) needs an override hook.

Server-driven layout

The server sends a full layout tree: rows, sections, components, and props.

The client is a generic renderer.

Pros

  • Layout changes without any client release.

Cons

  • The server now owns UI details like spacing and focus order.
  • Hard to make every combination accessible and fast; overkill when only the field list varies.

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.

ts
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 }} />;
}
The field type registry. Every custom field resolves to one entry.

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.

A workflow as a state machine with four statuses: To do, In progress, In review, and Done. Start goes from To do to In progress. Submit goes from In progress to In review. Resolve goes from In review to Done and opens a screen that asks for the Resolution field. Request changes goes from In review back to In progress. Reopen goes from Done back to To do. A note says the server also checks conditions, such as only the assignee may close.server checks conditions:only the assignee may closeResolve opens a screen:Resolution is requiredTo doIn progressIn reviewDoneStartSubmitResolveRequest changesReopen
From In review, the status menu offers exactly two moves: Request changes and Resolve. There is no arrow from To do to Done, so neither the menu nor a board drop can offer it.

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?

Text query only

One input. The user types JQL; autocomplete helps with fields and values.

Pros

  • Full power: OR, parentheses, functions, history operators.
  • Easy to paste, share, and store.

Cons

  • New users do not know the field names or syntax.
  • A typo gives an error, not a result.

Visual builder only

Dropdown chips: Status is In progress, Assignee is me, Label is any of...

Pros

  • Discoverable; impossible to write invalid syntax.
  • This is what Linear leads with: filters are chips you pick from menus.

Cons

  • Hard to express OR across fields or nested conditions.
  • Power users find clicking slow.

Both, over one parsed query

Parse text into a syntax tree (AST). The builder edits the same tree.

Print the tree back to text. Text is what goes to the URL and the server.

Pros

  • Beginners click, experts type, and each sees the other's result.
  • One format to save, share, and send.

Cons

  • The builder can show only a subset; complex queries must stay in text mode.
  • A parser and printer to maintain, and they must round-trip exactly.

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.

The query pipeline. Query text goes into the Parser, which produces tokens and errors and builds the AST. The Builder chips edit the same AST in both directions. Below the AST, a Printer turns the AST back into text, which goes to the URL as the q parameter and to POST /api/search, which pages with a cursor.Query texttyped by expertsParsertokens, errorsASTone truthBuilderflat AND onlyPrinterAST to textURL ?q=shareablePOST /api/searchcursor pagingan OR across fields stays in text mode: the builder cannot show it
Typed text and builder chips are two editors of one parsed query. Whatever the user edits, the printed text is what the URL stores and the server runs.

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.

Suggestionsnone here

No errors.

The builder and the text show the same query. Edit either one.

The current query split into 11 tokens, with 0 errors. The builder shows 2 clauses. Server runs so far: 1.statusin("In progress","In review")ANDassignee=currentUser()warm: field, hatched: operator, green: value, red: errorbuilder: 2 clausesvalid: Enter runs itserver runs: 1results match the text
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 startAt search endpoint is deprecated and being removed, and /rest/api/3/search/jql pages with a nextPageToken, 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 reconcileIssues ids 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?

One rank request per issue

Loop over the 40 issues and send a move for each, each relative to the previous one.

Pros

  • Reuses the single-move endpoint.

Cons

  • 40 requests; if request 23 fails, the sprint holds half the issues in a strange order.
  • Live events from the first moves arrive while the rest are in flight and reshuffle the list.

The client computes 40 keys and sends them

Generate 40 evenly spaced keys between the two neighbors and send all of them in one request.

Pros

  • One request; the client knows the final order exactly.
  • Fine for a few cards dropped into one column, which is how the kanban-board guide handles a multi-card drag.

Cons

  • If a neighbor moved meanwhile, the keys split a stale gap; across sprints and 40 issues that window is wide.
  • Puts the rank format in every client.

Send the intent, the server assigns ranks

One command: these ids, in this order, before issue X, into sprint 15.

The server assigns keys in one transaction and returns the new ranks.

Pros

  • All or nothing; the order is always consistent, and clients never see the rank encoding.
  • Jira's agile API is shaped this way: one call moves issues into a sprint and ranks them before or after a given issue.

Cons

  • The server must cap the batch: Jira's agile API moves or ranks at most 50 issues per call, so bigger moves need chunking or a job.

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.

A sprint's lifecycle as a state machine. Future (planned and ranked) goes to Active (with dates and a goal) on start, and Active goes to Closed (report frozen) on complete. When it closes, unfinished issues move to the next sprint (or a new one) or to the backlog, as the user chooses. A note says done issues stay in the closed sprint.Futureplanned, rankedActivedates, goalClosedreport frozenstartcompleteNext sprintBacklogunfinisheddone issues stayin the closed sprint
Completing a sprint is one server command: it closes the sprint, moves every unfinished issue, and writes history for each. The client asks only one question: where do unfinished issues go?

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?

The client loops over issues

Page through the query and send one update per issue, a few at a time.

Pros

  • No new server endpoint.

Cons

  • 2,428 requests; closing the tab stops it halfway.
  • No single record of what changed, so no undo.

One synchronous bulk request

POST the selection and change; the response arrives when every issue is done.

Pros

  • Simple to call; one result.

Cons

  • Thousands of issues with workflow checks and history writes take far longer than a sensible request timeout.
  • No progress while it runs.

A server job with progress

POST returns 202 Accepted with a job id at once.

The job runs on the server; progress arrives as events or by polling the job.

Pros

  • Survives the tab closing; has progress, a result, and a record for undo.
  • Jira Cloud's bulk operations API works this way: it takes up to 1,000 issues per request and returns a task id to poll for progress.

Cons

  • A job runner, a job table, and a progress UI to build.

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.

Sequence for a bulk edit between the User, the Client, the API, and the Job runner. The user applies a change to 2,428 issues. The client posts the selection as a query to /bulk. The API replies 202 with job id j_81 and starts the job runner, which resolves the query. The job runner sends progress, 1,200 of 2,428, then a result: 2,411 succeeded and 17 failed. The client shows the 17 failures grouped by reason. The user presses Undo, and the client posts to /bulk/j_81/undo.UserClientAPIJob runnerapply to 2,428POST /bulk202, job j_81resolve query1,200 of 2,428result: 17 failed17 failed, groupedUndoPOST j_81/undo
The request returns at once with 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.
A bulk job as a state machine. Queued goes to Running. Running ends in one of three states: Done when every issue succeeded, Partial when some failed, or Failed when nothing was applied. Done and Partial can go to Undo, which restores only issues nobody changed since.Queued202 sentRunningprogressDoneall appliedPartial17 failedFailednone appliedUndounchanged onlyfailed: for example, the query was invalid when the job started
Partial is a normal ending, not an error screen. Undo is offered from Done and Partial, and is itself a job that skips issues edited since.

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?

Poll each visible query

Every 30 s, refetch the first page of every list on screen.

Pros

  • No socket infrastructure; easy to reason about.

Cons

  • Up to 30 s stale; a board with 6 columns is 6 requests every 30 s for every viewer.
  • Rows jump when a poll lands mid-scroll.

A live subscription per query

The client subscribes with its query text; the server pushes matches and non-matches.

Pros

  • Exact: the server knows whether the changed issue matches.

Cons

  • The server must evaluate every open query against every change: with thousands of distinct queries per project, that is expensive.
  • Each view change re-subscribes.

A project event stream, patched into the cache

One WebSocket per tab, subscribed to the projects on screen.

Each event carries the issue id, a version, and the changed fields; the client patches the entity and decides what lists to touch.

Pros

  • One stream feeds every view through the normalized cache.
  • Cheap for the server: it fans out per project, not per query.

Cons

  • The client cannot always tell whether an issue now matches a complex query, so some lists become "maybe stale".
  • Needs version checks and a resync after a disconnect.

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.
Sequence for a live update between the Teammate, the Server, the Socket, the Cache, and the View. The teammate assigns PROJ-77 to Ben. The server sends issue.updated at version 42 to the socket. The socket hands it to the cache, which checks that 42 is newer than 41, patches the entity, and the view re-renders. The view's matcher finds the issue no longer matches the filter and marks the row instead of removing it. In the failure path, the server later sends version 44; the cache sees that version 43 is missing, refetches PROJ-77 from the server, and gets version 44.TeammateServerSocketCacheViewassign to Benupdated v4242 is newerre-rendermark: no matchv44 arrivesv43 missingGET PROJ-77PROJ-77 v44
Fields are patched exactly; list membership is handled gently: a row that stops matching is marked, not removed. A version gap means an event was missed, so the cache refetches that issue instead of patching on top of a hole.

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). a opens 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. s opens 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. j and k move 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.

PartResponsibilityWhy the boundary is here
RouterMaps URLs to a view, its config, and an optional issue over it; keeps the background view mountedThe 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 layerTurns query text plus sort into cached id lists; paging by cursor; countsOne place for paging and staleness rules
Issue cacheNormalized entities by id, versions, field sets, evictionOne copy of each issue for every view and the panel
Schema storeFields, screens, workflows per project; field registryRarely changes, needed everywhere, cached with ETag
Mutation layerOptimistic edits, transitions, rank commands, bulk jobs, rollbackEvery write takes the same path, so undo and errors are uniform
Live syncWebSocket per tab, event router, version checks, resync after gapsRemote changes enter the cache the same way local ones do
Shortcut layerKey to command mapping, the focused item, the off switchCommands are shared by keys, menus, and the command palette
Architecture of the tracker frontend. In the browser: the Router maps URLs to views; the Shortcuts layer sends commands to the Views (board, table, and others). Views read from the Issue cache and ask the Query layer for lists; the Query layer fills the cache. The Schema store tells views how to render fields. The Mutations layer writes to the cache optimistically, and Live sync patches it from events. On the server side: the BFF composes data and applies permissions, and talks to the Search index; the Query layer calls the BFF; Mutations call the Issues and jobs service, which publishes Events back to Live sync.BrowserRouterURL to viewViewsboard, table...Shortcutskeys to cmdsSchemafields, flowsIssue cacheentitiesQuery layerid listsMutationsoptimisticLive syncWebSocketServersBFFcompose, authSearchindexIssues, jobswrites, historyEventsper projectevents
Every arrow into the Issue cache comes from one of three doors: queries, mutations, and live events. Views never fetch or store issues themselves, which is why six of them can agree.

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.

Data flow for the sprint board. Read path, left to right along the top: the Search index returns matching ids to the BFF; the BFF sends a JSON page of 50 cards to the Query layer; the Query layer writes ids and entities into the Issue cache; the View selects from the cache to render. Write path, right to left along the bottom: the View calls Mutations, which patches the cache at once and sends a PATCH with version 41 to the API; the API writes the issue and its history and publishes an event to the Events service, which pushes issue.updated to the Socket, which patches the cache by version.SearchBFFQueryCacheViewids50 cardsentitiesselectMutationsAPIEventsSocketPATCH v41eventpushpatch by versionoptimistic
Reads arrive as pages of card fields; writes leave as small 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?

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.

StateLives inExample
Issues, users, sprintsNormalized cache (memory)issues["PROJ-123"] with version: 41
Query resultsCache list entriesids, cursor, approximate total per query and sort
Project schemaSchema store, revalidated with ETagfields, screens, workflow graph
View type, query, grouping, open issueURL/projects/PROJ/views/42?group=assignee
Saved views and filtersServerowner, sharing, config
Personal defaults (last view, density, shortcuts on or off)Server plus localStorage copyshortcuts: false
Selection, draft query, open menusComponent stateSelection in query mode with 3 excluded ids
ts
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 client-side entities. Custom field values sit in one map keyed by field id.
The client data model. Top row: Issue, kept in the cache by id; IssueList, kept in the cache per query, which holds issue ids; ViewConfig, kept in the URL and on the server, which gives the list its query and sort. Bottom row: Schema, per project with an ETag, which tells the Issue how to render its fields; Selection, in component state, which can be a query and starts a BulkJob; BulkJob, on the server by id. A note lists what goes in the URL: view, query, and the open issue.Issuecache, by idIssueListcache, per queryViewConfigURL and serverSchemaper project, ETagSelectioncomponent stateBulkJobserver, by idrenders fieldssame queryURL: view kind, query, grouping, open issueserver: saved views, filters, preferences
Arrows show references, not copies: a list holds ids, a view config holds a query string, a selection holds a query plus exclusions. Only the cache holds issue fields.

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.

http
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" } }
Core endpoints and one live event.

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:

MinutesWhat to doWhat to draw or say
0 to 6Scope questions: size, views, custom fields, live editing"Biggest project is 500k issues, so nothing is filtered on the client."
6 to 10Wireframe of the view with the panel openSidebar, view tabs, query bar, view, detail panel
10 to 22One cache, many views; the detail routeEntities plus id lists; /browse/PROJ-123 over the view, Back closes
22 to 32Schema and workflows; the query languageField registry; workflow as a state machine; text and builder over one AST; server paging
32 to 44Bulk jobs and live updatesSelection by query; 202 plus job; event, version check, "no longer matches" marker
44 to 52Architecture and APIDesign diagram; three endpoints and one event
52 to 60Follow-upsKeyboard 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?

Think first

The interviewer asks: a field is hidden from contractors. How does the client handle it?

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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next