Frontend System Design: How to Design Trello, Step by Step
"Design Trello" is one of the most popular frontend system design interview questions, and it's a clever one. On the surface, Trello is just a few columns of cards that you can drag around. You could build a basic version in an afternoon.
But a real Trello has to solve some genuinely hard problems:
- How do you remember the order of cards when people keep moving them around?
- How do you make every action feel instant, even on a slow network?
- What happens when two people move the same card at the same moment?
- How do you keep dragging smooth on a board with thousands of cards?
- How does someone who can't use a mouse move a card?
A strong answer touches all of these. In this guide, we'll design Trello's frontend from scratch, in plain language, in the same order you should follow in an interview:
- Understand what we're building (the requirements)
- Sketch the big picture (the architecture)
- Design the data (what we store, and how)
- Decide how the app talks to the server (the API)
- Make it fast, smooth and reliable (the optimizations)
For every decision, we'll explain why, not just what. Interviewers care far more about your reasoning than about a "correct" answer.
A quick tour of Trello
If you haven't used Trello, here's everything you need to know. Trello organizes work like sticky notes on a wall:
- A board is a project, for example "Website Launch".
- A board has lists, shown as columns from left to right, like "To Do", "In Progress" and "Done".
- Each list holds cards, stacked from top to bottom. A card is one task, like "Build the login page".
┌──────────────────────────────────────────────────────────┐
│ Website Launch │
│ │
│ To Do In Progress Done │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Login page │ │ Pricing page │ │ Logo │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ + Add a card │
│ │ Fix footer │ │ Blog setup │ │
│ └──────────────┘ └──────────────┘ │
│ + Add a card + Add a card │
└──────────────────────────────────────────────────────────┘
You move work forward by dragging cards from one list to the next. Clicking a card opens a bigger window (a modal) with its details: a description, colored labels, the people assigned, a due date, checklists and comments.
Step 1: Understand what we're building
The most common mistake in a system design interview is jumping straight into components and code. Interviewers keep the question vague on purpose. They want to see whether you clarify the problem first, the way you would before starting a real project at work.
Spend the first five minutes agreeing on what you're building.
Ask questions that change the design
Don't ask questions just to fill time. Ask the ones whose answers would change what you build. Here are four good ones, with the answers we'll assume for the rest of this guide:
- Can several people work on the same board at the same time? If yes, we need live updates and a plan for conflicts. We'll assume yes: teammates' changes should appear live.
- How big can a board get? Ten cards and ten thousand cards need very different rendering strategies. We'll assume up to about 5,000 cards.
- Does it need to work on phones and tablets? Dragging with a finger works differently from dragging with a mouse. We'll assume yes.
- Should it work offline? Offline support means saving changes on the device and syncing them later. We'll treat it as a nice-to-have.
What the app must do
These are the functional requirements: the features a user can actually use.
- Create, rename and archive boards, lists and cards.
- Drag a card up or down within a list, or across to another list.
- Drag whole lists left or right to reorder them.
- Open a card to view and edit its details: description, labels, members, due date, checklists and comments.
- See teammates' changes appear without refreshing the page.
How well it must do it
These are the non-functional requirements: the qualities that decide how the app feels. They're easy to forget, but most of the interesting design work comes from them.
- Instant. Every action should feel immediate. When you drop a card, it should land right away, with no loading spinner.
- Smooth. Dragging should run at 60 frames per second. That gives the browser about 16 milliseconds to draw each frame. If we do too much work during a drag, the card stutters and lags behind the cursor.
- Consistent. When two people change the same board at once, both should end up seeing the same thing.
- Accessible. People who use only a keyboard, or a screen reader, must be able to do everything, including moving cards.
- Touch-friendly. Dragging should work with a finger, not just a mouse.
What we're leaving out
Saying what you won't cover matters just as much. It shows you can manage scope under time pressure. For this design, we'll skip login, notifications, search and third-party integrations (which Trello calls Power-Ups).
Interview tip: Write the requirements in a corner of the whiteboard. Later, when you make a decision, point back to them: "We update the screen before the server replies because we agreed every action must feel instant." This ties your whole answer together.
Step 2: Sketch the big picture
Now that we know what we're building, let's decide how the app is shaped: how it renders, what components it's made of, and how data moves through it.
A single-page app
Trello is a classic single-page application (SPA). The browser downloads the app's JavaScript once, and from then on the app updates the screen itself, without reloading the page.
Why not render pages on the server? Server rendering mainly helps with two things: getting pages into search engines, and showing content faster on the very first load. Neither matters much here. Trello boards are private and sit behind a login, so search engines never see them. And the app is extremely interactive, with drag and drop, live updates and pop-up windows everywhere. A client-rendered app is the natural fit. We'll still make the first load fast with caching and code splitting, covered in Step 5.
The component tree
Here's how the screen breaks down into components:
BoardPage the whole page, at /b/:boardId
├── BoardHeader board title, members, filters
└── DragDropProvider keeps track of what's being dragged
└── ListsRow all lists side by side, scrolls sideways
└── List one column (repeated for each list)
├── ListHeader list title and menu
├── CardList the cards in this list
│ └── CardPreview one small card (repeated for each card)
└── AddCardComposer the "+ Add a card" box
CardModal full card details, at /c/:cardId
Here's what each one does:
- BoardPage owns the whole page. It loads the board's data and opens the live connection to the server.
- BoardHeader shows the board's name, the members' avatars and the filter options.
- DragDropProvider is an invisible wrapper. It knows which card or list is being dragged and where it would land, so everything inside it can take part in drag and drop.
- ListsRow lines the lists up side by side and scrolls horizontally when they don't fit on the screen.
- List is one column. Inside it, CardList renders the cards. In Step 5 we'll make it render only the cards you can actually see.
- CardPreview is the small card face: the title, colored labels, member avatars, and little badges such as a comment count or a checklist count like "2/5".
- CardModal shows a card's full details. It contains a lot of code (a rich text editor, comments, checklists) that most visits never need, so we load it only when someone opens a card.
Why the card modal gets its own URL
When you open a card in Trello, the address bar changes to something like /c/abc123. This small decision has three big benefits:
- Links can be shared. Copy the address, send it to a teammate, and it opens straight to that card.
- The back button works. Pressing back closes the card, which is exactly what people expect.
- The board stays put. The board stays loaded underneath the modal, so closing the card is instant and you don't lose your scroll position.
The three layers
Underneath the components, the app has three layers. Picture a busy office:
- The UI components are the people at their desks. They show information and respond to what users do.
- The store is the big whiteboard on the office wall. It holds the current state of the board: every list, every card, and which card is open. Components read from it to decide what to draw. There's only one whiteboard, so everyone works from the same information.
- The sync layer is the messenger running between the office and headquarters (the server). It carries our changes to the server and brings back changes made by other people.
The sync layer uses two channels:
- A REST API, meaning normal HTTP requests, for loading data and saving changes.
- A WebSocket, a connection that stays open, so the server can push teammates' changes to us the moment they happen, without us asking.
It also keeps a pending queue: a list of changes we've made that the server hasn't confirmed yet. This queue is the secret to making the app feel instant, so we'll come back to it in Step 5.
┌─────────────────────────────┐
│ UI components │
└─────┬─────────────────^─────┘
user actions │ │ what to draw
┌─────v─────────────────┴─────┐
│ Store │
└─────┬─────────────────^─────┘
changes to save │ │ confirmed + live changes
┌─────v─────────────────┴─────┐
│ Sync layer │
│ queue · REST · WebSocket │
└─────┬─────────────────^─────┘
requests │ │ responses + live events
┌─────v─────────────────┴─────┐
│ Server │
└─────────────────────────────┘
Following one drag from start to finish
Let's follow a single action through the whole system. Asha drags the "Login page" card from "To Do" to "In Progress".
- Asha's screen updates immediately. The store moves the card into "In Progress" right away, and the card lands where she dropped it.
- The change joins the queue. The sync layer adds "move card to In Progress" to its pending queue, tagged with a unique id. We'll call it
clientMutationId. - The request goes to the server. The sync layer sends it in the background while Asha keeps working.
- The server saves it and tells everyone. It sends a "card moved" event over the WebSocket to everyone viewing the board, including Asha.
- Teammates see the change. Ravi's app receives the event and moves the card on his screen.
- Asha's app recognizes its own change. The event carries the same
clientMutationIdher app created. So her app knows this is the change it already made: it marks the change as confirmed and removes it from the queue, instead of moving the card a second time.
Keep this flow in mind. Step 5 explains how each part copes with slow networks, failures and conflicts.
Step 3: Design the data
Good data design makes everything else easier. Let's write down the shape of our data, and then make three important decisions about how to store it.
The main types
type Board = {
id: string;
title: string;
memberIds: string[];
};
type List = {
id: string;
boardId: string;
title: string;
pos: number; // where the list sits on the board
};
// The card summary: everything the small card face needs.
type Card = {
id: string;
listId: string; // which list the card is in
title: string;
pos: number; // where the card sits in its list
labelIds: string[];
memberIds: string[];
dueAt?: string;
badges: { comments: number; checklistDone: number; checklistTotal: number };
version: number; // goes up by one every time the card changes
};
// The full details: loaded only when someone opens the card.
type = {
: ;
: ;
: [];
: [];
};
A few fields need a word of explanation:
posis a card's (or a list's) position. It decides the order. We'll see exactly how it works in a moment.badgesholds small counters for the card face, so we can show "3 comments" without loading the comments themselves.versiongoes up every time the card changes. It helps us ignore out-of-date updates, as we'll see in Step 5.
Now notice what's missing: a list doesn't hold an array of its cards. Instead, each card knows which list it's in (listId) and where it sits (pos). That looks like a small detail, but the whole design depends on it.
Decision 1: Store data flat, not nested
The obvious way to store a board is to nest everything the way it looks on screen: the board contains lists, and each list contains cards.
const board = {
id: "b1",
lists: [
{ id: "todo", title: "To Do", cards: [{ id: "c1" /* ... */ }, { id: "c2" /* ... */ }] },
{ id: "doing", title: "In Progress", cards: [/* ... */] },
],
};
That's fine for a quick demo, but it gets painful fast:
- Updating is clumsy. To rename one card, you have to dig through the board to find it, then create new copies of the card, its list and the board. (React and Redux expect you to update state by creating new objects, not by changing old ones.)
- Too much re-renders. Because the list and the board are now new objects, React assumes they changed, and lots of components re-render even though only one card is different.
- Moving is error-prone. Moving a card means removing it from one array and adding it to another: two changes that must always happen together.
The better way is to store data flat, also called normalizing it. Each kind of thing gets its own lookup table, keyed by id. Think of a library catalog: instead of putting a copy of a book on every shelf it relates to, the library keeps it in one place and refers to it by its number.
const store = {
entities: {
boards: { b1: { id: "b1", title: "Website Launch" /* ... */ } },
lists: { todo: { id: "todo", boardId: "b1", title: "To Do", pos: 1000 } },
cards: {
c1: { id: "c1", listId: "todo", title: "Login page", pos: 1000 /* ... */ },
c2: { id: "c2", listId: "todo", title: "Fix footer", pos: 2000 /* ... */ },
},
labels: {},
members: {},
},
ui: {
openCardId: null, // which card's modal is open
filters: {}, // active board filters
drag: null, // what's being dragged right now
},
sync: {
pending: [],
: ,
},
};
Now things get simple:
- Updating a card changes one entry in
cards. Nothing else is touched. - Only the changed card re-renders. Each component gets just an id, like
<CardPreview cardId="c1" />, and looks up its own data in the store. When card c1 changes, only c1's component redraws. The other 4,999 cards stay exactly as they are. - Showing a list is a simple query. Take the cards whose
listIdmatches, and sort them bypos. We cache the sorted result (this is called a memoized selector) so we don't re-sort on every render.
The store also has two smaller sections. ui holds screen state the server doesn't care about, like which card is open. sync holds the pending queue and whether we're online.
Decision 2: Load summaries first, details later
Look at a card on a Trello board. You can see its title, label colors, member avatars and a couple of badges. You can't see its description, its comments or its checklist items. Those only appear when you open the card.
So we split each card into two pieces:
- The summary (
Card) has everything the card face needs. We load it for every card when the board opens. - The details (
CardDetail) has the description, checklists and comments. We load it only when someone opens that particular card.
Why bother? Imagine a board with 2,000 cards, where many cards have long descriptions and dozens of comments. Loading everything up front could mean megabytes of data, most of which nobody will look at. Loading only summaries keeps the first load small and fast. And thanks to the badge counters, the card face can still show "3 comments" without downloading them.
Decision 3: Keep order with positions, not array indexes
This is the most interesting decision in the whole design, and interviewers love to dig into it.
The problem. Cards in a list have an order. The simplest way to store that order is an array index: card 0, card 1, card 2, and so on. But think about what happens when you drag the last card of a 100-card list to the top. Every other card's index shifts by one. That's 100 records to update and save, and 100 chances to clash with someone else editing the same list.
The solution: positions with gaps. Instead of indexes, give each card a number called pos, and leave big gaps between the numbers:
Card A pos 1000
Card B pos 2000
Card C pos 3000
To show the list, sort the cards by pos. To move a card between two others, give it a number in the gap. Here's Card C moving between A and B:
Card A pos 1000
Card C pos 1500 ← halfway between 1000 and 2000
Card B pos 2000
Only one card changed. Card C got a new pos (and a new listId, if it moved to another list). Every other card stays exactly as it was. That means one tiny request to the server, and two people moving different cards at the same moment will almost never clash.
Here's a small helper that picks the new position:
const GAP = 1000;
// before / after: the pos of the cards just above and just below the drop spot
function positionBetween(before, after) {
if (before == null && after == null) return GAP; // empty list
if (before == null) return after / 2; // dropped at the top
if (after == null) return before + GAP; // dropped at the bottom
return (before + after) / 2; // dropped in between
}
Dividing by two at the top keeps every position above zero.
There are two catches, and mentioning them is what makes your answer stand out.
Catch 1: the gaps can run out. If people keep dropping cards into the same spot, the gap halves every time: 1500, 1250, 1125, 1062.5, and so on. JavaScript numbers can only hold so much precision, so after about 50 drops into the same gap, the "halfway" number can no longer be told apart from its neighbors. The fix is rebalancing: when the gaps get too small, the server spreads that list's positions out again (1000, 2000, 3000...) and sends the new numbers to everyone. This rarely happens, so it's cheap.
Catch 2: ties. If two people drop a card into the same gap at the same moment, both cards can end up with pos: 1500. To make sure every screen shows the same order, we break ties using the card id: sort by pos first, and if two positions are equal, sort by id. Every client uses the same rule, so every screen agrees.
function byPosition(a, b) {
if (a.pos !== b.pos) return a.pos - b.pos;
return a.id < b.id ? -1 : 1; // same position: let the id decide
}
Real products work this way. Trello's public API lets you set a card's position to "top", "bottom" or any positive number, which is exactly this idea. Jira, also made by Atlassian, uses a system called LexoRank that stores positions as text instead of numbers. When two items get too close, the text simply gets longer, and Jira rebalances in the background when it grows too long. Mentioning this in an interview shows you know how it's done in production.
Step 4: Decide how the app talks to the server
Next, let's define the API: the conversation between our frontend and the server.
Loading and saving data
GET /boards/:id the board, its lists, card summaries, labels and members
GET /cards/:id full details for one card (when its modal opens)
POST /cards create a card { listId, title, pos, clientMutationId }
PATCH /cards/:id update a card { listId?, pos?, title?, ..., clientMutationId }
PATCH /lists/:id update a list { title?, pos?, clientMutationId }
A few things are worth explaining:
- One request loads the whole board.
GET /boards/:idreturns everything needed to draw the board in a single response. There's no chain of "first fetch the lists, then fetch the cards for each list", where every step adds another wait. One round trip means a faster first screen. - PATCH sends only what changed. Moving a card sends just
{ listId, pos }, not the whole card. Requests stay small, and we're less likely to accidentally overwrite a field someone else just edited. - The frontend picks the position. When you drop a card, the frontend works out the new
poswithpositionBetweenand sends it. The frontend already knows where the card landed, so it can show the result immediately instead of waiting for the server to decide. - Every change carries a
clientMutationId. This is a random id the frontend creates for each change, for example withcrypto.randomUUID(). The server includes it when it broadcasts the change, which is how Asha's app recognized its own change in Step 2. It also lets the server spot a duplicate when the same request is sent twice after a network hiccup, so the change isn't applied twice.
Live updates
When a board opens, the app opens a WebSocket connection and subscribes to that board:
→ subscribe "board:b1"
← {
type: "card.updated",
card: { id: "c1", listId: "doing", pos: 1500, ... },
version: 42,
clientMutationId: "7f3a9c..."
}
Every event says what happened (card.created, card.updated, list.updated and so on), includes the new data, and carries a version number. The version tells the app whether this event is newer than what it already has. We'll put it to work in Step 5.
How components talk to each other
The last piece of the interface is between components. Pass ids, not whole objects:
<List listId="todo" />
<CardPreview cardId="c1" />
Each component looks up its own data in the store. Props stay small and rarely change, so React can easily skip re-rendering components whose data didn't change.
Step 5: Make it fast, smooth and reliable
The basic design works. This next part is what separates a good answer from a great one. In an interview, spend most of your remaining time here, because this is where interviewers dig deep.
Optimistic updates: make every action feel instant
If we waited for the server to reply before moving a card, every drop would lag. On a slow mobile connection the delay could stretch past a second, and the app would feel broken.
Instead, we use optimistic updates: we update the screen right away, assuming the server will say yes, and fix things in the rare case it says no. It's like moving a sticky note on the office wall before your manager approves the change. They almost always approve. If they don't, you move the note back.
The simple approach has a hidden bug. The obvious way to do this is to take a snapshot of the state before each change, and restore the snapshot if the server rejects the change. But watch what happens here:
- You move Card A. The app takes a snapshot first.
- While that request is still on its way, you rename Card B.
- The server rejects the move, so the app restores the snapshot.
- The snapshot was taken before you renamed Card B, so your rename is wiped out too, even though nothing was wrong with it.
The better approach: confirmed state plus a pending queue. Keep two things separately:
- Confirmed state: what the server has confirmed.
- Pending queue: changes the user has made that the server hasn't confirmed yet.
What the user sees is always the confirmed state, with the pending changes applied on top:
function visibleState(confirmed, pending) {
return pending.reduce((state, change) => applyChange(state, change), confirmed);
}
Now every outcome is easy to handle:
- The server says yes. Take the change out of the queue and apply it to the confirmed state. Nothing on screen moves, because the user was already seeing it.
- The server says no. Take just that one change out of the queue and recompute. Card A slides back to where it was, while Card B keeps its new name. Show a short message like "Couldn't move the card. Please try again."
- The network failed. If the request never got an answer, it might just be a bad connection. Retry a few times, waiting a little longer before each attempt (this is called exponential backoff), and only treat it as a "no" if it keeps failing.
The queue is usually tiny, just the last few actions, so recomputing what's on screen is cheap.
Real-time collaboration: when two people edit at once
Now let's bring teammates into the picture.
Apply teammates' changes to the confirmed state. When an event arrives over the WebSocket, apply it to the confirmed state, then replay your own pending changes on top as usual. A teammate's change shows up immediately, and your own unconfirmed changes don't get lost or flicker.
Ignore old news. Messages can arrive late or out of order. Since the server increases a card's version every time the card changes, the app can compare versions. If an event isn't newer than what we already have, it's old news and we skip it. Otherwise, a late, stale message could overwrite newer data.
function onCardEvent(event) {
// Our own change came back, so it's confirmed: drop it from the queue.
if (isMine(event.clientMutationId)) removeFromQueue(event.clientMutationId);
const current = confirmed.cards[event.card.id];
if (current && event.version <= current.version) return; // old news, ignore it
confirmed.cards[event.card.id] = { ...event.card, version: event.version };
}
Same card, same field, two people. What if Asha and Ravi rename the same card at the same moment? For Trello, a simple rule works well: the last change the server receives wins, and everyone's screen updates to match. Card titles and due dates are short, and a lost edit is easy to redo. Merging every keystroke, the way Google Docs does, would be overkill here.
Don't pull a card out from under someone's cursor. Imagine you're halfway through dragging a card when a teammate moves that same card. If we applied their update immediately, the card would jump away from your cursor. So while a card is being dragged, we hold any incoming updates for it and apply them right after the drop.
Catch up after a disconnect. Connections drop all the time: a laptop goes to sleep, a phone goes through a tunnel. When the WebSocket reconnects, the app asks the server for every change since the last event it received, and applies them. If the server can't provide that (say, the app was offline for days), the app simply reloads the whole board. Either way, the user ends up with an up-to-date board without refreshing the page.
Smooth drag and drop
Dragging has to keep up with the cursor at 60 frames per second, which leaves about 16 milliseconds per frame. A few rules keep us inside that budget:
- Move the card with CSS
transform. Moving the dragged card withtransform: translate(...)lets the browser slide it around without recalculating the layout of the rest of the page. Changingtopandleft, or moving the element around in the DOM on every mouse movement, forces the browser to redo the layout, which is much slower. - Keep the cursor position out of the store. The cursor moves dozens of times per second. If every tiny movement updated the shared store, big parts of the board could re-render on every frame. Instead, keep the cursor position inside the drag layer, and update the store only when something meaningful changes, like the spot where the card would land. The final move is saved once, on drop.
- Show where the card will land. Display a gap or a line at the drop spot, so the user always knows what will happen when they let go.
- Scroll automatically near the edges. When you drag a card near the edge of the screen, the board should scroll so you can reach lists that are off-screen. The same goes for long lists: drag near the bottom and the list scrolls down.
- Use a long press on touch screens. On a phone, a swipe usually means "scroll". So a drag starts only after a short press and hold. Otherwise people couldn't scroll the board without accidentally moving cards.
In practice, you'd use a library rather than build all of this yourself. Atlassian's Pragmatic drag and drop powers Trello, Jira and Confluence, and dnd-kit is a popular choice for React. In an interview, say which one you'd pick, but also show you understand what it does underneath, like the points above.
Handling huge boards
Some boards have thousands of cards. If we render every single one, the page gets slow to load, slow to scroll and slow to drag.
Render only what's visible. If a list has 2,000 cards but only 12 fit on the screen, we render those 12, plus a few extra above and below so scrolling stays smooth. As you scroll, cards that leave the screen are removed and new ones are added. This technique is called virtualization (or windowing). Users can't tell the difference, but the browser does a tiny fraction of the work. If a board has many lists, we can do the same thing sideways for the lists themselves.
The tricky part: virtualization and drag and drop together. Drag and drop often works out the drop spot by measuring the cards on the screen. With virtualization, off-screen cards don't exist in the page, so there's nothing to measure. The fix is to work out drop positions from the data instead: we know each card's order and roughly how tall it is, so we can calculate where a card would land even when its neighbors aren't rendered.
Re-render only what changed. Wrap CardPreview in React.memo. Since each card receives only its id and reads its own data, a change to one card re-renders only that card.
Group live updates together. Sometimes lots of events arrive at once, for example when a teammate archives a whole list. Instead of updating the screen for every event, collect them and apply them together once per frame using requestAnimationFrame. Fifty events become a single update:
let incoming = [];
function onSocketEvent(event) {
incoming.push(event);
if (incoming.length === 1) {
// first event in this frame: schedule one update for the whole batch
requestAnimationFrame(() => {
const batch = incoming;
incoming = [];
applyEvents(batch); // one store update, one render
});
}
}
Fast loading
- Split the code. The card modal and its rich text editor are large, and they aren't needed to show the board. Load them only when someone opens a card, using
React.lazyand a dynamicimport(). The board appears sooner. - Fetch on hover. People usually hover over a card for a moment before clicking it. Start loading the card's details on hover, and by the time they click, the details are often already there, so the modal opens instantly.
- Show the last board instantly. Save the most recently viewed board in the browser's IndexedDB storage. The next time the user opens it, show the saved copy right away, then fetch the latest version in the background and update the screen. This pattern is called stale-while-revalidate: show something slightly old immediately, then refresh it.
Offline support
We already have a pending queue, so offline support is only a small step further. Save the queue in IndexedDB so it survives a page reload or a closed tab. While offline, users keep working and their changes pile up in the queue. When the connection comes back, the app sends them in order, and the same conflict rules from the real-time section apply. Show a small banner, such as "You're offline. Changes will sync when you reconnect," so users know what's happening.
Accessibility
Drag and drop is designed for a mouse or a finger. Some people use only a keyboard; others use a screen reader. They need to be able to move cards too.
- Offer a keyboard alternative to dragging. Add a "Move" option to each card's menu, where you pick the destination list and position, and let people move a focused card with the arrow keys.
- Announce changes out loud. A screen reader user can't see a card move. Use an
aria-liveregion to announce it, for example: "Moved 'Login page' to In Progress, position 2 of 5." - Manage focus in the modal. When a card opens, move keyboard focus into the modal and keep it there (a "focus trap"). Escape should close it, and focus should return to the card that opened it.
- Don't rely on color alone. Labels are colored, but not everyone can tell colors apart. Make sure labels can show their names as text.
Security
Card descriptions and comments support formatting with Markdown, which gets turned into HTML for display. If we insert that HTML into the page as-is, someone could write a comment containing a hidden script, and it would run in the browser of everyone who opens the card. This attack is called cross-site scripting (XSS). The fix is simple: always clean (sanitize) the HTML with a library like DOMPurify before showing it.
How to present this in a 45-minute interview
You won't have time to say everything above in full, so plan your time:
- Requirements, about 5 minutes: questions, features, qualities, and what's out of scope.
- Big picture, about 10 minutes: single-page app, component tree, modal URL, the three layers and the drag flow.
- Data, about 8 minutes: the types, flat storage, summaries vs details, and positions.
- API, about 5 minutes: REST endpoints, WebSocket events and
clientMutationId. - Deep dives, 15 to 20 minutes: optimistic updates, real-time sync, drag performance, big boards and accessibility.
If time is short, go deep on three topics: positions with gaps, optimistic updates with a pending queue and real-time conflicts. These are where interviewers push hardest.
Mistakes to avoid
- Jumping into components before asking any questions.
- Storing the whole board as one big nested object.
- Using array indexes to keep cards in order.
- Waiting for the server before updating the screen.
- Ignoring failures: what if a request fails, or the connection drops?
- Forgetting keyboard and screen reader users.
Quick recap
- Requirements: live collaboration, boards of up to about 5,000 cards, touch support, and offline as a bonus. The app must feel instant, drag smoothly, stay consistent between users and be accessible.
- Big picture: a single-page app; the card modal has its own URL; UI components read from one shared store; a sync layer handles REST requests, the WebSocket and the pending queue.
- Data: store everything flat, keyed by id; load card summaries first and details on demand; order items by
posnumbers with gaps, break ties by id, and rebalance when the gaps run out. - API: one request loads the board; PATCH sends only what changed; every change carries a
clientMutationId; WebSocket events carry a version number. - Optimizations: confirmed state plus a pending queue; ignore stale events; last write wins; hold updates during a drag; drag with
transform; virtualize long lists; memoize cards; batch events per frame; split code, prefetch on hover and cache the last board; keyboard moves, live announcements and focus management; sanitize user HTML.
Practice the building blocks
Several pieces of this design come up as coding questions on their own: retrying a failed save with exponential backoff, limiting how many requests run at once, and writing your own Promise helpers. You can practice these JavaScript patterns with a live playground and test cases on mentoxis.com.
Ready to put this into practice?
Book a 1:1 mock interview with a FAANG engineer, or work through free interview questions with a live code editor.