PubTrivia has three different answers to "how does this page stay current", and they are three different answers because they have three different audiences.
The 79 pages in the sitemap do not stay current at all. 77 of them are prerendered at build time and sit in a cache; the other two render per request because they show prices in the visitor's own currency. A page about how to run a quiz night does not need to change while you read it.
The screen a host puts on the pub television is the opposite: it is driven by a socket, because a question opening a second late is a visible fault in a room full of people.
Then there is the internal dashboard I use to see whether anything is stuck. It needs to be current within a few seconds. It has one user. It gets 68 lines.
The component
export function AutoRefresh({ seconds = 15 }: { seconds?: number }) {
const router = useRouter()
const [paused, setPaused] = useState(false)
const [pending, startTransition] = useTransition()
const [lastAt, setLastAt] = useState(() => Date.now())
const [, tick] = useState(0)
useEffect(() => {
if (paused) return
const id = setInterval(() => {
if (document.visibilityState !== 'visible') return
startTransition(() => router.refresh())
setLastAt(Date.now())
}, seconds * 1000)
return () => clearInterval(id)
}, [paused, seconds, router])
// Keeps the "updated 12s ago" label moving between refreshes.
useEffect(() => {
const id = setInterval(() => tick((n) => n + 1), 1000)
return () => clearInterval(id)
}, [])
// ... a dot, an age label, a refresh-now button, a pause button
}
There are four decisions in there and each one is worth a paragraph.
router.refresh, not a fetch
The page it sits on is a Server Component with SQL in it. Several counts, a list of rows, a few probes of other services. The obvious way to make that live is to add an endpoint that returns the same numbers as JSON, then fetch it on a timer and render it on the client.
That is a second copy of every query, a second set of types, a second place for a filter to go stale, and a client component for every table. router.refresh() instead asks the server to render the same page again and patches the result in. The queries stay where they are. Nothing on the page becomes a client component because of the polling, which is why the polling is 68 lines and not 680.
Wrapping it in startTransition is what keeps the current numbers on screen while the new ones are being fetched, rather than falling back to a loading state every fifteen seconds. The pending flag it returns is used for exactly one thing: spinning the refresh icon.
What one refresh costs
This is the part people guess at, so I measured it on the one page that is public and renders per request. The HTML document is 102,004 bytes. The same URL asked for as a React payload, which is what a refresh actually fetches, is 52,058:
$ curl -s https://pub-trivia.app/pricing | wc -c
102004
$ curl -s -H 'RSC: 1' https://pub-trivia.app/pricing | wc -c
52058
So a refresh is roughly half the bytes of a page load, and none of the JavaScript, CSS or fonts, which are already in the browser. On a 30 second interval that is 120 refreshes an hour. For a dashboard with one viewer whose numbers are the reason the tab is open, that is a rounding error. For a page with ten thousand concurrent viewers it would be an outage with extra steps, and this is the distinction that decides whether polling is lazy or correct.
The admin pages are smaller than the pricing page, so treat that number as the right order of magnitude rather than the exact figure. The method is the point: ask for your own page with RSC: 1 and you are looking at the cost of one router.refresh().
The interval is a prop, and the fastest one is conditional
Three pages use the component, with three intervals:
<AutoRefresh seconds={30} /> // the overview
<AutoRefresh seconds={15} /> // the list of quizzes
{isLive && <AutoRefresh seconds={10} />} // one quiz, only while it is running
The last line is the one I would keep. A quiz that finished last Tuesday is a static record, and polling it every ten seconds would be asking the database the same question forever for a page whose answer cannot change. The component is not mounted at all unless there is something live to watch, so the polling stops without any code that decides to stop it.
Two details that are easy to leave out
The hidden tab. The interval keeps firing when a tab is in the background, and router.refresh() in a background tab is a database query nobody will ever read. One line prevents it:
if (document.visibilityState !== 'visible') return
Note that it skips the tick rather than clearing the interval, so there is no visibilitychange listener and no re-subscription logic. The timer runs, does nothing, and the first tick after you come back to the tab is a real refresh.
The pause button. This one is a usability bug I had before I had the feature. A table of numbers that reorders itself under the cursor while you are reading a row is genuinely hard to use, and the fix is not a longer interval, it is the ability to stop. So the control is a dot plus three affordances: it says Live, updated 12s ago or Paused, a refresh-now button, and a pause toggle with an aria-label that changes with its state.
That is also why the second interval exists. The age label has to count up between refreshes, so a 1 second timer bumps a state value nothing else reads:
const [, tick] = useState(0)
A re-render a second, of one <div>, to keep a number honest. The alternative is a label that says "updated 0s ago" for thirty seconds, which is worse than no label.
The socket we already have, and did not use
The thing that makes this an actual decision rather than a default is that the app already runs a WebSocket server. Pushing admin updates over it would have been architecturally tidy and completely wrong: it would put a dashboard with one viewer into the same fan-out as a room full of phones, add a channel and a message type per statistic, and create a new way for an internal page to break a live quiz.
Polling has none of that. It is a setInterval and a method the framework already ships. The cost is paid in bytes by the only person watching, and the blast radius is one tab.
Check it yourself
The two ends of the spectrum are both observable from outside. A prerendered page admits it in a response header, and tells you how long the copy you got has been sitting in the cache:
$ curl -sI https://pub-trivia.app/faq | grep -iE 'x-nextjs-prerender|x-vercel-cache|age'
age: 1428
x-nextjs-prerender: 1
x-vercel-cache: HIT
And the per-request page carries no prerender header at all, which is the same difference the dashboard is sitting on the other side of. Read the pages that never move at the guides index, then look at the pricing page, which is the only public page in the app that is rendered for you specifically.
Top comments (0)