‹ Writing GameKal

No more loading screen

Until this week, opening GameKal meant waiting behind a full-screen loader. When it lifted, Home appeared, and if you were signed in, a row of sample games could flash for a moment before your own shelf replaced them. Sections could still move as they arrived.

Now there’s no loader. The page is there at once, drawn as gray shapes exactly where the real content will sit. Each section fills in on its own the moment its data is ready, in a single frame, without moving anything around it. After the release, every main page measured a layout shift score of 0 on the live site, at desktop and phone sizes. Sample games only appear after the app has confirmed your shelf is really empty.

This post covers the rules I set before starting, how each piece of the page decides it’s ready, and the long tail of 1 px problems that took most of the time. It also covers the two pages that still shifted after everything else was clean.

What was wrong with a loader

The loader wasn’t slow on its own. It hid everything until everything was ready, so the slowest piece set the pace for the whole screen. And when it lifted, the page underneath was still settling.

Spinners told you nothing about what was coming. A few sections had their own spinner on top of the full-screen loader. A spinner says “wait”, but not “for what” or “how big”.

Sample content showed up too early. GameKal shows a few sample games to people with an empty shelf. Before this change, those samples could render while the app was still finding out whether you were signed in, then get replaced by your real shelf. You saw the wrong content first.

Things moved. A section that arrives late and pushes the next one down is what Google’s Cumulative Layout Shift measures. Anything under 0.1 counts as good. One phone page measured 0.46.

The rules

Before writing any code, I wrote down what “done” meant. Every later decision got checked against this list.

  1. Skeleton first, always. Every page opens as skeletons, on desktop web, phone web, iOS and Android.
  2. Then the checks, in order. Is someone signed in? If so, load their shelf. If not, or if the shelf is truly empty, show the sample games. Never sample games while the real answer is pending.
  3. Each component owns its loading state. Sections load independently. No section waits for another.
  4. All or nothing per component. While a component is loading, all of it is skeleton, every string included. When it’s ready, it swaps to the real thing in one frame.
  5. No layout shift. Each skeleton sits exactly on the box its content will fill.
  6. Nothing moves while loading. No spinners, no hover effects, no animations. The skeleton blocks pulse gently, and that’s all.

How a section knows it’s ready

One signal says the shelf is known. The library is ready, the session is known (signed in or not), and the account has settled. Until all three are true, anything that depends on the shelf stays skeleton. That single rule ended the sample-games flash.

Skeletons are sized by the real text. Instead of guessing a bar’s width, the real text renders invisibly and sets the size of its box, and the skeleton bar is drawn over it. When the data arrives, the text becomes visible and the bar goes away. The box doesn’t change size because it was always the text’s box. Gradient headings needed their own way of being hidden, since they draw their color differently.

The boot screen paints only the background. The full-screen loader’s code is still in the project, unused. On the web, the first paint is the background color, and the desktop site’s code starts downloading at the same moment.

Fonts get 100 ms. A heading that renders in a fallback font and then swaps to the real one reflows, and that’s a shift. The fonts are preloaded and use font-display: optional: if a font isn’t ready within a short window, that page load keeps the fallback instead of swapping later. The app waits at most 100 ms for fonts before drawing.

Opening a page, before and after

Not to scale.

Before
Full-screen loaderwaits for everything
Page appearssections still arriving
Sample gamescan flash first
Your shelfpushes things around
After
Skeletonsevery section, in place
Checkssession known,
account settled
Each section swapsone frame, same box

Sample games appear only after the checks find an empty shelf.

The long tail

The structure was the easy part. Getting every skeleton to sit exactly on its loaded box took longer. Most of these were found by comparing the loading and loaded states side by side, a tool I wrote about in Held and loaded, side by side.

The “This week” header bar was 2 to 3 px short. Its skeleton didn’t match the real bar’s height, so the list under it jumped when the dates arrived.

A 1 px hairline was off by 1 px. I first trimmed the “All games” bar to match. The real cause was 1 px of offset in the hero above it, and trimming the bar would have hidden that.

