Notifio lets you monitor up to 15 saved searches. People assume that number is a pricing lever, the sort of cap that exists so a bigger plan can sell you 50. It is not. There is one paid tier and the limit is the same on it. The 15 comes out of arithmetic, and the whole module is this:
/**
* Hard limits shared by the main process and the renderer.
*
* Kept in its own module (no imports) so the renderer bundle can pull the same
* numbers the server enforces, instead of drifting from a hardcoded copy.
*/
/**
* 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;
The number is the last line of a sum
The app is an Electron desktop client that drives a real browser over your saved searches. Those searches are checked one after another, not in parallel, because parallel means several simultaneous requests to the same portal from one household connection, which is both rude and the fastest way to get that connection treated as a bot.
Sequential checking makes the cap arithmetic rather than opinion. A search takes three to five seconds. The promise of the product is that you hear about a room before the people relying on the portal's own alert email do. Once a full sweep takes more than about a minute, the search at the bottom of your list is on a worse cadence than the daily digest you were trying to escape, and the product is lying.
Fifteen searches at five seconds is 75 seconds of worst case sweep. That is the edge of what the promise survives. So that is the limit.
What I like about writing it down this way is that it tells you exactly what would change it. Not "users asked for more", but a faster per search time. Halve the scrape and the honest cap is 30. The constant is downstream of a measurement, and the comment names the measurement.
Interval means start to start
The related decision is what "every 30 seconds" means when a cycle can take longer than 30 seconds.
/**
* How often each search should be checked. This is the interval between the
* *starts* of two cycles, not an extra pause bolted onto the end of one. The
* time already spent scraping counts towards it.
*
* Searches are scraped one after another, so a 15-search cycle can take longer
* than the interval on its own. When that happens the next cycle starts almost
* immediately: the sites at the end of the list have already waited far longer
* than 30s, and sleeping another 30s would only make them staler.
*/
const POLL_INTERVAL_MS = 30_000;
const elapsed = Date.now() - cycleStartedAt;
const target = randomDelay(POLL_INTERVAL_MS, POLL_JITTER_MS);
const delay = Math.max(MIN_GAP_MS, target - elapsed);
setTimeout(tick, 30_000) at the end of the work is the version almost everyone writes first, and it quietly turns a 30 second interval into a 30 second gap. With a full list that is a 75 second cycle plus 30 seconds of sleep, so your slowest search is refreshed every 105 seconds while the log cheerfully says every 30.
Subtracting the elapsed time fixes it, and then you need a floor, because the correction on an overrunning cycle is otherwise zero:
/**
* Floor between cycles, even when one overran the interval. Back-to-back with
* no gap at all leaves no room to hit Stop.
*/
const MIN_GAP_MS = 5_000;
Five seconds is not about pacing the sites, the per host backoff handles that. It is so the event loop, the UI and the person clicking Stop all get a turn.
The order is shuffled, and that is a fairness fix
// Vary the order. Checking the same searches in the same sequence every 30
// seconds for hours is a pattern in its own right, and it also means the
// search at the bottom of the list is always the last to hear any news.
const results = await scrapeAll(shuffled(due), log, hooks);
A fixed order gives the search you added first a permanent advantage of up to a minute over the one you added last, every cycle, forever. Nobody orders their saved searches by how much they care. Shuffling converts a systematic penalty into noise that averages out across cycles.
One constant, three places it has to be true
// server.ts
if (cfg.sites.length >= MAX_SEARCHES) {
return res.status(409).json({
error: `You can monitor up to ${MAX_SEARCHES} searches. Remove one to add another.`,
});
}
// SitesList.tsx
import { MAX_SEARCHES } from "../../limits";
const atLimit = sites.length >= MAX_SEARCHES;
The renderer disables the add button and shows "12 of 15", the local API refuses with a 409 and the same sentence, and both read the one constant. The module has no imports at all, which is the only reason the renderer bundle can pull it without dragging Node built ins into the browser context.
That is a small thing, but the alternative is the failure mode every app with a limit eventually has: a UI that says 15, a backend that enforces 20, and an error message that is a second copy of the sentence and gets edited only in one of the two places.
Where the rest of the cadence lives
Two related pieces have their own posts. When a portal pushes back, only one search on that host goes back in first, and the rest follow in the same cycle once the probe returns clean: When a block clears, only one search goes back in. And the wait a refusal earns is keyed by host rather than by search, which was a real bug before it was a design: Our retry ladder was keyed by the wrong thing.
If you want to see what the cadence buys, notifio.app/alerts lists what each portal's own alert email actually does, one page per site, for example Rightmove and Kamernet. The download is at notifio.app/download.
Top comments (0)