System Design Bits

Support the project

System Design Bits is free and always will be. If it helped, a coffee helps back.

Buy Me a Coffee QR codeBuy me a coffee

Case Study

LinkedIn Skills: My Own Experience

Four things I noticed while updating the skills on my own profile, and the state problem they all point at.

What This Is

I was adding and removing skills on my LinkedIn profile and the list did a few things I did not expect. I hit most of them more than once, so these are not single misreadings, but I have no access to their code, I only ever had my own account and browser to go on, and I am not the one who decides what that screen is supposed to do. So this is not a bug report. It is one person poking at a UI from the outside, writing down what happened, and then asking what it would take to build the same screen without the thing that surprised me.

Start here if you want the map firstFrontend System Design, in nine stages. Stage 4 is data ownership, stage 5 the UI states, stage 6 the network. This page is one stage 4 decision, seen from the outside, going sideways in four different ways.

How To Read It

Guesses Kept Apart From Observations

The only reliable part of any of this is what appeared on screen. Everything after that is me reasoning about code I have never seen, which is worth doing as an exercise and worth nothing as a diagnosis. Repeating something does not make the explanation for it right: a finding can be perfectly real and still be deliberate, or depend on something no browser tab will show me.

So the four kinds of statement are coloured differently and never blended:

What I saw

What appeared on screen, repeatedly, on one account in one browser. The only part that is not reasoning.

What I expected

What I assumed would happen. My assumption, not a contract anyone published.

What I cannot tell

What a browser tab cannot settle. Usually the most interesting part, and the easiest to skip.

How I would build it

What I would do on a screen of my own. A proposal, not a correction of anybody's work.

Between them sits a fifth thing, marked separately wherever it appears: the guess. Each one is a mechanism that could produce what I saw. Listing four of them per finding is the point, because the moment there is only one it starts to feel like an answer.

The Machine

What All Four Are About

Before the findings, the thing they all happen inside. Seven parts, and one loop worth staring at: the pagination trigger does not read the list it watches, it issues a request built from the cache’s bookmark. So any mutation that moves that bookmark changes what the next scroll fetches, and nothing in the trigger knows a mutation happened.

A proposal, not a reconstruction of anyone’s implementation

Where each piece of the list’s state could liveUser actionsfilter · delete · add · reorderUI state + mutationsthe browser's own stateServer APIowns the recordsPaginated cachekeyed by filterDerived viewrecomputed, never storedRendered listwhat the user seesPagination triggerwatches the end of the listnext page request

The Findings

Four Things That Surprised Me

Pagination stopped after I deleted a skill

What I saw

Scrolling the list loaded more skills, a page at a time. After a few pages had loaded I deleted one skill, and the list appeared to fall back to about its first page. Scrolling further did not seem to load anything else. A hard refresh brought pagination back, and nothing short of one did: the next delete stopped it again, so the page had to be reloaded once per deleted skill for scrolling to keep working.

What I expected

I expected the deleted row to go, the rest of what had loaded to stay, and the next scroll to carry on fetching.

What I cannot tell

It happened more than once, so it is not a one-off misreading, but I did not have the network tab open every time. That leaves the question I most want answered still open: whether a request went out and came back with nothing, or whether no request was sent at all. From the outside those look identical, and they have completely different causes.

Guesses, not diagnoses

Scroll trigger not reattached?hasNextPage computed from a stale total?Pages and offset reset independently?In-flight request landed after the delete?Cache reset instead of reconciled?

How I would build it

Treat a delete as an edit to the pages already loaded rather than a reason to drop them. Take the row out of whichever page holds it, and pull the next offset back by one so the window held and the offset asked for next still describe the same list. Better still, page by cursor instead of by offset, and the arithmetic stops existing.

Why That Fix Has A Second Half

Finding 1 ends on a clause that looks like a detail: and pull the next offset back by one. This section is here to earn it, because the obvious fix without that clause is worse than the bug.

A list that resets after a delete looks like it is resetting too much, so the obvious move is to stop resetting it: take the deleted row out of the pages already loaded and leave everything else alone. Do exactly that and you trade a loud bug for a silent one. The list keeps loading, nobody files anything, and one skill quietly stops existing.

Unlike the rest of this page, none of what follows is a guess about anyone. It is arithmetic, and it holds for any list that asks for records by position.

