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.
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.
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
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
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.
Already answered in the Q&A
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
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 skippedif (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.
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);
}A filtered list kept showing a skill I had deleted
What I saw
With a category filter applied, I deleted a skill that was visible and its row stayed on screen. Switching the filter, or reloading the page, and it was gone.
What I expected
I expected a delete that worked to remove the row from the view I was looking at, which was the filtered one.
What I cannot tell
That a reload fixed it tells me the server had accepted the delete. It does not tell me which copy on the client was wrong, or whether the row that stayed was a cached response, a local array, or something else entirely.
Guesses, not diagnoses
How I would build it
Put the filter in the cache identity, so a Frontend view and an All view are different entries rather than the same one. On a delete that succeeds, reconcile every cached collection that could have held the record, not only the one on screen. The reload works for the same reason the fix does: a fresh read has no stale copy to disagree with.
Already answered in the Q&A
Who Owns What, And What Changes When It Changes
Findings 1 and 2 rhyme. Both are what a second copy of something that already had an owner looks like from the outside. The design I would reach for is not a cleverer delete handler, it is one owner per piece of state and everything else derived.
The server
The records: name, category, endorsement count, rank. Everything that has to survive a reload, and the only copy anyone else can see.
The client
Which filter is selected, which row is being edited, whether a dialog is open, what has been typed. State nobody else needs and no server should hold.
Nobody
The list on screen. Derived from the records and the filter on every render, which is why it cannot go stale. The moment it becomes a copy with an owner, it can.
The model, and the one field everything turns on
type Skill = {
id: string;
name: string;
category: string;
endorsements: number;
priority: number; // the user's rank. nothing else writes it
};
type SkillsUIState = {
selectedCategory: string; // never reconciled by a mutation
editingSkillId: string | null;
};
// the filter is part of the identity, or two views share one slot
const key = ["skills", { category: "Frontend" }];The mutation lifecycle, in six steps
the row is still on screen
dimmed, or gone already if the update is optimistic
or rejects, and a rejection has to be survivable
not only the one being rendered
no copy to update, so nothing to forget
the offset moved whether or not you moved it
Optimistic update vs refetch after success
Endorsement counts did not explain the order
What I saw
Two skills with five and four endorsements sat near the bottom of the list, below skills with none.
What I cannot tell
This only tells me that endorsement count alone is not the sort key. It is not evidence of what the rule is, and not evidence that endorsements count for nothing. There is almost certainly a signal involved that I cannot see, and the order may well be correct by a definition I do not have.
What the order could be built from
How I would build it
Pick the ordering rule deliberately, then make it legible. The lesson I take from being confused by someone else's list is not that their rule is wrong, it is that a list whose order cannot be accounted for by anything visible will read as broken to a user who cannot see the inputs. If the order is derived, say so. If the user set it, let them see that they did.
Already answered in the Q&A
A skill I added went to the top
What I saw
A skill I added appeared at the top of the list, above skills that had been sitting higher up, including endorsed ones.
What I cannot tell
This may well be deliberate, and if it is, it is defensible: showing someone the thing they just added, where they will see it, is a reasonable choice. I am noting it because it is a decision worth making on purpose, not because I think it is a bug.
What the order could be keyed on
How I would build it
Persist an explicit rank and let the user own it. A new record takes a rank at the end, or wherever the user drops it, and nothing in the add path renumbers records that already have one. If the new row should be surfaced, scroll to it or highlight it rather than rewriting the order to do it.
Already answered in the Q&A
The rank a new record gets
// Puts every new skill first, and quietly renumbers nothing,
// which is why it looks harmless until someone has an order.
const rank = Math.min(...ranks) - 1;
// Puts it last, and leaves every rank the user set alone.
const rank = Math.max(...ranks) + 1;Key Terms
The Vocabulary Of A List That Changes
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.
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.
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.
Marking a cached collection as no longer trustworthy so the next read fetches it again, rather than editing the cached copy in place.
Applying a mutation locally before the server confirms it, so the UI answers at once, and owing the user a rollback if it fails.
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.
Bringing a cached copy back into agreement with what the server now holds, after a write moved one of them.
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.
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.
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.