The landing page counts itself
10 November 2026 · ThaiPo
For a while, our landing page carried a trust line that said "12,000+". It was a lovely number. Round, confident, comfortably five digits. Nobody had counted anything to arrive at it. Next to it sat three stat tiles that were, to be generous, decorative.
The awkward part is that we run a product with a database. The real numbers were sitting one query away the whole time, and some of them were better than the fake ones. So we deleted the decoration and made the landing page read its own figures from production. This is how, and why it took more thought than a select count(*).

Four tiles, all true
- Messages translated. All time. 640,000+ on the day it shipped.
- People in translated chats. Distinct people seen in a chat we translate: 3,000+, with a second line for those seen in the last 30 days, 1,500+.
- Languages, to and from Thai. The headline figure is how many we offer. The line underneath is how many had real traffic this month, which was 11 at launch. Offering a language is a claim; someone using it is evidence.
- Names and terms learned.25,000+. The vocabulary packs we ship ourselves are excluded, because nobody's chat taught us those.
Are these big numbers? Not by the standards of a made-up trust line. But a visitor can smell "trusted by 12,000+" from across the room, and 1,500 people active this month is a figure with a pulse. A small real number beats a big fake one, and it has the further advantage that it goes up on its own.
One query, no new plumbing
The whole thing is one SQL statement, a handful of scalar subqueries, over tables the product already keeps in order to run, among them usage counters, chat membership, per-chat settings and the learned glossary. We added no event stream, no analytics table and no nightly job. Everything it reads is state that would exist whether or not the landing page wanted to brag about it.
We also ran the statement against production before shipping it. TypeScript will happily typecheck the function around a query and has no opinion at all about the string inside, so a misspelt column sails through every gate and fails in front of the first visitor.
The landing page must never wait for a vanity metric
A quick query is fine, until the day the database is busy and it takes 8 seconds, on the highest-traffic page we have, in service of a number in a cream-coloured box. So the rules are: ask at most once an hour, never wait long, never fail.
const TTL_MS = 60 * 60 * 1000;
const WAIT_MS = 1500;
let memo: { at: number; value: LandingStats } | null = null;
let inflight: Promise<LandingStats> | null = null;
export async function getLandingStats(): Promise<LandingStats> {
if (!dbConfigured()) return FLOOR;
const last = memo?.value ?? FLOOR;
if (memo && Date.now() - memo.at < TTL_MS) return memo.value;
inflight ??= load()
.catch(() => last)
.finally(() => {
inflight = null;
});
// A stale or first read never holds the page: past WAIT_MS it renders the
// last known figures and the refresh finishes in the background.
return Promise.race([inflight, new Promise<LandingStats>((res) => setTimeout(() => res(last), WAIT_MS))]);
}A few lines, four behaviours:
- Memoised per process for an hour. A fresh value returns immediately and the database never hears about it.
- One query no matter how many visitors.
inflight ??=means everyone who arrives during a refresh shares the same promise. A burst of traffic on a cold cache costs one query, not one each. - A 1.5 second budget. The page races the query against a timer. If the timer wins, the page renders the last known figures and the query finishes in the background for the next visitor. Stale while revalidating, with a short grace period first.
- A floor.
FLOORis a hard-coded set of the real figures from launch day, already rounded down. A slow cold start, or a query that fails before any has succeeded, lands there. Since the totals mostly grow, the usual worst case is that the page undersells us.
Why a module-level variable and not the framework's cache? Because this page is rendered per request. It reads the visitor's user agent to send people inside social-app browsers down a sign-up path that works there, which opts it out of static rendering. There is no build-time snapshot to hang a number on, so the cache lives in the process, and each web machine keeps its own. That is an hour of disagreement between machines at most, on figures that are about to be rounded anyway.
Rounding down, on purpose
// "643,017" reads as invented precision and changes on every load; round
// down to two significant figures so the tile is always true: 640,000+.
export function roundDown(n: number): string {
if (n < 100) return String(Math.max(0, Math.floor(n)));
const unit = 10 ** (Math.floor(Math.log10(n)) - 1);
return (Math.floor(n / unit) * unit).toLocaleString("en-US") + "+";
}An exact count has two problems. It looks made up, which is unfair but true. And it twitches: reload the page and the number has moved, which draws the eye to exactly the wrong thing. Two significant figures, always rounded down, with a plus sign, fixes both. The tile is never an overstatement, and it changes only when something worth noticing has happened.
The tests pin the edges: 999,999 shows as 990,000+, never a rounded-up million, and the real 1,000,000 gets to say so. Counts under 100 are shown exactly, because "10+" languages when you mean 11 is just being coy.
The honest wrinkle
On launch day the page showed its live, database-backed numbers, and there was no way to tell from the page that they were live. The floor is the launch-day figures rounded down. The query returns the launch-day figures, which the tile then rounds down. The two paths produced an identical page.
So the feature shipped in a state where working perfectly and being completely broken looked the same from the outside, and the only proof available was to wait for a tile to tick over. It is the rare bug class where the fix is patience. We had run the query by hand, so we knew it worked; but the page itself kept a straight face about it.
Where the tiles sit
Directly under the hero, above everything else. The first thing a visitor should learn after "what is this" is "do people use it", and that question deserves an answer from the database, not from the copywriter.
The takeaway
If your product has a database, your landing page does not need a made-up number; it needs one cached query, a timeout, a floor and the modesty to round down. The fake number is finished the day you type it. The real one is smaller, and it is still counting.
ThaiPo is designed, built and run by one developer, Steven Acreman.
Try it in your own chats
ThaiPo lives inside LINE and translates every message in both directions, free forever. Get started free or add @thaipo.ai as a friend on LINE and it sends your signup link right in the chat.