System Designhard11 min read

Design Jira (Boards & Issue Tracker)

A kanban board with drag-and-drop, optimistic card moves with rollback, normalized client state, real-time teammate updates that survive your in-progress drag, and fast filtering.

Published · by Frontend Masters India

"Design Jira" looks tame next to a collaborative editor, and that's the trap. The hard parts are subtle: a drag-and-drop board has to feel instant, which means optimistic updates and clean rollback when the server says no, and it has to absorb your teammates' changes in real time without yanking the card out from under your cursor mid-drag. Interviewers use it to see whether you understand client state and conflict handling under a UI that has to feel immediate.

1. Scope it first

Ask before designing:

  • How big is a board? A team board with 50 cards is trivial. A board with thousands of issues across many columns forces virtualization and careful filtering.
  • Real-time, or refresh-to-see-changes? If teammates move cards live, you need a sync channel and a conflict story. Assume yes.
  • What's on a card? Title, assignee, labels, story points, status? This shapes the data model and what you filter on.
  • How much editing happens inline? Drag between columns only, or also inline title edits, assignee changes, comments? Each adds an optimistic-update surface.

Assume a real-time board with hundreds to thousands of issues and inline edits. That's where the interesting decisions live.

2. Normalize the client state

The naive shape is an array of columns, each holding an array of full card objects. It looks clean and it rots fast: the same card can appear in two places after a bad merge, and updating one card means hunting through nested arrays.

Normalize instead. Store entities by ID in flat maps, and store order as arrays of IDs. This is the Redux-style normalized shape and it's the right call here.

type State = {
  issues: Record<string, Issue>;        // id -> issue
  columns: Record<string, Column>;      // id -> column
  columnOrder: string[];                // board layout
  // each column holds an ordered list of issue IDs
};
type Column = { id: string; title: string; issueIds: string[] };

Now moving a card is just editing two issueIds arrays. Updating a card's assignee touches one entry in issues. There's a single source of truth per issue, so a real-time update or a filter can't desync your board.

3. Drag and drop

Use the right primitive. The native HTML Drag and Drop API is awkward and inconsistent, especially on touch. A library like dnd-kit (or the older react-beautiful-dnd) handles pointer and keyboard dragging, auto-scroll, and accessible announcements. Mentioning keyboard-accessible drag is a strong signal, because a mouse-only board excludes real users.

During a drag you only reorder IDs in your local state, which is cheap because of the normalized shape. The card's data never moves; only its position in two arrays does.

4. The hard problem: optimistic moves with rollback

When someone drops a card in "Done," it must snap there instantly. Waiting for a server round-trip before moving the card feels broken. So you update local state immediately (optimistic), fire the request, and reconcile.

The piece people forget is rollback. If the server rejects the move (permissions, a stale version, a validation rule), you have to put the card back exactly where it was, and tell the user quietly.

function moveCard(cardId, fromCol, toCol, toIndex) {
  const snapshot = structuredClone(state.columns); // remember the truth
  applyMoveLocally(cardId, fromCol, toCol, toIndex); // optimistic
  api.move(cardId, toCol, toIndex).catch(() => {
    state.columns = snapshot;        // rollback to exact prior order
    toast("Couldn't move that card");
  });
}

Snapshot the order, not just the card, so rollback restores the exact index, not just the column. And key the request so a slow rejected move can't clobber a newer successful one (the same stale-response problem as autocomplete, in a different costume).

5. Real-time updates without losing your drag

A teammate moves a card while you're mid-drag. If you blindly apply their update to local state, you'll re-render and rip the card out of your hand. This is the genuinely tricky part of a live board.

The rule: while a local drag is in progress, don't apply remote changes to the things you're touching. Buffer incoming updates and apply them when the drag ends, then reconcile. For everything you're not dragging, apply remote updates live so the board stays fresh.

socket.on("issueMoved", (evt) => {
  if (isDragging && evt.issueId === draggingId) {
    pendingRemote.push(evt);       // hold it
  } else {
    applyRemote(evt);              // safe to apply now
  }
});
// on drop: flush pendingRemote, then reconcile against your move

Reconciliation needs a tiebreaker when you and a teammate moved the same card. A server-assigned version or last-write-wins with a timestamp decides who wins, and the loser sees the card settle into the agreed position. Decide the policy and state it; "the server is the referee and broadcasts the resolved order" is a fine answer.

6. Filtering and search over many issues

Boards get filtered constantly: "only my issues," "label = bug," a text query on titles. Filtering should feel instant and must not mutate your underlying state. Derive the filtered view from the normalized store.

const visibleIds = useMemo(
  () => column.issueIds.filter((id) => matches(issues[id], filters)),
  [column.issueIds, issues, filters]
);

Memoize so you don't refilter on every unrelated render. For free-text search over thousands of issues, debounce the input and consider a prebuilt client-side index (a simple inverted index, or a library) so each keystroke isn't a linear scan of every title and description. If the dataset is huge, search server-side and treat the board as a window onto results.

7. Rendering a large board

Thousands of cards across columns will choke the DOM. Virtualize each column's list so only visible cards render, the same windowing idea as a feed, applied per column. Variable card heights (a card with labels and an avatar is taller than a bare title) mean dynamic measurement, not fixed rows. Combine virtualization with drag carefully: the drop target may be a card that isn't currently rendered, so the library needs to handle auto-scroll and offscreen targets, which is a known sharp edge worth calling out.

Keep re-renders contained: because state is normalized, a single card update should re-render that card, not the whole board. Select narrowly and memoize card components on their issue ID.

What the interviewer will push on

  • "What happens when the optimistic move fails?" Snapshot the column order before the move, roll back to that exact order on rejection, and surface a quiet error. Don't just drop the card in place.
  • "A teammate moves the card you're dragging." Buffer remote updates for the dragged item until drop, apply everything else live, then reconcile with a server-decided order.
  • "How do you keep a thousand-card board fast?" Normalized state for narrow re-renders, virtualize each column, memoize filtered views and card components.
  • "How do you make filtering instant?" Derive filtered IDs from the store with memoization, debounce text search, and use a client index or server search for large datasets.
  • "Is the drag accessible?" Yes by design: keyboard pickup and move with live-region announcements, via a library that supports it. A pointer-only board fails real users.

The one-paragraph recap

A Jira-style board keeps normalized client state (entities by ID, order as arrays of IDs) so moving a card is just reordering IDs and every update has a single source of truth. Drag and drop runs through an accessible library, card moves are optimistic with an order snapshot for exact rollback when the server rejects, and real-time teammate updates are buffered for the card you're dragging while applied live everywhere else, then reconciled by a server-decided order. Filtering and search derive memoized views from the store with debounced text input, and large boards virtualize each column with narrow, memoized re-renders. Lead with normalized state and the optimistic-move-plus-rollback flow, and you've covered what makes this board real instead of a demo.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

More design prompts