- date written
- minutes to read
- 7
- sources cited
- 8
The app felt slow on phones: the cause was having no loading screen
Database queries at 67 to 266 milliseconds, and 0 loading screens across 23 pages. The 102 kB download was not the cause either. The frozen feeling came from the silence after the tap, fixed with 12 skeleton screens.
- performance
- erp
- operations
Staff inside the company reported: "the web app is slow, especially on phones". I suspected the database first, then the JavaScript bundle size. I measured before fixing. The measurements pointed at a third thing.
Measure first, guess second
I measured three things.
Database queries. Measured from a machine in Vietnam: 67 to 266 milliseconds per query. The lists all fetch in parallel, free of the one-query-per-row pattern. Every list query sets a row limit of 100, 500, 1000 or 2000.
JavaScript bundle size. Numbers from the build on 29 July 2026:
| Page | First load size |
|---|---|
| Shared by every page | 102 kB |
| Home, content, tasks, my tasks, workload | 106 kB |
| Revenue | 130 kB |
| HR, projects | 146 kB |
| Costs | 167 kB |
| Task board (heaviest in the menu) | 190 kB |
| Users | 200 kB |
| Sales opportunities (heaviest overall, currently hidden from the menu) | 222 kB |
100 to 190 kB is ordinary for a React application. The gain was somewhere else.
The number of loading screens. Searched the whole codebase: 0 files declaring a loading screen. Meanwhile the app area has 23 pages declared to render fresh on every open: 23 dynamic pages, 0 loading screens.
The third measurement was the answer.
The frozen feeling comes from the silence
Next.js shows an instant response only when a page declares a loading screen. Without that declaration, tapping a link leaves the old page on screen: nothing moves until the server finishes rendering. On high-latency 4G that silence lasts 0.5 to 2 seconds. The user thinks it has frozen and taps again, which makes it slower.
Underneath there is a fixed cost as well. Every navigation costs two round trips to the auth service: one in the middleware to refresh the session, one in the page itself. The page's call is cached, but it stays separate from the middleware's.
Worse, the middleware's exclusion list previously covered images only. Three font files and the web app manifest still ran through it, each one dragging in another auth round trip on first load.
The latency of each auth round trip from a user's device is not measured. I wrote down "not measured" together with the method for measuring it, and left the number cell blank.
The fix: 12 skeletons that match the real layout
The rule for this round: add new files only, change no page that is already running. One file holding the shared skeleton blocks, plus 12 loading screens: a default one for the whole app area and 11 specific ones for content, content item detail, the task board, tasks, my tasks, projects, the approval gate, management, costs, revenue and HR.
How the skeletons are built:
- Read each page, then rebuild the same block count, the same table column count, the same row height, a task board column width of 280 px, and a metrics grid holding a minimum height of 118 px. When the data arrives, the layout does not move.
- Neutral colours only, taken from the shared palette, so they read correctly on both light and dark backgrounds.
- Rendered entirely on the server, holding no state. The size table above was re-measured after the skeletons went in and still lands in the same 102 to 222 kB range, byte for byte.
- Each skeleton declares the right role for screen readers, with a hidden label saying the data is loading. The interface already turns motion off when the user has reduced motion switched on.
- Priority went to the 5 tabs on the phone navigation bar: home, tasks, projects, content, approval gate.
One trade-off I accepted: the home page uses the shared default skeleton, deliberately neutral, dropping the coloured welcome banner. Matching it exactly would mean splitting the home page into a route group of its own in a live system, in exchange for about half a second of appearance. I left it for later.
The second fix tightened the list of files that pass through the middleware. Image, icon, font, web app manifest, plain text and source map extensions all went onto the exclusion list. That ended the three font files and the manifest each dragging an auth round trip along with them.
Why that is safe, checked point by point. The login gate for the app area sits in the shared layout. The middleware only refreshes the session and guards 4 additional path prefixes, all 4 of which are inside the app area and therefore already guarded by that layout. I ran the new rule against the real path list: every application path still goes through the middleware as before.
I left the API routes alone, even though all 9 of them check the session internally. Taking the middleware off there risks a sudden logout when two requests refresh the session at the same moment. Left for a later round.
Why I kept rendering fresh on every request
The easiest suggestion to accept is to drop dynamic rendering, or to turn caching on. The pages would get faster. But on pages whose data is specific to each person, which here is nearly every page in the system, one cached copy would be shared across several people. One user sees another user's data. In an ERP already serving users, that is a data incident. This round I left the caching behaviour of every page untouched.
Same for the idea of merging the two auth round trips by having the middleware write its result into a request header for the page to read back. Trusting that header instead of verifying again is the pattern behind known authentication bypass holes. Worth considering only once auth latency is measured above 300 milliseconds, and only with a separate security review.
How to re-measure and know it is actually faster
The measurement has to run over a real network, because opening the app on the development machine has no network latency. The method: open Chrome's network panel, throttle to 4G, tap Tasks, Projects, Content and Approvals in turn, then watch time to first byte.
That number barely moves, because a skeleton changes the feel, not the server. The number that drops is the time from the tap to the screen changing: before, it equalled the server response time; after, it is one frame, about 16 milliseconds.
Checks after the fix: the type checker came back clean, 0 errors. The build succeeded and first load sizes were unchanged. I looked at the live version in a 375×812 viewport, delaying the data fetch by 5 extra seconds to hold the loading screen in view. The skeleton showed the layout of the destination page. The title bar and the navigation bar changed the moment I tapped. The layout stayed inside the frame: scroll width equal to the screen width, 375. The 980 px wide content table scrolls inside its own container.
Two cheap items are still open: a 600×299 px logo weighing 146 kB that displays at 30 px tall, and 3 font files totalling 841 kB not yet converted to a compressed format. Next round.
What I took from it
- "Slow" is a feeling; turn it into numbers before choosing where to fix. I nearly went to work on the database while the database was taking 67 to 266 milliseconds.
- The safest fix is usually the one that only adds files. The 12 loading screens changed no page behaviour and no byte counts.
- Speed comes second to keeping each person's data where it belongs. Caching and dynamic rendering are the system owner's decision.
- What has not been measured gets written down as "not measured", with the method for measuring it. The number cell stays blank until there is a measurement.
If your company is also hearing "the app is slow" and nobody knows where, tell me the context.