Think of the list as a book, and of the browser as a reader with a bookmark. You read the first four pages and leave the bookmark on page 5, because that is where you will carry on. Then somebody tears page 2 out. Every page after it slides forward by one, but your bookmark still says page 5, and page 5 is no longer the page you meant. You carry on from the wrong place and skip one, and the book still opens perfectly, which is why you never notice.

The same thing, with eight skills and a page size of four

Why a deleted record can make the next page skip one1. Eight skills. The browser has loaded the first four.ABCDEFGH01234567bookmark: spot 42. B is deleted. Everything after it slides forward one spot.ACDEFGH0123456still spot 4, so E is skipped3. Move the bookmark back one too, and nothing is missed.ACDEFGHbookmark: spot 3
already in the browserthe next request returnsasked for by nobody
Read the middle row twice. E has not been deleted, and it has not failed to load. It simply moved from spot 4 to spot 3, and spot 3 is behind where the browser is reading from. Nothing in the request or the response is an error, and nobody is ever told.

Line by line, in plain JavaScript

// The server has eight skills. The page size is four.
const server = ["A","B","C","D","E","F","G","H"];

// The browser asked for the first four and kept them.
let loaded = ["A","B","C","D"];
let bookmark = 4;              // where the NEXT request starts

// Now B is deleted. On the server, everyone after B moves up:
//   before   A B C D E F G H     E was at spot 4
//   after    A C D E F G H       E is at spot 3

loaded = loaded.filter((s) => s !== "B");
//   loaded is now ["A","C","D"], three instead of four,
//   and nobody touched the bookmark

// The next scroll fires and asks for four, starting at spot 4:
server.slice(bookmark, bookmark + 4);
//   → ["F","G","H"]
//   E is not in there, and it never will be. It sits at spot 3,
//   which the browser believes it has already read.

The fix is one line, and it is the whole lesson

function onDeleted(name) {
  const spot = loaded.indexOf(name);

  // Was the deleted row inside the part we already read?
  if (spot === -1) return;       // No. Our count is unaffected.

  loaded.splice(spot, 1);       // Yes. Drop it from our copy,
  bookmark -= 1;             // and move the bookmark back with it.
}

// bookmark is 3 now, so the next request returns:
//   ["E","F","G","H"]   nothing skipped
That if (spot === -1) return is doing more work than it looks. If you delete a row the browser never loaded, it was never in front of the bookmark, so moving the bookmark would skip a different record instead. A good number of deletion bugs in a paginated list are this one condition, written the wrong way round.

Asking by position

“Give me four, starting at spot 4.” Trivial to implement, works on any query, and every deletion in front of the bookmark quietly breaks the arithmetic. Fine for a list nothing changes.

Asking by record

“Give me four, starting after D.” Deleting B does not move D, so this cannot happen at all. Known as cursor pagination. Costs a stable sort key and a cursor the server can resolve.

A list people can change wants the second one. If the first is already shipped, the deletion path has to keep the arithmetic honest by hand, and that is a thing to decide once, not in every handler that happens to remove a row.

Back to finding 1. A list that stops loading is annoying and obvious, and somebody reports it within a day. A list that drops one row out of forty is neither, and the usual way to get one is to fix the other carelessly. That is the only reason this section exists: not to explain what I saw, but to explain what I would have shipped if I had fixed what I saw without thinking about the bookmark.

A late response is not a valid response

// issued against spot 4; a delete landed while it was in flight
function onPageResponse(request, response) {
  const entry = cache[request.filter];

  // the bookmark moved while this was on its way, so this page
  // describes a list that no longer exists. writing it back
  // would put the deleted row on screen again.
  if (request.start !== entry.bookmark) return;

  append(entry, response.items);
}

Key Terms

The Vocabulary Of A List That Changes

Offset pagination

Asking for a window by position: start at 60, give me 10. Cheap to implement, and correct only while nothing in front of position 60 changes.

Cursor pagination

Asking for a window by its edge: give me 10 after this record. A deletion elsewhere does not move the cursor, because a cursor names a record rather than a count.

Cache identity

The key a cached response is stored under. It has to carry every parameter that changes the response, or two different responses end up sharing one slot.

Invalidation

Marking a cached collection as no longer trustworthy so the next read fetches it again, rather than editing the cached copy in place.

Optimistic update

