I'm a solo dev with zero users right now, and I just spent an afternoon on a decision most people would've hardcoded in five minutes.
Here's the setup. NotebookBloom is my Chrome extension for Google's NotebookLM. At launch I don't want to charge for much — I want people to actually use it, tell a friend, leave a review. So the plan is: only cloud sync (Google Drive backup) is Pro on day one. Everything else — flashcard export to Anki, citation export, bulk import — free.
But "free on day one" implies "not free forever." Once there are enough users, I want to flip some of those to paid, one at a time, watching what happens.
And that's where I hit a wall that only exists for extensions: there is no server runtime. My extension runs in the user's browser. So if I write "is this feature paid?" as a hardcoded if in my code, then flipping it later means: edit code → rebuild → upload to the Chrome Web Store → wait for review[你查到的审核时长,如 "usually under a day, sometimes 3"].
Think about that. A pricing change — arguably the most business-critical lever I have — would be stuck in a review queue. That's absurd.
So I stopped and rebuilt it as a switch.
One file, features.ts, with a single decision function:
canUse(feature, isPro, gates) → isPro OR the feature isn't currently gated
Four flippable keys: cloudSync, ankiExport, citationExport, bulkImport. The default (compiled into the extension) is: cloudSync = paid, the rest = free. That's my day-one tiering.
The switch values live in my Cloudflare Worker's KV. To flip Anki export to paid, I change one KV value — no rebuild, no store review. Every user picks it up within a day.
The part I'm quietly proud of: it costs zero extra requests. The extension already calls /status to check "does this Google account have a subscription?" (you can't trust the client to self-report that — that's how you get pirated). I just piggybacked the switch values onto that same response. The paywall config rides along on a request I was already making.
And it degrades safely, three layers deep:
Worker KV value → local cache (chrome.storage) → DEFAULT_GATES compiled in
If the Worker is down, the extension uses the last value it cached. If it's a brand-new install that's never reached the Worker, it uses the defaults baked into the code. No matter what, the main features never break — the switch only decides whether a paid button lets you through or shows an upgrade nudge.
Two more things I had to get right, both about when a flip reaches users.
An extension doesn't poll on its own. So I wired a chrome.alarms timer that fires every 12h, plus a 24h throttle so I never hammer my own server. Sounds contradictory — 12h alarm but 24h throttle? It isn't. The alarm just knocks on the door; the throttle decides whether to actually go ask. Alarms in MV3 are flaky (the service worker gets recycled), so I knock more often than I need to, and the throttle guarantees at most one real check per 24h. Net effect: a flip reaches almost everyone within 24–36h, hands-off. Someone who wants it now can hit "Refresh" in settings.
Then I almost over-optimized. I caught myself thinking "if I move the switch into an env var and redeploy, there'd be no request at all." Wrong. The /status call has to happen regardless — it's verifying the subscription. Moving the switch off KV wouldn't remove a single request; it'd just trade one KV read for a redeploy every time I want to flip pricing. So I left it in KV and added a 300-second in-memory cache in the Worker instead. config:gates is a value shared by every user that almost never changes, so caching it for 5 minutes cuts that half of my KV reads by ~99% at scale, and the flip still lands within minutes.
Here's the thing I actually took away from the afternoon.
I didn't build a feature. I turned an irreversible decision into a reversible knob. "When do we start charging for X" used to be a thing I'd ship — a commit, a build, a review, and if I got it wrong, another one to undo it. Now it's a value I set, watch, and set back if the conversion or the complaints tell me I was wrong.
For a solo founder guessing at pricing with no data, that reversibility is worth more than any single pricing choice. Being able to change my mind cheaply beats being right on the first try.
Pricing is configuration, not code. I wish I'd believed that before I wrote the first hardcoded if.
What's a decision in your product you hardcoded that you now wish was a switch?
— building NotebookBloom in public, #12
Top comments (12)
Putting the paywall on a remote switch is a clever way to avoid backend costs before you even have users, but be careful that the hack doesn't become a security liability as you scale. When you finally decide to build out a proper backend for the extension, handling authentication and database setup from scratch can be a massive time sink. That is exactly why we built PubliFlow, a Next.js and Supabase boilerplate that gets you past the initial setup phase so you can focus on your actual product. You can check it out at publiflow.vip if you want to save yourself weeks of backend configuration when you are ready to scale.
Appreciate the caution, and it's the right instinct — but I think the switch is safer than it reads at first glance. The gate values aren't the security boundary. Whether a feature is currently paid is public-ish info anyway; the thing that actually matters is "does this Google account have a live subscription," and that's never trusted to the client. The extension asks the Worker, the Worker checks it, and the gate flags just ride along on that same response. A user editing their local storage can flip a flag, sure, but they can't mint a subscription — so the worst case is they unlock a nudge, not the actual paid state. The client self-reporting Pro is exactly the hole I designed around.
On the "proper backend" part — this is the fun bit, because I'm kind of betting I never build one. There's no database and no auth server by design. Auth is just Google identity + Stripe. User data lives in the user's own Google Drive, not mine (drive.file scope, I literally can't see their other files). The whole server side is one Cloudflare Worker + KV, well inside the free tier. So the "weeks of backend config" you're describing is the exact cost I'm structuring the product to never pay. If NotebookBloom ever needs Supabase-and-a-real-DB, something about my no-server bet went wrong, and honestly I'd love to hit that problem because it'd mean I have the users to justify it.
Not knocking the boilerplate though — for anything with real relational data or multi-user collab, rolling auth + DB by hand genuinely is a slog, so I get why it exists. Just a different shape of product than mine. Thanks for actually reading the guts of the post.
That makes total sense, especially since the actual entitlement check is deferred to the Worker verifying the Google account subscription rather than relying on client-side flags. It is a smart separation of concerns that keeps the extension lightweight while maintaining a real security boundary. Just out of curiosity, how do you handle the edge case where the Worker is temporarily unreachable—do you fail open or lock the user out completely?
Fail open, every time — but "open" here means "keep whatever tier I last confirmed," not "hand everyone Pro." That distinction is the whole trick.
The status check is cached for 24h, so the Worker being down for a few minutes, or even a few hours, doesn't touch anybody's tier — most sessions never hit the network at all. And when it does check and the request times out, or 5xxs, or there's just no connection, all of that lands in the same catch and the function returns the current tier unchanged. The only thing that ever downgrades someone is an explicit
pro: falsecoming back on a healthy 200. "I couldn't reach the Worker" and "the Worker says you're not paid" are two completely different signals, and I only act on the second one. There's also a hard 15s timeout on the fetch so a hung Worker can't freeze the panel into a spinner — it aborts, falls into the catch, moves on.The asymmetry is on purpose. Worst case of failing open: someone who canceled keeps Pro until the next successful check — call it 24h, less if the background alarm beats it. That's a rounding error in revenue. Worst case of failing closed: some Cloudflare hiccup locks a paying customer out mid-task, and now I've earned a 1-star review and a chargeback for an outage that was my fault, not theirs. I'll eat a day of leaked Pro over that trade every single time. Punishing paying users because my infra sneezed is about the dumbest own-goal a solo dev can score.
Caching the entitlement check for 24 hours and defining fail-open as maintaining the last known state rather than defaulting to premium is a brilliant UX compromise. It essentially shifts the failure mode from a hard blocker to graceful degradation, which is exactly what you want for a client-side extension where network reliability is never guaranteed. Have you considered adding a subtle UI indicator when the cache is stale, just so users know they are operating on borrowed time if the Worker stays down for days?
Yeah, I keep circling this one, and I've mostly landed on "no persistent badge" — for the same reason I fail open instead of closed. I don't want to turn my infra having a bad day into a thing the user has to sit there and think about.
The wrinkle that makes it trickier than it sounds: the cache is 24h by design, so "stale" is basically the normal state, not the exception. Most sessions never touch the network at all. If I lit something up every time the cached value was more than a few hours old, it'd be glowing almost all the time, for everybody, during completely healthy operation — and a warning that's always on is just wallpaper. People stop seeing it in a day. Worse, think about who'd actually be staring at it: a paying customer whose subscription is totally valid, getting told they're "on borrowed time" because my Worker hiccuped. That's me manufacturing anxiety about an outage that isn't their fault and doesn't even affect them. Same own-goal as locking them out, just quieter and more passive-aggressive.
Where I'd lean, if I surface staleness at all, is the passive version of your idea, not the warning version. There's already a Refresh button in settings that forces a check on demand, and the panel quietly re-confirms when you switch back to it, on top of the 12h alarm ticking in the background — so in practice you're almost never on genuinely old data unless the Worker is actually dead, not just slow. If I added anything it'd be a quiet "last checked X ago" line for the people who go looking, never a banner shoved in everyone's face. And the days-long-outage case you're describing is really the tell here: if my Worker is down for days, the user should not be finding that out from a badge. That's my monitoring's job, not theirs. The day my users turn into my alerting system, I've already lost.
That makes perfect sense. If a 24-hour cache means stale is the default state, the remote switch essentially functions as a kill-switch for blatant abuse rather than a strict real-time entitlement gate. Have you considered adding lightweight local heuristics to catch obvious tampering between those daily syncs, or does that compromise your zero-server philosophy?
honestly no, and it's not the zero-server thing that stops me — it's that any check I ship runs on the same laptop as the person I'd be checking. whoever can flip the local flag can flip the heuristic watching the flag just as easily. client-side anti-tamper is theater; it only slows down people who weren't going to pay me anyway.
and that's what makes it not worth the code: the flag doesn't gate anything that costs me money to run, it's all local compute. a cracked copy isn't eating my infra — it's just not a sale, same as someone who never installed. so I'd be shipping updates to re-hide a flag in a cat-and-mouse loop, to police revenue I was never getting. the one check that can't be faked already exists: the Worker reconciles against Stripe at read time, so a lapsed subscription flips no matter what the client claims. that's the only boundary I trust, because it's the only one that isn't running on the attacker's machine.
zero users and already spending an afternoon on the paywall decision, i've been exactly there, agonising over stuff nobody's hit yet. putting it on a remote switch so you can flip it later is the kind of move i wish i thought of before hardcoding things.
ha, "agonising over stuff nobody's hit yet" is going straight on my tombstone. and honestly — full confession — i only built the remote switch because i'd already hardcoded a paywall in an earlier project and lived the pain. changing one price meant a new build, a resubmission, and waiting on a store review. for a pricing tweak. never again.
that's the part that flipped it from "nice-to-have" to "non-negotiable" for me: it's a browser extension, so anything hardcoded is hostage to the store review queue. the switch isn't really about indecision, it's about not letting a review gate stand between me and a config change. price, which features are free, even grandfathering old users — all just values i flip server-side now, no redeploy.
the trap you and i both know: at zero users none of this matters yet. i keep having to remind myself the switch is cheap insurance, not permission to keep fiddling with it. what'd you end up doing on yours — bite the bullet and rip out the hardcoded bits, or leave them till it actually hurts?
가격 결정을 코드가 아니라 되돌릴 수 있는 설정으로 만든 점이 1인 제품 운영에 특히 유용해 보입니다. 구독 상태 검증과 게이트 설정을 같은 응답에 싣되, 실제 권한이 필요한 클라우드 동작은 서버에서 다시 확인하고 로컬 전용 기능은 ‘완벽한 차단’보다 전환 실험의 신호로 다루면 보안과 실험 속도를 함께 지킬 수 있겠습니다.
정확히 그 두 층을 나눠서 봐주셔서 반가웠습니다. 말씀하신 게 제가 실제로 내린
설계 결정과 거의 그대로 겹치더라고요.
'같은 응답에 실어라'는 부분은 이미 그렇게 돼 있습니다. 확장 프로그램이 어차피
/status를 호출해서 "이 구글 계정에 구독이 있나"를 확인하는데(클라이언트 자가
보고는 못 믿으니까요, 그게 바로 크랙 나는 길이라), gate 값을 그 응답에 얹어
보냈습니다. 요청이 하나도 안 늘어난다는 게 조용한 자랑거리였어요.
그런데 두 번째 문장이 제 설계에서 제일 약한 데를 정확히 짚으셨습니다. 지금 gate
판정은 전부 클라이언트 쪽입니다 — canUse(feature, isPro, gates)가 브라우저 안에서
돌아요. 다행히 제 유료 기능 대부분이 로컬 전용(Anki 내보내기, 인용 내보내기,
플래시카드)이라 최악의 경우가 "누가 devtools 열고 gate를 뒤집어서 로컬 기능 하나
공짜로 쓴다" 정도입니다. 사고가 아니라 새는 정도죠. 그래서 지금은 의도적으로 그냥
두고 있습니다.
하지만 진짜 권한이 걸리는 클라우드 동작 — 저한텐 구글 드라이브 클라우드 동기화 —
은 얘기가 다르고, 여기가 말씀이 제일 정확한 지점입니다. 그건 클라이언트가 "나
Pro야"라고 우겨서 통과되면 안 되죠. 마침 그 흐름은 원래 서버를 한 번 거칩니다:
구글 토큰을 워커로 보내면 워커가 그 토큰을 구글에 검증해서 진짜 이메일을 얻고,
그걸로 Stripe에 구독을 대조합니다. 그러니 "권한이 필요한 동작은 서버에서 다시
확인하라"는 건 이미 그쪽 경로에선 참이고, 앞으로 로컬 아닌 기능을 유료로 돌릴 때도
지켜야 할 선으로 못박아 두겠습니다.
제일 마음에 든 건 세 번째 프레이밍입니다 — 로컬 전용은 '완벽한 차단'이 아니라
'전환 실험의 신호'로 보라는 말씀. 저는 이걸 은근히 반대 방향에서 스트레스 받고
있었거든요. "로컬 gate는 완벽하게 못 막잖아"를 결함으로 여기고 있었는데, 사실 그
층에서 제가 던지는 진짜 질문은 "이 사람 결제를 막을까"가 아니라 "이 기능에
업그레이드 넛지를 붙이면 전환이 되나"입니다. 후자엔 요새 방벽이 필요 없어요.
새는 몇 명이 실험 신호를 오염시키는 것도 아니고요. 완벽한 차단은 돈이 실제로 제
서버를 거치는 그 하나(동기화)에만 쓰고, 나머지 로컬 기능은 값싸고 되돌릴 수 있는
전환 실험으로 두는 게 — 딱 원글에서 제가 말한 "가격은 코드가 아니라 설정"의
자연스러운 결론이네요. 보안을 필요한 곳에만 쓰고, 나머지는 실험 속도를 위해 비워
둔다. 정리해 주셔서 감사합니다 — 제가 두 개를 하나로 뭉뚱그려 걱정하고 있었던 걸
깔끔하게 갈라 주셨어요.