I run a site with 38 free developer tools on it. JSON formatter, JWT decoder, base64, PDF merge, OCR, background remover — the usual set. There are 113 URLs in the sitemap, JSON-LD on every tool page, an llms.txt, markdown twins of every page for AI crawlers, and three analytics vendors wired up.
Last quarter it got 24 visitors from organic search.
Not 24,000. Twenty-four. Over 90 days.
I finally sat down and read my own analytics properly instead of glancing at the top-line number. Here's what was actually going on, including one bug that had been quietly corrupting every funnel I'd ever looked at.
The top-line number was lying
Here's what PostHog showed for the trailing 90 days:
| Metric | Value |
|---|---|
| Visitors | 735 |
| Pageviews | 1,121 |
| Bounce rate | 75.3% |
| Avg. session | 43.7s |
735 isn't great, but it's a number you can tell yourself a story about. Then I broke it down by country:
| Country | Visitors | Pageviews | Pageviews/visitor |
|---|---|---|---|
| CN | 344 | 344 | 1.00 |
| US | 222 | 224 | 1.01 |
| SG | 93 | 247 | 2.66 |
| IN | 20 | 67 | 3.35 |
| AE | 14 | 165 | 11.8 |
Look at the last column.
344 visitors and exactly 344 pageviews. Not 343, not 350. Every single one landed on one page and left. Same for the 222 from the US, at 1.01.
I can't prove those are bots. But humans don't behave with that kind of arithmetic precision at scale — real traffic has a long tail of people who click a second link. A ratio pinned to exactly 1.00 across hundreds of sessions is a script, and one that runs enough JavaScript to fire a pageview, which is why a server-side user-agent block never caught it.
That AE row at 11.8 pages per visitor? That's me. I live there. I was a meaningful fraction of my own "engaged" traffic.
Strip out the automation and my own browsing and the real number is somewhere under 150 humans a quarter.
Lesson one: a visitor count you haven't segmented is not a number, it's a vibe. Pageviews-per-visitor by country took thirty seconds and invalidated a year of assumptions.
The bug that was breaking every funnel
While I was in there, I found this in my server-side analytics helper:
client.capture({
distinctId: distinctId || "anonymous",
event: eventName,
// ...
});
Every server-side event that didn't get an explicit ID was attributed to a person literally named "anonymous".
Not "anonymous" as a concept. "anonymous" as a primary key.
So every API call from every visitor on every route collapsed into one omniscient super-user with tens of thousands of events. Which means:
- Any funnel spanning a client event and a server event could never convert, because the two halves belonged to different people.
- "Users who did X" was meaningless for anything server-side.
- A big chunk of my "Direct" traffic was this phantom.
The fix is unglamorous — pull the real ID out of the cookie that posthog-js already sets:
export function getDistinctIdFromRequest(request: Request): string | null {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (!key) return null;
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null;
const name = `ph_${key}_posthog`;
// ...parse the cookie, pull out distinct_id
}
Lesson two: || "anonymous" is not a fallback, it's a data corruption bug. If you can't identify someone, drop the event or use a per-request ID. Never merge unrelated humans under a shared sentinel string. I'd had this in production for months.
Why 38 tools rank for nothing
This part was harder to admit.
My tools target json formatter, base64 decode, jwt decoder, merge pdf. I'd been treating "no traffic" as a quality problem — surely if I made the JSON formatter nicer…
But those queries are owned by jwt.io, jsonformatter.org and iLovePDF, sites with fifteen-plus years of backlinks. A new domain with no authority does not rank there. Not with better UX, not with perfect Core Web Vitals, not with immaculate JSON-LD. The SEO work wasn't underperforming; it was aimed at a wall.
Building tool #39 in the same category would have produced exactly the same 24 visitors.
Lesson three: check whether the race is winnable before optimising your running form. I had genuinely good technical SEO pointed at keywords I had no business competing for.
The actual problem: nothing could spread
Once I stopped blaming search, the real gap was obvious. My site had:
- No dynamic OG images — every share used one static JPEG
- No way to link to a result, only to a tool
- No streaks, no leaderboards, no daily anything
- Five games that stored score in
useStateand lost it on refresh
There was no mechanism, anywhere, by which one visitor could produce a second visitor. I'd spent a year on supply and zero on distribution.
So I built the missing half.
Shareable result cards, without building a defamation machine
The idea is standard: encode a result into the URL, render a personalised OG image, so sharing a result previews the actual result.
The interesting part is what you must not do.
That ?r= parameter is attacker-controlled, and it ends up inside an image served from my domain, under my branding, previewed in Slack and WhatsApp and X. If it can carry free-form text, I haven't built a share card — I've built a machine that renders arbitrary words as an official-looking graphic from my site.
So the payload carries numbers and enum members only:
const cvRoasterResult = z.object({
v: z.literal(1),
k: z.literal("cv-roaster"),
s: z.number().int().min(0).max(10), // score
i: z.enum(["gentle", "honest", "savage", "legendary"]),
});
Every word on the card comes from a server-side phrase bank, selected by those numbers. The URL supplies data; the server supplies language.
A nice side effect: with a closed vocabulary there's nothing worth forging, so the payload doesn't need signing — and shared links keep working across a secret rotation. The worst a tampered code can do is claim a score you didn't earn, which is a bragging problem, not a security one.
Two things that cost me time, both in Next.js 16:
searchParams is a Promise now. Forgetting to await it in generateMetadata doesn't throw. You get an object whose fields are all undefined, so every shared link silently falls back to the generic card and the entire feature dies with no error anywhere. I now assert against it in an end-to-end test that fetches a ?r= URL and greps the HTML for the OG route.
Don't use the edge runtime for ImageResponse reflexively. The moment your route imports anything touching Node crypto or your KV client, edge bundling gets unhappy. Node runtime works fine. And skip custom fonts in v1 — fs.readFileSync("public/fonts/…") works locally and 404s in production because file tracing can't see it.
Running untrusted regex without hanging the tab
For the daily game I needed to execute a player-supplied regular expression on every keystroke.
That's a ReDoS gun pointed at my own users. A pattern like (a+)+$ backtracks exponentially, and on the main thread that means the tab stops responding mid-typing — which reads as "this site is broken," not "clever puzzle."
Three layers:
- Cap the length. 64 characters. It's a golf game; nothing legitimate needs more.
-
Reject the obvious shapes — nested quantifiers,
.*.*, huge bounded repeats, lookbehind. - Run it in a Web Worker with a watchdog.
Layer three is the one that actually matters, and the important detail is where the timer lives:
timerRef.current = setTimeout(() => {
worker.terminate();
workerRef.current = null;
setState({ message: "That pattern took too long to run…" });
}, 250);
The watchdog cannot live inside the worker. A worker stuck in catastrophic backtracking can't process its own messages — it can't time itself out and it can't be asked to stop. terminate() from the outside is the only thing that works. Then you spawn a fresh one.
The watchdog then shipped a bug of its own
I loaded the worker the way the docs suggest:
new Worker(new URL("./regex-worker.ts", import.meta.url))
In a production build, that emitted no worker chunk. And here's the part that cost me the most time: new Worker does not throw when its URL 404s. The failure arrives later, on onerror, which I wasn't handling.
So the worker never loaded, never replied, and the watchdog fired every single time. The game confidently told everyone — including me, typing a correct seven-character answer — that their pattern was "probably backtracking". It worked perfectly on localhost.
The real lesson isn't "test in production", it's that I had encoded an assumption I never checked: that silence means slow. Silence also means broken. Those need different handling:
const everWorked = workerProven.current;
if (!everWorked) {
// Never answered anything — this is a loading failure,
// not a runaway pattern. Don't blame the player's input.
workersUsable.current = false;
runInline(pattern);
return;
}
The worker is now built from a Blob, which has no bundler dependency and can't regress the same way, and onerror falls back to the main thread. That fallback is safe here precisely because the guards already ran and the subject strings are my own and short.
The thing I actually built
Regex Golf Daily — one puzzle a day, the same for everyone.
You get five strings to match and five to reject. Write the shortest regex that matches every one on the left and none on the right. Live feedback as you type. Your score is the character count.
Day selection is a pure index into a curated bank, keyed on the UTC day:
export function dayIndex(now: Date = new Date()): number {
const utcMidnight = Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()
);
return Math.floor((utcMidnight - DAILY_EPOCH_UTC) / MS_PER_DAY);
}
UTC matters more than it looks. If the day boundary is local, two players in different timezones get different puzzles and any comparison between them is meaningless. There's no fixing that after launch.
No seeded generator, either. Generating good regex puzzles is a research problem; curating 30 is an afternoon. And a pure index means "which puzzle did people see on day 40" stays auditable.
One test I'd push anyone building a daily game to write:
it("reference matches every 'match' string", () => {
const re = new RegExp(puzzle.reference);
for (const s of puzzle.match) expect(re.test(s)).toBe(true);
});
Every puzzle carries a known-good solution that's never shown to players. It exists purely to prove the puzzle is solvable. It caught a broken puzzle in my initial 30 — an email pattern that accidentally accepted a string it was supposed to reject.
An impossible puzzle on day 9 destroys nine days of someone's streak, and you cannot give that back. It's the single most damaging bug that genre can ship.
Which is exactly what I nearly shipped.
I wrote a second test asserting the worker's copy of the evaluation loop agreed with the main-thread one. They disagreed — on one puzzle, out of thirty.
The cause was a single call I'd written without thinking:
const pattern = raw.trim(); // seemed obviously right
Trimming user input is such a reflex that it doesn't feel like a decision. But whitespace is a significant character in a regular expression. One of my puzzles is "match markdown headings", and its answer is ^#+ — hash, then a space. Trimming rewrote it to ^#+, which happily matches #NoSpace, a string the puzzle requires you to reject.
So the correct answer was silently converted into a wrong one before it ever ran, and the player was told they'd failed. That puzzle was unsolvable, and it was scheduled to go live four days later. Another puzzle in the bank — "match trailing whitespace" — was broken the same way, which in hindsight is almost funny.
Nobody would have reported this as a bug. They'd have typed the right answer, been told they were wrong, assumed they were wrong, and left.
Lesson: trim() is a decision, not a formality. Ask what the input actually is. For a search box, trim. For a regex, a password, or a Markdown block, the whitespace is the data.
It's free, there's no signup, and it runs entirely in your browser.
What I'd tell past me
- Segment before you conclude. Pageviews-per-visitor by country reframed everything in thirty seconds.
-
Never key analytics on a shared sentinel.
|| "anonymous"silently merged thousands of people into one. -
Check the race is winnable. Great technical SEO aimed at
json formatteris just a nicer way to lose. - Distribution is a feature you build, not something that happens after you ship enough tools. I built supply for a year and never once built a reason for one visitor to produce another.
-
Write the test that makes two implementations argue. Both bugs that would have ruined the launch — the worker that never loaded and the
trim()that made a puzzle unsolvable — were found by tests comparing two paths that were supposed to agree, not by tests asserting a known answer. I'd have shipped both.
Happy to go deeper on any of it — especially the OG payload design or the worker watchdog, which were the two genuinely fiddly bits.
And if you enjoy regex: today's puzzle. I'd be curious what you get it down to.
Top comments (0)