Notifio is two codebases in one repository: an Electron desktop app that watches rental search pages, and the Next.js site that sells it, documents it and sends its email. 126 TypeScript and TSX files between them.
I have measured plenty of things about this repo and published most of them. I had never measured the prose. So:
126 files 17,920 code lines 3,389 comment lines 18.9% of non-blank lines 2,273 blank lines 1,116 comment blocks 27,231 words For scale: every visible word on all 38 public pages of notifio.app adds up to about 37,300. The comments in the repository are three quarters of the public website.
That number alone is not interesting. What I wanted to know is what all those words are for, and there is a cheap way to find out.
Counting cues
A comment can say what the code does, or it can say why it is this code and not some other code. The second kind survives a refactor; the first kind is a liability. You can approximate the split by counting phrases:
cues = ['because', 'rather than', 'instead of', 'on purpose', 'deliberately', 'used to', 'which is why', 'so that', 'never', 'must not', 'must never', 'do not'] The result, across all 1,116 blocks:
| Phrase | Count |
|---|---|
| rather than | 107 |
| never | 71 |
| because | 66 |
| instead of | 23 |
| deliberately | 19 |
| used to | 17 |
| on purpose | 15 |
| must not | 13 |
| do not | 10 |
| so that | 7 |
| must never | 5 |
| which is why | 1 |
rather than beating because by 107 to 66 is the finding. The most common explanatory move in this codebase is not to give the reason for the code that is there. It is to name the thing that is not there.
Once you see it, it is everywhere. The reply ledger:
/** * NDJSON rather than sqlite on purpose: no native module, so the existing * electron-builder pipeline (already fiddly with the bundled Playwright * browsers) stays untouched. Updates are appended as a new row with the same * id and readers fold last-wins, which keeps writes atomic by construction. */ The event stream that drives the app window:
// Per-search state is owned by the monitor and streamed as whole snapshots. // A snapshot is a few hundred bytes and arrives at most a few times a cycle, // which is far simpler to reason about than a set of incremental patches the // renderer would have to apply in order. The encrypted settings file, on what to do when the OS keychain is unavailable:
// Not running under Electron, or the OS keychain is unavailable. Fall back to // plaintext rather than losing the data. And the licence check, which is the one I would keep if I could keep only one:
// Only a considered "no" from the server counts as one. // // A 429 used to land here and be recorded as an invalid licence, which // it plainly is not: the endpoint is rate limited per IP, so a burst of // checks (or a shared office address) could switch someone's working // licence off. Anything that is not the server having looked the licence // up and refused is "we do not know". Every one of those is answering a question that the code cannot answer: not "what does this do" but "why is the obvious thing wrong here". A reader who does not know that 429 used to be treated as a rejection will eventually simplify that branch away.
Being honest about the ratio: only 200 of the 1,116 blocks, 18 percent, contain any of those cues. The large majority of comments in this repo are still headings and labels, and plenty of them could go.
The desktop app is twice as commented as the website
Splitting by package:
app/src 47 files 8,118 code 2,161 comment 26.6% server/src 79 files 9,802 code 1,228 comment 12.5% I did not do this deliberately, and when I saw it I spent a while deciding whether it is a problem. I think it is correct, for two reasons.
The website's behaviour is observable. If a page is wrong you load it and see that it is wrong, and the test suite that guards its prices and its metadata goes red. The desktop app's behaviour mostly happens while nobody is looking: a poll cycle at 3am, a baseline decision, a retry, a reply that must never be sent twice. The cost of guessing wrong is not a broken page, it is an email about a room that was let yesterday.
The other reason is that the app is where the arguments were. Website code tends to be the first implementation of a decision. App code here is often the third.
Eleven files where the prose outweighs the code
| File | Comment | Code |
|---|---|---|
server/src/lib/stripe/managed-payments.ts | 118 | 65 |
app/src/baseline.ts | 41 | 17 |
server/src/lib/alerts/types.ts | 39 | 38 |
server/src/components/pricing/Price.tsx | 36 | 22 |
server/src/lib/pricing/server-currency.ts | 34 | 6 |
app/src/reply-defaults.ts | 32 | 7 |
server/src/lib/pricing/constants.ts | 25 | 6 |
app/src/limits.ts | 14 | 1 |
server/src/lib/maintenance.ts | 8 | 4 |
server/src/lib/http/bots.ts | 7 | 6 |
server/src/lib/stripe/api.ts | 7 | 4 |
Five of the eleven are about money, which is the part of this product where being wrong is expensive and silent, and where the rules come from somebody else's documentation rather than from me.
app/src/limits.ts is the extreme case. One exported line, fourteen lines of comment:
/** * Maximum number of searches a user can monitor. * * Every search is scraped one after another inside a single poll cycle, so the * count directly sets how stale the slowest search gets. At ~3-5s per search, * 15 keeps a full cycle under a minute, beyond that the product stops being * "you hear about it first", which is the only reason it exists. */ export const MAX_SEARCHES = 15; A constant is the thing most likely to be edited by somebody who does not know what it costs. 15 looks arbitrary, and the only defence against a well-meaning bump to 50 is the paragraph above it.
app/src/baseline.ts is 62 lines total: a class wrapping a Set, four short methods, and 41 lines explaining a rule that went through two wrong versions before this one. The comment ends with a list of the things that must not re-base a search, because an earlier implementation re-based on a time threshold and that turned out to be exactly wrong.
Fifteen files with no comments at all
Also worth knowing: 15 of the 126 files contain no comment line whatsoever. Ten of those fifteen are UI components, hooks, or pure content arrays:
app/src/renderer/components/ActivityLog.tsx app/src/renderer/components/Toast.tsx app/src/renderer/components/ActivationScreen.tsx app/src/renderer/hooks/useToast.ts server/src/app/help/FaqAccordion.tsx server/src/app/help/ContactForm.tsx server/src/components/Nav.tsx server/src/lib/articles/guides.ts server/src/lib/articles/audiences.ts server/src/app/help/faq-data.ts I am not going to add comments to any of them. A component that renders a list of log lines, or an array holding the text of the guides and the help page, has no rejected alternative worth recording. The absence of comments there is the same signal as their abundance in limits.ts: prose shows up where decisions were contested.
What I will do differently
Nothing structural. Two habits confirmed and one caution.
The habit worth keeping is the rather than sentence. It costs one clause, it records the alternative, and it is the only form of comment I have never regretted writing. "NDJSON rather than sqlite" tells the next person that sqlite was considered and what would break.
The second habit is naming the dead design in the past tense. used to appears 17 times, and every one of them marks a trap somebody already fell into.
The caution is what I found in the last audit I did: three places in this repo describe a masking behaviour that no longer runs, and one of them is a published privacy policy. A comment that records why is still a comment. If the why stops being true, nothing fails, and 27,231 words is a lot of surface area for that.
If you want to run this on your own repo, the whole census is a line-classifier that tracks whether it is inside a block comment, plus collections.Counter over the phrases above. It is about twenty lines, and the ratio between rather than and because tells you something about your team that no linter will.


