DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

One Scrape Per Category, Not Per User: How I Kept a Job-Alert SaaS From Going Broke

I run a small job-alert product called Upwork Scout. Users set filters, describe themselves once, and it emails them only the Upwork jobs that actually fit their profile. Simple pitch. The interesting part is a constraint I gave myself on day one: the scraping cost has to stay roughly flat no matter how many people sign up. Here is how I built it so that adding a user costs me almost nothing, plus a few of the boring engineering decisions that ended up mattering more than the flashy ones.

The trap most alert tools fall into

The obvious way to build a job-alert tool is to loop over your users and, for each one, run their search against the source and email them the results. It works great with ten users. It quietly bankrupts you at ten thousand, because your scraping bill grows linearly with signups. Every new user becomes a new recurring cost, and scraping is by far the most expensive thing the app does.

I did not want a business where growth is a liability. So before I wrote the scan engine I flipped the model.

One scrape per category, not per user

The realization that made the whole thing viable: most users are not that unique. Two freelancers who both want "AI automation" work are searching for nearly the same jobs. So instead of scraping per user, I scrape per category.

A category is a single Upwork boolean search defined in code. Every cycle, the engine looks at which categories have at least one active subscriber, scrapes only those, stores the fresh jobs once in Firestore, then matches every user against that shared job stream in memory.

          category A query
cron  --> category B query --> dedupe + store new jobs --> match each user --> email
          category C query          (Firestore)             (in-memory)      (instant/digest)
   (only categories with at least one active subscriber are scanned)
Enter fullscreen mode Exit fullscreen mode

The effect is that my scraping cost tracks the number of active categories, not the number of users. There is a hard cap on categories, so there is a hard ceiling on spend. Two hundred users subscribed to the same three categories cost me exactly the same to scan as two users subscribed to those three categories. In-memory matching is basically free by comparison, so the expensive part of the pipeline stopped scaling with growth.

That one inversion is the difference between a tool I can afford to run and one I would have to shut off.

Two-stage matching: cheap filter, then a paid opinion

Matching a user to a job happens in two passes, and the ordering is deliberate because the second pass costs real money.

The first pass is a pure, deterministic function. Does this job satisfy the user's hard filters: category or keyword hit, budget range, experience level, client spend, client rating, country, payment-verified, and a dozen more. No network, no model, just booleans. It throws out most jobs for free.

Only the survivors reach the second pass, which is the AI scoring. I send the job and the user's self-description to Claude and get back a 0 to 100 fit score and a one-line reason. I run it on Haiku by default because it costs about a tenth of a cent per score, and I can point it at a bigger model with one env var if match quality ever matters more than cost on a given day.

The important discipline here is to never let the expensive pass do work the cheap pass could have done. If the deterministic filter can reject a job, the model never sees it. The model is there for the fuzzy judgment a boolean cannot make, "is this actually the kind of work this person wants," not for anything a comparison operator already answered.

Reject only on positive evidence

This is the detail I am most quietly proud of, and it came from watching real data. Upwork does not always expose every field. Older records were missing things, and sometimes a field is just hidden on a given posting. My first version treated missing data as a failed filter, which meant a perfectly good job with a blank client-spend field got silently dropped. Users were missing work and I could not see why.

So I made the policy explicit and consistent across every filter: a filter only rejects a job on positive evidence. If the data needed to fail a check is missing, the job passes and the interface flags the value as unknown rather than pretending it failed. The user decides what to do with an unknown, instead of the system deciding for them by omission. Silent false negatives are the worst bug class in an alert tool, because nobody complains about the email they never got.

Caching the model's opinions

The other cost lever is that a job is never scored twice for the same user. Every AI verdict gets cached, keyed by user and job id, with the score and the reason. If a job resurfaces in a later cycle, the cached verdict is reused. Combined with a platform-wide daily fetch budget that hard-stops scraping once it is hit, the two caps mean the app cannot run away with my money even on a busy day. I would rather deliver slightly late than wake up to a surprise bill.

The boring decisions that carried the most weight

A few unglamorous choices did more for reliability than the clever ones:

No password database. Auth is passwordless magic links, and sessions are signed JWT cookies. There is no password table to leak, no reset flow to build, no hashing to get wrong. The whole surface I would have had to secure simply does not exist.

A self-locking cron endpoint. The engine is one route you hit on a schedule from an external pinger. It acquires a short self-expiring lock before it runs, so if two pings overlap, the second one does nothing instead of double-charging me for scraping and double-emailing users. The endpoint is idempotent by design, which means I stopped worrying about exactly-once scheduling and just let the lock handle it.

Lazy secret reads. Every secret is read at request time, not at import time, so the build never needs any of them. That sounds trivial until you have a CI build fail because a key was not present at compile time for code that only needs it at runtime.

What building it taught me

The lesson I keep relearning is that the architecture decision you make before the first real user is worth more than any optimization you make after. I could not have refactored my way from per-user scraping to per-category scraping once the data model assumed one search per person. It had to be the shape of the thing from the start.

The second lesson is that in a tool whose entire job is to notify you, the failure you cannot see is the one that kills trust. The reject-on-positive-evidence rule and the "flag the unknown" interface came from taking that seriously. An alert product lives or dies on the emails it does not wrongly withhold.

It is built on Next.js 16 on Vercel, Firestore, Apify for the scraping, Resend for email, and Claude for the scoring. If you freelance on Upwork and want to see the matching in action, it is live at upwork-scout.com. And if you are building anything that scrapes or calls a paid API per user, ask yourself the one question that saved this project: what can I compute once and share, instead of computing per user and paying for it forever.

Top comments (0)