Six weeks ago I started building SlotOwl — a Chrome extension that watches
government appointment portals (visa, immigration, passport, Global Entry)
and notifies you the moment a slot opens. This week I shipped it.
This post is about ONE design decision I made early on that turned out to
shape the whole product: I scrape inside the user's browser tab instead of
on a server somewhere.
If you're building anything that watches a third-party website on a user's
behalf — appointment monitors, restock alerts, ticket trackers, hotel-rate
watchers, anything — I think this pattern is worth considering.
The problem in 30 seconds
Government appointment portals are a nightmare. US visa dropbox, Schengen
visa, INM Mexico cita, passport renewals, Global Entry — all of them
release slots at random hours, and slots get grabbed in 6 minutes.
The existing tools to catch a slot fall into two camps:
- Manual — sit on F5 for hours/days
- Sketchy paid bots — $50–200 services that ask for your portal login and run a scraper on their server farm
Camp 2 has three structural problems:
- Security: sharing your portal login with a third party is, at best, against the portal's ToS, and at worst gets your account locked
- Reliability: server-side scrapers get IP-banned constantly, breaking for hundreds of users at once
- Scale economics: every user costs CPU + bandwidth on the operator's servers
I wanted a third option. The simplest version of that idea: what if the
scraper just... ran in the user's own already-logged-in browser tab?
The architecture
Here's the whole thing on a napkin:
┌────────────────────────────────────────────────┐ │ User's Chrome browser │ │ │ │ ┌─────────────┐ ┌──────────────────┐ │ │ │ Portal tab │ ←poll── │ Service worker │ │ │ │ (logged in) │ │ (background) │ │ │ └─────────────┘ └────────┬─────────┘ │ │ │ │ │ │ "slot found" └────────────────────────────────────┼───────────┘ │ ▼ ┌─────────────────────────┐ │ Firebase Cloud Func │ │ alertFanout │ └────┬──────┬──────┬──────┘ │ │ │ email push desktop
Important: the portal HTML never leaves the browser. The only thing that
travels to my server is "workflow X went available at timestamp T". That's
the entire payload.
Why this beats server-side scraping
1. Security: zero shared logins
The user is already logged into the portal in their own browser. The
extension's content script reads the page DOM in that tab. We never see
the user's portal credentials, never store them, and never send them
anywhere.
If you're a security-minded user, you can audit the extension's source
and verify this in 10 minutes. With a server-side competitor, you have
to take their word for it.
2. Anti-ban: each user looks like one human
Server-side scrapers funnel hundreds of users through a small pool of IPs
and user agents. Portals notice this pattern within days and IP-ban the
operator, breaking the service for everyone.
When the scraper IS the user, that pattern disappears. Each user's
traffic looks like — well, that user. There's nothing to fingerprint
beyond "this person opens the portal page periodically", which is
indistinguishable from a real user being slightly anxious.
3. Cost economics: zero per-user CPU on my server
The polling is happening on the user's machine. My only server-side
work is the alert fan-out (an HTTP call → Firestore write → email +
push). At 1000 active users, my Firebase bill is < $50/mo. A
server-side equivalent would be running a small fleet of headless
browsers around the clock.
4. Captcha resilience: the user solves it
Portals often throw captchas to deter automation. A server-side scraper
gets stuck or has to pipe the captcha to a human-solver service (slow,
expensive, sketchy).
In my model, when the polling script hits a captcha, the page state
becomes "captcha required" and we fire that as the alert. The user
solves the captcha (it's their browser!) and polling resumes. No
automated solving, ever. By design.
The downsides (because there always are some)
1. The user has to keep their browser open (or service-worker awake)
Chrome aggressively suspends extension service workers. To keep polling
running, I use the chrome.alarms API with a 1-min minimum, which
wakes the service worker briefly to do its check.
This is reliable enough but it does mean if the user closes Chrome
entirely, polling pauses. For most use cases this is fine — they only
need monitoring during the 12 hours when slots could realistically open.
2. Per-user polls are slower than centralized polls
A server farm could in theory check the portal every 10 seconds for
all users at once, then fan out. My architecture polls every ~2 minutes
per user, per workflow. So in theory, the centralized version catches
a slot 1.5 minutes faster on average.
In practice, slot windows are 5–15 minutes wide on the portals I've
tested, so a 2-min poll catches them comfortably. The structural
benefits (security, anti-ban, cost) easily outweigh the polling lag.
3. Workflow definitions need to be portable
Server-side scrapers can hard-code per-portal logic. I need users
(or me) to define workflows declaratively, because the same extension
runs against many portals.
Solution: workflows are JSON definitions:
{
id: "schengen-stockholm",
entryUrl: "https://visa.vfsglobal.com/swe/en/...",
selectors: [
{ match: "no available slots", state: "unavailable" },
{ match: "available", state: "available" }
]
}
Anyone can define a new workflow without me shipping code. (In practice
I curate the popular ones.)
Stack details (the boring-but-useful section)
- Extension: Manifest V3, vanilla JS (no React/Vue — fewer build steps, smaller bundle, faster to iterate). esbuild for bundling.
-
Backend: Firebase Cloud Functions (Node 20). One function per
responsibility —
alertFanout,linkMintToken,linkConsumeToken,webPushSubscribe,sendEmail,getUsage,joinWaitlist, etc. Eleven functions total. Each is small enough to keep in your head. -
Database: Firestore. Workflows under
users/{uid}/workflows/{id}, alert quotas underusers/{uid}/usage/{yyyy-mm}. - Email: Resend. Way cleaner API than SES or Mailgun for transactional.
- Cross-device push: Web Push API + VAPID keys. I considered Firebase Cloud Messaging but went with raw Web Push because (a) one fewer dependency, (b) when iOS Safari fully ships push to homescreen apps, Web Push will work natively. FCM would have meant another adapter.
- Marketing site: hand-rolled static site (no Next.js, no Nuxt). A build script reads partials and writes the dist folder. Total weight is ~30 KB CSS + 12 KB JS.
What I'd do differently
If I were starting over today, three things:
Define the workflow JSON schema even more strictly, sooner. I
added a Zod schema in week 4. Should have done it day 1 — would have
saved me from a class of "user submitted half-broken workflow" bugs.Build the alert quota system before the alert system. I built the
alerts first, the quotas later. The day I added quotas, I had to
retrofit every existing alert path. Quotas first would have been
trivial.Treat the privacy story as the marketing story from day 1. The
biggest objection to "an extension that watches portals" is "wait,
is this safe?" Spending a weeks polishing the privacy policy
wording is doing marketing, not legal.
What's next
SlotOwl is currently in Chrome Web Store review (3 days in, fingers
crossed). The waitlist is at
— if you (or anyone you know) is hunting an appointment, please share.
Honest about the future: I don't know yet whether this is a $1k MRR
side project or a real business. I'll know more after the first 100
real users tell me what they're willing to pay.
If you're building something similar, or you've shipped a Chrome
extension recently and have war stories, I'd love to hear from you.
DMs are open on insta @greythinkinglab.
— Nik / greythinkinglab
Day 19 of a 150-day solo-founder challenge. Onwards.
https://greythinkinglab.com
Top comments (0)