Applying a mutation locally before the server confirms it, so the UI answers at once, and owing the user a rollback if it fails.

Derived state

A value recomputed from other state on every render rather than stored. It cannot go stale, because there is no copy of it to go stale.

Reconciliation

Bringing a cached copy back into agreement with what the server now holds, after a write moved one of them.

Stable sort

A sort that leaves records with equal keys in the order it found them, so a list does not reshuffle itself between renders for no visible reason.

Final Considerations

How You Would Catch All Of This Early

Every finding on this page took a person clicking around to notice, which is the most expensive way there is to find a bug and the least reliable. All four are also cheap to assert on, as long as the arithmetic lives somewhere a test can reach without starting a browser.

Three tiers, cheapest first. The useful question for each one is not what it covers but what it is the only thing that can cover.

Milliseconds, no DOM, no network. The pagination and cache arithmetic as plain functions over plain data.

Deleting a record inside the loaded window pulls the next offset back by one.

Deleting a record outside the loaded window leaves the offset untouched.

A page response whose offset no longer matches the cache is discarded rather than appended.

Adding a record leaves the rank of every record that already had one unchanged.

An empty collection produces an empty state, not a cache entry that says there is more.

What only this tier catches. Findings 1 and 4, in full, before anything is rendered. This only works if the arithmetic lives in functions that can be called without a component, which is a design decision as much as a testing one.

And before any of those, five cheaper guards

For an append-only pager, the next offset should equal the number of records loaded for that filter. One check after every cache write turns finding 1 into a stack trace.

The through line is the same as the rest of the page. A thing with one owner can be asserted on. A thing with two can only be noticed. Four findings that took scrolling and clicking to spot would each have been a failing assertion, if the state they disagree about had been somewhere a test could hold still.

Key Points

What To Carry Forward

✓

A paginated, filterable, mutable list is not an array on screen. It is four pieces of state that have to agree: the records, the loaded window, the filter, and the next offset.

✓

Every finding here is a version of one thing: something changed the records and something else was not told.

✓

Asking by position and deleting records pull against each other. An offset is a count, and deleting a record changes what every later count means.

✓

Derive the visible list, never copy it. A copy is a second owner, and the second owner is the one that goes stale.

✓

The cheapest test is the one that runs without a browser, which makes where the logic lives a testing decision.

Common Mistakes

Where This Usually Goes Wrong

✕

Fixing the symptom a report names. The delete handler gets patched while the add path, the filter change and the error path keep breaking the same rule.

✕

Resetting the cache on every mutation because reconciling it is harder. Correct, slow, and it throws away everything the user had scrolled to.

✕

Keeping a local copy of the visible rows so filtering feels fast, creating a second owner for data the server already owns.

✕

Leaving the filter out of the cache key, then being surprised when two filters disagree about what exists.

✕

Writing the pagination arithmetic inside the component, so the only test that can reach it has to start a browser.

“They moved my bed to the other room and I still go and lie down in the old spot. Nobody told me. I am holding a copy of the floor plan and it is wrong.”

Reflect

Who Owns The Data, And What Must Change When It Changes?

That question is the page compressed into one line. Run every piece of state on the screen you are building through it. If the answer is that the server owns it and three other places keep a copy, you already know which bug you are going to ship, and you can go and write the test for it now.

Try It Yourself

Go And Break A List Somewhere Else

Open a long, filterable list in any app you use, a playlist or a label list or a settings screen with categories. Load several pages, apply a filter, delete something, and watch what the remaining rows do.

Before reloading, write down which of the four you think disagreed: the records, the loaded window, the filter, or the next offset. Then reload and find out whether the server agreed with the screen.

Then write the test you would have wanted. If it needs a browser, ask what would have to move for it not to.

Interview Prompts

Seven questions this case study answers

Question 1 of 7

What happens to offset pagination when an item is deleted?

  • ✓Every position past the deleted record starts pointing one spot earlier than it did, because an offset is a count and the thing being counted got shorter. Say the browser holds the first four of eight and its next request starts at spot 4. Delete one of those four, and the browser holds three while everything on the server slides forward one. It still asks for spot 4, so the record that moved into spot 3 is fetched by nobody: it is behind where the browser is reading from, and it was never deleted. Nothing in the request or the response is an error. Cursor pagination does not have the problem, because a cursor names a record rather than a count, and deleting a different record does not move it.