My blog's first screen was wasting 0.6 seconds waiting for a visitor check
I had the screen wait for the visitor count API, so the home's post list request was stuck in line behind it. I made it stop waiting, and with a simulated mobile connection the home went from 3.98 to 3.39 seconds.
#React #react-query #Performance #Frontend
While fixing the visitor count, I opened the blog's App.tsx again for the first time in a while. I only wanted to check where the visitor count API gets called, but the line right below it caught my eye. const { isLoading: isCheckLoading } = useQuery(['checkIP'], checkIP, { onSuccess: (res) = { if (res?.id) setUser(res) }, }); if (isCheckLoading) return SplashLoading / ; /checkIP is the API that hashes the visitor's IP, counts them once as a visitor for today, and returns an anonymous user id. And until that response came back, it was blocking the whole app with a loading screen . No top bar, no post list, nothing gets drawn. In this post I measure how much this was actually slowing down the first screen, and go over how I changed it so it doesn't wait. Why did I make it wait There was a reason when I first built it. The id from here is used for likes, comments and the guestbook. I thought something might break if the screen showed up before there was an id, so I just made it wait until the id arrived. I wanted to play it safe. But when I looked at those three places again, all of them only read the id when you press a button. // ContentLikes.tsx const handleLike = () = { addLike(bid || '', user.id || '').then(...) } Drawing the screen doesn't need the id. By the time someone is reading a post and presses like, the response has long since arrived. It was waiting for something it had no reason to wait for. If anything, making it wait was hiding a bug. When the request is judged to be a bot, the server responds without an id, and then user is null , so user.id throws right away. It only stayed hidden because nobody in that state had pressed like so far. The response itself is fast. The problem is waiting in line At first I thought it was no big deal. All /checkIP does is insert one row into the DB, so measured inside the server it takes about 35ms. But from the browser's side, that's not the whole story. If you follow the order, it goes like this. The browser gets the HTML and JS, and when the app starts it sends /checkIP . Until the response comes back, it only draws the splash. When the response arrives, the home component finally mounts, and only then does the home start the post list API request . The cards show up once that response comes back. So a whole round trip was stuck in front of the first screen. Even if the server takes 35ms, a round trip on a mobile connection is much longer than that. The two requests could go out at the same time, but the setup made the next one start only after the first one finished. I measured it I didn't want to go on gut feeling, so I measured it with playwright. Each time I opened the home and a post page in a new browser context with the cache off, and measured when the first post card or the first paragraph of the body appeared on screen. For mobile connections I applied the Chrome DevTools preset values as is through CDP. // Slow 4G { latency: 150, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8 } // Fast 3G { latency: 562.5, downloadThroughput: 1.44 * 1024 * 1024 / 8, uploadThroughput: 675 * 1024 / 8 } I ran each scenario five times and looked at the median. Before the change, /checkIP on the home took 8ms on desktop, 171ms on Slow 4G and 585ms on Fast 3G. And after that, the post list made one more round trip. What I changed I removed the blocking line and let the response arrive in the background. useQuery(['checkIP'], checkIP, { onSuccess: (res) = { if (res?.id) setUser(res) // If the visitor count came back before checkIP, it may be missing me as today's first visit. // A request still in flight may also have been counted on the server before checkIP, so cancel it and fetch again. queryClient.invalidateQueries('getVisitLog', undefined, { cancelRefetch: true }) }, }); I changed the three places that read the id to user?.id . Even if someone presses the button before the response, or gets judged as a bot and has no id, there's no error. One more thing I had to think about was the visitor count on the home. The home shows today's visitor count, and now /checkIP and the visitor count request go out at the same time. If the visitor count gets handled first, someone visiting for the first time today sees a number that doesn't include them. So I made it fetch the visitor count again once /checkIP finishes. At first I only added invalidateQueries('getVisitLog') , but when I checked in the browser, sometimes the refetch request didn't go out. If the visitor count request is still in progress, react-query doesn't send a new one and waits for the one in progress. If that in-progress request was handled on the server before /checkIP , the result is still a count without me. I added cancelRefetch: true so it throws away the one in progress and fetches a new one. Results Time until the post list (body) shows up, median of 5 runs Home Post page Desktop 251 - 208ms 237 - 181ms Mobile Slow 4G 2,295 - 2,051ms 3,241 - 2,968ms Mobile Fast 3G 3,978 - 3,391ms 5,269 - 4,776ms The home got 0.59 seconds faster on Fast 3G and 0.24 seconds faster on Slow 4G. The amount saved is almost the same as the /checkIP round trip on each connection. The one round trip that was waiting in line is gone. Desktop already had short round trips, so the difference is about 40ms. First paint also moved earlier. On Fast 3G it went from 2,920ms to 2,256ms. Before, the first paint was the splash background, and now the top bar and the frame of the home are drawn right away. The splash that's still left On the home the splash doesn't show at all anymore, but on post pages the splash still flashes briefly in four or five out of five runs. This is a different splash. The post page is loaded separately with lazy() , and until that JS chunk arrives, it uses the same splash as the Suspense fallback. This one waits because there's really nothing to draw yet, so it's a different kind of thing. Still, I think keeping the top bar and leaving only the body area empty would be better than a loading screen covering everything, so I put it on my list to fix next. Final thoughts Everyone has a reason when they add a loading screen. I had one too: "it might break if it shows up without an id." But nobody goes back to check whether that reason still holds. While the loading screen is up, the user sees nothing, and you can't tell from one line of code whether it really needs to wait. If there's a condition holding up a loading screen, I'd recommend following where the value it waits for actually gets read. If it's only read on click, the screen can be drawn first.