The calendar grid skeleton was too much. It drew a full grid of boxes. The right answer was small blocks where the date numbers go, nothing more.

Things moved while loading. The date above Home’s slider animated in its skeleton state. The calendar’s arrows faded in. A release counter rendered as a real number, “10”, with an animated glow, before anything else had loaded. All of these now wait like everything else.

The header had to be skeleton too. I briefly considered a real, static header while the rest loaded. That’s inconsistent: it shows navigation for a page that isn’t there yet. The header is skeleton until the page is ready, with each icon’s block matching the others.

Title bars were 1 px too tall on Journal and Up Next cards, and Journal had two shifts left after the rest was clean.

The two pages that still shifted

Measuring the live site after a release found two problems the local checks hadn’t.

A game page opened by its link on a phone measured 0.46. That’s the page as a sheet sliding up over Home. It opened with an empty body, then grew as the game’s record arrived, and the thumbnail row appeared one render late. Now the sheet opens only once the record has answered, so its first frame is drawn at its final height, with the thumbnail row already in place.

The trade-off: on a direct link, Home shows alone for a moment before the sheet slides up. On a normal connection that’s a fraction of a second. If the lookup hangs, the sheet opens anyway after 6 seconds.

A game that doesn’t exist measured 0.047. The “We could not find this game” page pulled the footer into view about 2 seconds after the answer. The fix was to keep the page at least a window tall, so the footer starts below the fold and stays there.

That one had a fair alternative: show the footer from the first frame. But the first frame can’t know whether the game exists, and putting the footer there brought a 0.18 shift back to every real game page. A missing game is rare, and scrolling to reach a footer is harmless.

Layout shift on the two pages that still moved

Cumulative Layout Shift on a cold load. Lower is better. Under 0.1 is good.

BeforeAfter

Game pagephone, direct link
0.46
0
Game not founddesktop
0.047
0
00.10.20.30.40.5
Measured with a layout-shift observer. The not-found 'before' was measured locally; the rest on the live site. Phone at 375 x 812, desktop at 1920 x 1080. Single cold loads on a normal connection; field numbers vary.

After both fixes, Home, Calendar, Journal, Up Next and a game page all measured 0 at 1920 x 1080 and at 375 x 812.

Results

The number I cared about is the one above: 0 across the main pages. The one I didn’t expect was how the app feels. On my phone, it seems a lot faster, especially moving between tabs. Nothing got faster on the network. The page just stopped waiting for its slowest part before showing you anything, and stopped moving once it did.

Costs and trade-offs

Every component carries a second look. Each section has a loading version that has to stay in sync with its real one. A change to a card’s layout is now two changes, and the side-by-side check is what keeps them honest.

The direct-link sheet waits. As above, a game opened from a link on a phone appears a moment later than it used to, in exchange for not jumping.

It didn’t fix page weight. A Lighthouse run on the live Home page still scored 68 for performance. That’s a different problem: a 1.2 MB image shown as a 36 x 48 thumbnail, 785 KB and 557 KB PNGs shown at 132 x 198, a 327 KB app icon, and about 352 KB of unused JavaScript.

What I learned

Looking back, three lessons stand out.

Write the rules before the code. “Skeletons everywhere” means different things to different people. Six numbered rules settled dozens of small arguments before they started.

Size placeholders with the real thing. Invisible real text sets the box, and the skeleton draws on top. Guessed widths are how you get 1 px problems.

Measure the live site. Two pages that were clean locally shifted in production, on a real network, with real records arriving at real speeds.

What’s next?

Page weight is next: resize those images, give the first large image a fetch priority, and cut the unused JavaScript. After that, the first paint itself: shipping the page’s skeleton in the HTML so it’s there before any JavaScript runs.

The first thing you saw used to be a loader. Now it’s the page.

I'm listening if you're talking

A bug, a new build, a data question, a site that needs to be faster or a full rebuild. Tell me what's going on.

Let's see how to collaborate.