Design a Jira-style 3-column Kanban board (open / in-progress / done)
Break out into Board / Column / Card components. State shape keyed by id with column → ids ordering. APIs for move/reorder via optimistic updates. Discuss scalability (virtualization, pagination), offline, real-time, and accessibility.
This is a classic component-design / mini system-design prompt. The interviewer is looking for: (a) clear data modeling, (b) component decomposition that's reusable and testable, (c) thoughtful API design for move operations, (d) awareness of scale concerns (long columns, many users, optimistic UI), (e) accessibility (drag-and-drop is famously bad for a11y if done naively). Spend the first 5 minutes asking questions and modeling state, not coding.
Clarifying questions (essential):
- 3 fixed columns or user-defined columns?
- One project per board, or many?
- Single user, or real-time multi-user (Jira / Linear style)?
- Approximate card counts per column — 50? 5,000?
- Authoritative sort: manual order, or sorted by field (priority, due date)?
- Offline support / mobile?
- Filtering / search / labels / assignees?
Scope this answer to: 3 fixed columns, multi-user with optimistic + reconcile, up to a few hundred cards per column.
Data model (the most important slide).
Normalize. Don't store cards as nested arrays — duplicates, mutation pain, terrible drag-reorder ergonomics.
type ID = string;
type Card = {
id: ID;
title: string;
description?: string;
assigneeId?: ID;
labels: string[];
createdAt: string;
updatedAt: string;
};
type Column = {
id: ID;
title: "Open" | "In Progress" | "Done";
cardIds: ID[]; // ordered — this is the source of truth for layout
};
type BoardState = {
cardsById: Record<ID, Card>;
columnsById: Record<ID, Column>;
columnOrder: ID[];
};This shape makes every operation O(1) lookup, O(n) within a column for reorder, and serializes cleanly to/from the server.
API surface.
Two read endpoints + small set of mutations:
GET /boards/:id→ entire board state (with pagination if columns are large).GET /boards/:id/columns/:colId/cards?cursor=…→ paginated card load for huge columns.POST /cards→ create.PATCH /cards/:id→ update fields (title, assignee, labels).POST /cards/:id/move→{ fromCol, toCol, toIndex }. The server stores ordering, ideally as a fractional rank (LexoRank or fractional-indexing library) so concurrent reorders don't require recomputing the whole column.DELETE /cards/:id.
For real-time: WebSocket channel per board pushes { type: "card.moved", cardId, fromCol, toCol, toIndex, version }. Clients reconcile by version.
Why fractional ranks? If you store position: integer, every reorder shifts all subsequent positions — a heavy DB write storm under concurrent users. With LexoRank-style strings, you insert a new rank "between" two existing ones with no neighbor updates: rank("a", "c") = "b". Industry default at Jira, Linear, Notion.
Component decomposition.
<Board>
<BoardHeader /> // filters, search, add member
<ColumnList>
<Column> // one per column
<ColumnHeader /> // title, count, add-card
<CardList> // scrollable / virtualized
<Card /> // draggable, clickable to open detail
</CardList>
<NewCardComposer />
</Column>
...
</ColumnList>
<CardDetailDrawer /> // modal/drawer for selected card
</Board>Each level has a clear job and a clear prop boundary. <Column> knows only its column id; it pulls cards from the store via a selector. This keeps drag operations targeted: dropping a card into a new column changes only that column's cardIds array and the moved card's columnId — every other column re-renders only on identity check.
State management. A normalized store (Zustand / Redux Toolkit / TanStack Query cache). Each component subscribes to the slice it cares about. <Card> subscribes to one card by id; reordering one card re-renders only the affected cards, not all 200.
Move algorithm (the meat).
function moveCard(state: BoardState, cardId: ID, toCol: ID, toIndex: number): BoardState {
// Remove from old column
let fromCol: ID | undefined;
const columns = { ...state.columnsById };
for (const c of Object.values(columns)) {
if (c.cardIds.includes(cardId)) {
fromCol = c.id;
columns[c.id] = { ...c, cardIds: c.cardIds.filter(id => id !== cardId) };
break;
}
}
// Insert in new column at index
const target = columns[toCol];
const next = [...target.cardIds.slice(0, toIndex), cardId, ...target.cardIds.slice(toIndex)];
columns[toCol] = { ...target, cardIds: next };
return { ...state, columnsById: columns };
}Always immutable. With Immer the same code reads as draft.columnsById[toCol].cardIds.splice(toIndex, 0, cardId).
Optimistic UI + reconciliation. On drag-end, update local state immediately, send the mutation. On success, replace optimistic with server-confirmed state (ranks may have collapsed). On failure, roll back and surface a toast. With TanStack Query: onMutate snapshots cache, onError restores, onSettled refetches.
Drag-and-drop library. Use @dnd-kit (modern, accessible, virtualization-friendly) or react-beautiful-dnd (mature but deprecated). Don't roll your own — accessibility alone is a project. @dnd-kit ships keyboard support (Space to grab, arrows to move, Space to drop) and screen-reader announcements out of the box.
Scalability concerns.
- Long columns. Virtualize with
@tanstack/react-virtual; only mount cards in viewport. Combine with drag-and-drop carefully —@dnd-kitsupports virtualized lists. - Pagination per column. "Done" can have thousands of cards; load 50 at a time, lazy-load the rest.
- Many users (real-time). WebSocket fan-out per board. Server is the source of truth; clients apply CRDT-like merge or last-write-wins on the move event. Show "user X is dragging" presence indicators.
- Search / filtering. Server-side search endpoint; client overlays a filter onto the visible cards (don't re-fetch the whole board).
- Offline. Service worker caches the last board snapshot; mutations queued in IndexedDB; sync on reconnect with conflict-resolution UI.
Accessibility (often forgotten — senior signal).
- Keyboard drag (
@dnd-kit/sortablehandles this; verify in interview). - ARIA live regions to announce moves ("Card X moved from Open to In Progress, position 2 of 7").
- Focus management — return focus to the dragged card after drop.
- Color isn't the only state indicator (icons + labels for column meaning).
Pros / cons of the design.
Pros: normalized state → cheap updates; fractional ranks → no write storm; @dnd-kit → a11y free; optimistic UI → instant feel; server-authoritative → multi-user safe.
Cons: more upfront modeling than a nested-array prototype; ranks require a small server-side rebalancing job when they get too dense; real-time fan-out is non-trivial; offline conflict resolution needs UX thought.
Lazy loading. Card detail modal is its own route or dynamic import; only loaded when a card is clicked. The board itself can route-split (/boards/:id) so the rest of the app doesn't pay the kanban bundle cost. Heavy chart / activity-log sub-views inside the card detail are further lazy-loaded.
What interviewers reward: asking the clarifying questions, normalizing state, choosing fractional ranks (or knowing them as a concept), recognizing optimistic-update + reconciliation, naming accessibility, and showing you've thought about what doesn't scale in your first sketch.
Code
Follow-up questions
- •How would you handle two users dragging the same card simultaneously?
- •How would you sync across tabs in the same browser?
- •How would you implement WIP limits per column?
- •How would you add swimlanes / row grouping?
- •How would you scale to 10k cards on the 'Done' column?
Common mistakes
- •Nesting cards inside columns array — every move recreates the world.
- •Storing integer positions and shifting all subsequent rows on every reorder.
- •Forgetting optimistic rollback on mutation failure.
- •Rolling your own drag-and-drop without keyboard or screen-reader support.
- •Loading every card up front even when 'Done' has thousands.
Performance considerations
- •Virtualize long columns; subscribe per-card, not per-board, so reordering one card doesn't re-render 200.
- •Use fractional ranks (LexoRank) to make reorders O(1) DB writes.
- •Memoize selectors with reselect / Zustand useShallow.
Edge cases
- •Network failure mid-drag → roll back optimistic state and toast.
- •Concurrent moves of the same card from two clients → server is the arbiter, version each move event.
- •User refreshes during an in-flight mutation → IDB-queued mutation replays on reload.
Real-world examples
- •Jira, Linear, Trello, GitHub Projects, Notion all use this exact normalized + fractional-rank pattern.