31 articles vanished from my site. No error, no log — one hardcoded .limit(80).
Read on: the test that never failed because it proved 1 = 1 · 繁體中文版
I had not deleted a single article, and yet 31 of them were completely gone from the site. Not in the series cards, not reachable through the category filters, not findable in site search. No error. No log entry. No trace.
The symptom: the data is there, the people are gone
My coffee brand's site has a content section with a few years of accumulated writing — early coffee notes from 2020 through recent dev journals.
One day, tidying up, I noticed the earliest batch of coffee posts seemed to be missing from the front end. The series card count was short. Clicking into the category showed nothing. Searching a keyword I knew was in the title returned nothing.
I was quite sure I had never deleted them. I queried the database: all present, status published, nothing unusual.
The rows exist, the front end can't see them. That is a worse feeling than "the data was deleted", because there is nowhere to start. No error message to search for, no stack trace to follow. It isn't broken. It is quietly short.
The root cause: .limit(80) truncates in silence
I opened the query in my list page component and found this line:
.limit(80)
Written long ago, when I had maybe sixty articles. I counted the real number of published posts that day: 111.
There it is. The frontend fetches 80, and the other 31 — mostly the 2020–2023 coffee posts, because of the sort order — were never fetched at all. They weren't "fetched but not rendered." They were absent from the frontend's data from the first millisecond. Series counts, category bucketing, and site search were all operating correctly over a corpus that was missing a chunk.
The reason .limit(80) is so nasty is that it does not error. It isn't "too much data, give up, throw." It is "here are the first 80, pretend the rest don't exist." That is silent truncation, and it belongs to the most dangerous bug family there is — every component looks like it is working, because every component is working. The input was already wrong.
Stopgap: change 80 to 200 and move on, for now
Having found the cause, I did not immediately do the correct, larger fix. I stopped the bleeding:
.limit(200)
One line, thirty seconds, 31 articles back.
I knew 200 is not a cure — today it is 111, and someday it is 201 and the identical bug returns identically. But stopping the bleeding and curing the disease are two different jobs: 31 articles were invisible to real visitors right now, and the priority in that moment was getting them back, not shipping perfect architecture an hour later.
An expedient bump isn't a sin. The sin is bumping it and forgetting, which converts it into the next time bomb. So I queued the real fix at the same moment — and did it the same day.
The real fix: delete the premise that the frontend loads everything
Three changes.
1. A paged RPC on the server. A new fn_get_content_list_page taking limit / offset. The frontend loads the first 30 and appends as you scroll. Whether the total is 111 or 1,111, the frontend never tries to swallow it whole. While I was there, the same RPC took over full-text search (including matching legacy English slugs) and computed reading time in the database instead of in the browser.
2. Counts separated from the list. The old count was pages.value.filter(...).length — deriving "how many posts are in this category" from the articles already loaded into the frontend. That is two bugs in one: it couples load volume and count correctness to the same ceiling. Truncate the load and the count silently follows. I replaced it with an independent countRows query that asks the database directly, selecting two narrow columns and moving no content. "How many are there" and "which ones do I load" are two different questions and should never share a data source.
3. The list stopped carrying full article bodies. The old list query dragged the whole content jsonb for every article, so the index page was hauling the full text of every post on the site. Now it returns summary, cover image and reading time only. Payload per batch went from 348,954 bytes to 22,114 — about 94% less.
Verification: "they're back" is not a number
Fixing vanished data has a trap: it is very easy to glance at the page, see more articles, and call it done. I wanted the numbers to match exactly, not to "look fuller."
Two independent lines, crossed:
- SQL counts — ask the database how many per category and per series.
- Playwright on a real browser — boot the dev server, actually click the category buttons, actually scroll to trigger the next page, and count what is rendered.
Before the fix, the category buttons summed to 8 + 51 + 6 = 65, against a database total of 111. After: 23 + 70 + 13 = 106, plus 5 known cross-category exceptions — exactly 111, matching to the unit. Playwright also confirmed the "30 then 60 more" path produced no duplicates and no gaps.
Numbers matching is what let me say it was fixed.
Three things to take away
Any frontend
.limit(N)over a list needs an answer to "what happens past N." Usually the answer is silent truncation: no error, just quietly less data. It is the hardest class to diagnose precisely because every part of the system looks healthy."How many" and "which ones" are different questions. Never use the length of the loaded array as a count. It goes wrong in lockstep with the load ceiling. Ask the database for
COUNT, page the list, keep them apart.After restoring missing data, reconcile per bucket against database counts. Exact match or it isn't fixed — "looks like more now" isn't a verification. If you can, drive the real load path with a browser rather than trusting a unit test that seeds its own array.
That .limit(80) sat in my code for years and only detonated when the article count quietly crossed 80. Which is the actual lesson: a hardcoded number never tells you it has expired. It just starts lying while you aren't looking.
Originally published on my blog: 31 articles vanished from my site. No error, no log — one hardcoded .limit(80).
I keep a running index of every pothole I've hit building a real production system solo — symptom on the left, what to grep in your own repo on the right: coffeeshooters.com/potholes
And if your team is shipping AI-written code faster than anyone can read it, that's the thing I do for a living: coffeeshooters.com/code-audit
Top comments (1)
Silent truncation is the worst class of data bug because everything downstream looks correct — the UI renders, the search works, the category counts match — they're all just operating on a silently incomplete dataset. No stack trace to search for, nothing to alert on.
The "how many" vs "which ones" distinction is the key insight here. Using .filter(...).length on the already-loaded array as a count means the count and the load ceiling fail identically — you get a count that's wrong in exactly the proportion that the load is wrong, and both look internally consistent. Separating them into countRows (narrow columns, no content) and a paged list query is the right architecture.
The 94% payload reduction (348,954 bytes → 22,114) from removing full article bodies from the list query is striking. Dragging full content jsonb for 80+ articles on every index page load is the kind of thing that starts as a convenience ("easier to have everything available") and becomes a real performance problem quietly.
The verification step — two independent lines crossed, SQL counts vs. Playwright-driven browser counts, checking 23 + 70 + 13 + 5 cross-category = 111 exactly — is what separates "it looks fixed" from "it is fixed." A lot of data restoration work stops at the former.
The hardcoded number detonating silently when the count crossed 80 is exactly the lesson. Numbers like that don't expire noisily.