Skip to main content
Written on 19/10/2026 from the code on main / chat+ai in LMS_Backend, LMS_Frontend and this site, right after Auth User Optimize shipped. That plan made the boot payload small (1.5 MB → ~20 KB). This one is about the number of trips the web app makes between the Login button and the dashboard, and what each trip waits on.
Status: CODE DONE ✅ (19/10/2026, Update 126 in the frontend log), SERVER STEPS PENDING ⚠️. Every step that lives in a repository shipped on chat+ai of LMS_Backend and LMS_Frontend (backend suite 127 tests, Flutter suite 850). What is left is done by hand on the servers and is listed in the table below; until it is done the measured “after” column of §5.1 stays empty. Scope: the Flutter web path from “Login” pressed (credentials valid) to the first painted student dashboard, with the user’s data already in the providers so nothing on the dashboard pops in later. The Flutter renderer (CanvasKit vs wasm) is out of scope on purpose and is not part of any step below. The F5 / cold-boot path only benefits indirectly (§3 step 1 and step 5 apply to every request).

Status per step (19/10/2026)

The exact nginx / PHP / Plesk commands are in the step texts of §3; the measurement commands in §5.

0. TL;DR

Today a successful login costs 4 HTTP requests + 4 CORS preflights = 8 serial server round trips before the dashboard route is even built, plus ~250 ms of bcrypt and, in production, a server-side call to Cloudflare. Measured against backend-test on 19/10/2026, each round trip costs ~150–180 ms of server + network time (§1.3), so the login button spins for roughly 1.7–2.0 s on a good connection before the first dashboard frame. Target after the five steps of §3: 1 request (POST /auth/login returns the token and the same payload as GET /auth/user), 0 preflights after the first one of the session, nothing else awaited before navigation. Estimated 0.5–0.7 s to the dashboard frame on the same server, and ~3× fewer requests on every later page too (step 1 removes one preflight per API call app-wide). The app’s first load is a separate problem with its own numbers (§1.5). Before the login form can even be shown, the browser downloads 34 resources / 6.7 MB on the wire: the Dart bundle (3.3 MB) and CanvasKit (1.6 MB) take ~1 s on a fast link, then the engine pauses ~0.5 s to compile them, then it downloads all 15 fonts of the manifest (1.14 MB, uncompressed) before the first frame, then the app blocks on the theme call, then it fetches a 421 KB background and a 106 KB flag. Measured: login form fully painted at 3.7 s cold on a fast link, 8.6 s on a simulated 8 Mbit/s line, 1.3 s warm. Steps 6–8 take the font phase from ~0.7 s to ~0.2 s and the cold first paint to ~3.0 s / ~6.7 s; the bundle itself is the remaining cost (§6).

Table of contents

  1. TL;DR
  2. What happens today (traced and measured)
  3. Target sequence
  4. The plan, ranked by gain per effort
  5. Rollout order and compatibility
  6. How to measure before / after
  7. Deliberately not in this plan
  8. Files touched
  9. Optional task — student cold load: the four ideas, evaluated

1. What happens today (traced and measured)

1.1 The sequence, file by file

Two things to notice: loginUser() reuses authUserAndRole(), which was written for the cold boot and therefore re-does the boot work (theme, device, language); and every one of the four requests is awaited in series, including the statistics call whose only consumer is a chart that already has an empty state.

1.2 Every HTTP request between the click and the dashboard

The Dio client sets Content-Type: application/json on every request (GETs included) and adds Authorization: Bearer … once a token exists. Both headers are “non-simple” for CORS, so the browser sends an OPTIONS preflight before each call. The backend answers preflights with access-control-max-age: 0 (config/cors.php), so nothing is ever cached and the preflight is paid again on every single request. Requests 1–8 are strictly serial (each starts when the previous one returned): the login path is 8 round trips deep before a single dashboard widget is built. Server topology (Plesk, backend-test.theteam.com.tn, checked 19/10/2026). nginx runs as a reverse proxy in front of Apache, which runs PHP. The only custom nginx directive on the vhost is the /storage/ CORS block from the CanvasKit image fix; everything under /api/ is proxied to Apache. So each of the 8 trips above goes browser → nginx → Apache → Laravel and back, including the four empty preflights.

1.3 Measured cost of one round trip (19/10/2026)

curl from the dev machine against https://backend-test.theteam.com.tn/api, 5 runs each, a fresh TLS connection every time (the browser keeps one HTTP/2 connection, so subtract the TLS column to get what the app pays per request). The server answers over HTTP/2 with Brotli. Read: an empty preflight costs almost as much as a real endpoint. It crosses nginx and Apache and then Laravel boots fully just to let the global HandleCors middleware return a 204. ~130–150 ms for “two hops + boot + nothing” is the budget steps 1 and 5 go after (nginx can answer a preflight in ~1 ms; the TCP column above, ~45 ms, is the floor for anything from this machine). Waterfall estimate for a student login on web, warm HTTP/2 connection, backend-test:
This matches the “it spins for about two seconds” feeling even though the payload is now tiny: the time is in trips, not bytes.

1.4 Small things that are fine (don’t spend time on them)

  • SecureStorage.saveToken() on web (WebCrypto + localStorage): a few ms.
  • ThemeProvider.setPrimaryColor() → two SharedPreferences writes: a few ms.
  • User.fromJson + the student fan-out on a ~6–20 KB payload: a few ms on the UI thread.
  • The dashboard Selector4 / shell Selector5 recompute progress on every build; with card-level progress from the backend this is a loop over 2–10 items, negligible.
  • The first-login language dialog + nav tour intentionally delay the dashboard by 400 ms once per user; not a performance bug.

1.5 The app’s first load, before the login form (measured 19/10/2026)

Captured with a headless Chrome driven over the DevTools protocol against the deployed test site (https://elearning-test.theteam.com.tn/, the build of 19/10/2026), on-the-wire (compressed) sizes, three runs: cold (empty cache, fast link from the dev machine), warm (second load, cache on) and cold at a simulated 8 Mbit/s down / 60 ms RTT home line. Why the fonts are like this (engine source checked in flutter/engine/src/flutter/lib/web_ui):
  • Flutter web downloads every font listed in FontManifest.json before the first frame. initializeEngineServices awaits Future.wait([initializeRenderer, _downloadAssetFonts]), and loadAssetFonts fetches all families of the manifest in parallel (canvaskit/fonts.dart). There is no lazy loading per page: the login page pays for every font the whole app declares, and so does the admin dashboard. The manifest is the app’s pubspec.yaml fonts: plus every font declared by a dependency (syncfusion_flutter_pdfviewer adds RobotoMono, font_awesome_flutter three files, syncfusion_flutter_datagrid two, cupertino_icons one).
  • Poppins is 6 × ~153 KB because the files carry Devanagari. Poppins-Regular.ttf has 1 059 glyphs for 471 code points: 308 Latin, 94 Devanagari, 69 other; the Devanagari conjunct glyphs are most of the file. A Latin + Latin-Extended + punctuation subset of the same file is 25 KB (measured with pyftsubset, kerning kept), 14 KB once Brotli-compressed. Arabic is not in Poppins today either (the engine already falls back to Noto Sans Arabic from gstatic for ar).
  • The frontend vhost serves font/ttf uncompressed (content-encoding absent on every font, while .js / .json arrive as Brotli): the nginx brotli_types / gzip_types list of the Plesk vhost does not include font types. Brotli alone would take Poppins-Regular from 158 KB to 63 KB, MaterialIcons from 44 to 21 KB, RobotoMono from 87 to 48 KB. Fonts also carry no cache-control header (the warm run still served them from the heuristic cache).
  • Roboto (62 KB) comes from gstatic on every cold load because the engine always downloads its default fallback unless the manifest declares a Roboto family (loadAssetFonts, loadedRoboto).
  • Any emoji on screen triggers a 709 KB download (Noto Color Emoji subset 0 from gstatic, font_fallback_data.dart). The staff first-login tour strings carry 👋 / 👉 (tourWelcomeTitle, tourClickNextHint), so the admin dashboard’s first paint after a first login pulls 709 KB that the whole dashboard does not otherwise need; the AI-assistant welcome (aiAssistantWelcome) does the same in the course player.
  • Two more things on the boot path are not fonts but sit in the same waterfall: on web app.dart renders SizedBox.shrink() while authResult == null, so after the HTML loader is removed the user sees a white page for the whole theme round trip (0.55 s cold, 0.45 s warm; the phone shows a spinner); and the login page then downloads a 5120 × 2880 JPEG for a panel of ~800 px and a 979 × 512 PNG for a 24 px flag.
Admin dashboard, first page. Same boot, same fonts (none are page-specific). After login, handleAdminRole() additionally awaits GET /dashboard_admin (+ its preflight) before navigating, and the first interactive login fires the usage-guide tour with the emoji strings above. Steps 1–5 apply to it unchanged; step 6c covers the emoji.

2. Target sequence

Estimated: 170 (trip) + 250 (bcrypt) + 60–150 (payload queries) ≈ 480–570 ms on backend-test for a repeat login, +150 ms for the first login of the session (one preflight), +0 ms for Turnstile in production once it overlaps bcrypt. Every dashboard widget that reads the login payload paints in the first frame; the activity chart and the leaderboard fill in within the next ~400 ms.

3. The plan, ranked by gain per effort

1

Step 1 — Preflights: cache them (1 line) and answer them in nginx (1 directive block)

1a. config/cors.php has 'max_age' => 0. Set it to 7200 (Chrome caps the preflight cache at 2 h; Firefox at 24 h; supports_credentials => true is compatible). The browser then reuses the preflight result per origin + URL + headers, so the login path drops from 8 trips to 5 right away, and every later API call drops one trip too.
1b. Let nginx answer the OPTIONS itself (Plesk → Websites & Domains → the backend domain → Apache & nginx Settings → Additional nginx directives). Today the vhost has only the /storage/ block; add the preflight block next to it. An OPTIONS then never reaches Apache or PHP: ~130–150 ms becomes ~1 ms of nginx time plus the network. The remaining preflight per endpoint per 2 h (the one 1a cannot cache) becomes free, and so does every preflight of an older app build or any other client.
Notes for 1b:
  • It mirrors what config/cors.php does today (allowed_origins => ['*'] with credentials, so the origin is reflected). If the Laravel config is ever narrowed to a list of origins, the nginx block must get the same list (if ($http_origin ~* ^https://(elearning(-test|-dev)?\.theteam\.com\.tn)$)), otherwise nginx would keep accepting origins Laravel refuses.
  • Keep 1a as well: it is what the browser caches, and it is the only thing in effect behind php artisan serve locally and on any vhost where the block was not pasted.
  • Plesk validates the nginx config on save; if it refuses the block, the usual cause is a vhost in “nginx serves PHP” mode where rewrite … last interacts with Plesk’s PHP location. Then use location ^~ /api/ { if ($request_method = OPTIONS) { … return 204; } … } and copy Plesk’s proxy_pass / proxy_set_header lines from the generated /var/www/vhosts/system/<domain>/conf/nginx.conf into it.
  • Apply on backend-test first, measure with the §5 commands (the OPTIONS row should fall from ~250 ms to ~60–90 ms total from the dev machine), then dev and prod.
1c. While there: app/Http/Kernel.php registers two CORS middlewares in the global stack, \Illuminate\Http\Middleware\HandleCors (Laravel 10’s own) and \Fruitcake\Cors\HandleCors (the pre-Laravel-9 package). Keep only Illuminate’s; both read the same config, so behavior is unchanged and one pass of header work per request disappears. Check composer.json and drop fruitcake/laravel-cors if nothing else uses it.Caveats: the preflight cache is keyed per URL, so the first call to each endpoint in a 2 h window still pays one preflight (that is the one 1b makes almost free). A preflight is still required (the Authorization header forces it); removing Content-Type from GETs would not avoid it, so don’t bother.Verify: curl -sD - -o /dev/null -X OPTIONS -H "Origin: http://localhost:5000" -H "Access-Control-Request-Method: GET" -H "Access-Control-Request-Headers: authorization,content-type" https://backend-test.theteam.com.tn/api/auth/user must show access-control-max-age: 7200; after 1b the same response has no x-powered-by: PHP header (it never reached PHP). The Chrome Network tab then shows one OPTIONS per endpoint instead of one per call, each a few ms long.
2

Step 2 — Don't re-run the boot work after login (frontend only)

Split AppService.authUserAndRole() into a one-time bootstrap and a reusable “apply this user” method. loginUser() / loginUserWith2FA() call only the second one.
signOutUser() / deleteMyAccount() keep calling authUserAndRole() (token is null → login route). The theme refresh after an admin edits the platform colors already goes through PlatformConfigProvider directly, so it is not affected.Removes requests #3 and #4 of §1.2 and the DeviceInfo platform call from the login path.
3

Step 3 — Take the statistics call off the critical path (frontend only)

In handleStudentRole() the statistics future is started early (good) but awaited at the end, so the spinner waits ~200 ms for chart data. Stop awaiting it, and make its arrival repaint the chart:
  • StudentStatisticProvider.getStudentStatistic() sets studentData without notifyListeners(); today that is harmless only because the call is awaited before the dashboard exists. Add isLoading + notifyListeners() on success and failure.
  • LearningActivityCard already does context.watch<StudentStatisticProvider>() and the phone dashboard uses a Consumer, so they repaint on their own once the provider notifies. Show the card’s loading state while isLoading so it doesn’t flash “Nothing to chart yet” first.
  • handleStudentRole(): replace await statistics with unawaited(statistics) (the catchError inside the provider already swallows failures). refreshAuthUser() (phone pull-to-refresh) may keep awaiting so the refresh indicator spins until the chart is fresh.
Removes requests #7 and #8 from the critical path (they still happen, after the first frame).
4

Step 4 — POST /auth/login returns the boot payload (backend → frontend → docs)

The login response is the only request we cannot avoid, and the server already has the user in hand, so it should return what GET /auth/user would have returned next. Same builder, same shape, same Dart model.Backend (AuthController::login, after the 2FA block):
  • The 2FA flow is unchanged: the first POST still answers 402; the second POST (with 2fa_code / 2fa_recovery_code) returns the user block too.
  • No ?legacy=1 on login: only new app builds read user; old builds ignore the key and keep calling GET /auth/user. The 28-query budget of AuthUserPayloadTest applies here too.
  • Test: in tests/Feature/AuthUserPayloadTest.php (or a new LoginPayloadTest), log in with the seeded student and assert response['user'] equals GET /auth/user for the same user (minus volatile fields), and that a staff login carries no student blocks.
Frontend
  • LoginResponse: add final User? user; (@JsonKey(name: 'user')), regenerate with dart run build_runner build -d.
  • LoginProvider.loginUser() / loginUserWith2FA():
  • applyAuthUser() (step 2) already contains the mobile restricted-role rule, so an instructor logging in on the phone is still logged straight back out. 04/11/2026: no longer true for instructors: since Update 223 they stay signed in on phones and get a small instructor app (restrictedRoles = [3, 4, 5], see the Changelog); content managers, admins and super-admins are still signed out there.
Docs: api-reference/auth.mdx §4 success response gains the user key with a pointer to auth.mdx §9 for its shape; add a line to student boot payload that the same payload is also returned by login since this change.Removes requests #5 and #6: the login path is now one request (plus a cached preflight).
5

Step 5 — Server hot-path hygiene (each one small, all measurable)

These shave the ~130–180 ms “boot + nothing” cost of §1.3 and the login handler itself.
  1. PHP side on the Plesk vhosts (test / dev / prod). Make sure the deploy runs php artisan optimize (config:cache + route:cache + event:cache) and that OPcache is on with opcache.validate_timestamps=0 in production (php -i | grep opcache on the box). An empty OPTIONS at 130–150 ms is the usual signature of an un-cached Laravel boot. Expect 50–80 ms per request back. Also on Plesk: the vhost currently runs PHP behind Apache (nginx proxies to Apache, which runs PHP and honors public/.htaccess). Plesk can instead run the domain as “PHP-FPM served by nginx”, which removes the Apache hop from every API request (typically another 10–30 ms per request, more under load). It is a server-config decision with two conditions: the document root must be Laravel’s public/ and .htaccess is ignored, so the front-controller rule moves into the same “Additional nginx directives” box (location / { try_files $uri $uri/ /index.php?$query_string; }), and the preflight block of step 1b must still validate (see its last note). Try it on backend-test only, with the §5 numbers before / after; switching the handler back in Plesk is the rollback.
  2. Overlap the Turnstile verification with bcrypt (production only). Today the handler awaits Cloudflare, then looks the user up, then runs Hash::check (~250 ms). Start the HTTP call asynchronously, do the lookup + hash check while it is in flight, then check the captcha result first and only then return the credential outcome (so an unverified request still learns nothing about the account):
    Saves min(bcrypt, Cloudflare RTT) ≈ 100–250 ms per production login. Read the secret from config('services.turnstile.secret') (fed by CLOUDFLARE_TURNSTILE_SECRET_KEY, already in .env.example) instead of a literal in the controller.
  3. UserPolicy::view runs $user->permissions()->pluck('id') (one query) before testing $user->id === $model->id. Put the self check first: one query less on GET /auth/user and on the login payload.
  4. throttle:api with CACHE_DRIVER=file does a locked file read + write per request. If the vhost has APCu or Redis, point CACHE_DRIVER at it (also speeds up every other request). Minor, but free.
  5. bcrypt rounds: BCRYPT_ROUNDS defaults to 12 (~250 ms per login on this CPU). 10 rounds would be ~60 ms. This is a security trade-off, not a bug, so it is listed and not recommended; if the user decides to change it, Laravel re-hashes on the next login automatically (Hash::needsRehash) only if the login code calls it, which it does not today.
  6. Sanctum stateful domains: if a production .env lists the frontend domain in SANCTUM_STATEFUL_DOMAINS, every API request also starts a file session + CSRF check. The app authenticates with bearer tokens only, so that variable should stay empty. Nothing to change if it already is.
6

Step 6 — Fonts: 1 138 KB before the first frame → ~190 KB (frontend + Plesk)

The engine will always download the whole manifest before painting (§1.5), so the lever is what the manifest contains and how it is served.6a. Subset Poppins to Latin (915 KB → 152 KB, same 6 files, same names). One-off tool run, output committed in place of the current files; no new package in the app:
Measured on each weight: 149–156 KB → 25 KB. The range keeps every Latin-1 and Latin-Extended-A/B letter (French, Spanish, Turkish…), punctuation, currency signs (U+20A0-20CF, the TND dinar has no glyph anyway) and arrows. Arabic text keeps using the engine’s Noto fallback as it does today. Check the login page, a quiz review and an ar page before and after with test/student_web_responsive_test.dart + the headless screenshots.6b. Compress fonts on the frontend vhost (152 KB → 82 KB for Poppins; ×2 on the icon fonts). Plesk → the frontend domain (elearning-test, then dev, prod) → Apache & nginx Settings → Additional nginx directives:
If the vhost has no Brotli module, the gzip_types line alone gives 158 → 71 KB on a Poppins file. Verify with curl -sI -H "Accept-Encoding: br, gzip" https://elearning-test.theteam.com.tn/assets/assets/fonts/poppins/Poppins-Regular.ttf (expect content-encoding: br or gzip).6c. No emoji in strings that render at boot. Replace 👋 / 👉 in tourWelcomeTitle, tourClickNextHint (and aiAssistantWelcome) in the three translation files by an icon in the widget (Icons.waving_hand_outlined / HeroIcons.arrowRight), or drop them. Saves the 709 KB Noto Color Emoji download on the staff first login and in the course player.6d. Optional, small. Declare a tiny local Roboto family (a 2–3 KB Latin subset of Roboto-Regular.ttf) in pubspec.yaml: the engine then skips the 62 KB gstatic fetch and the extra origin (DNS + TLS) on every cold load. Keeping only 3 Poppins weights in fonts: and loading the other 3 after the first frame with FontLoader is possible too, but after 6a each weight is 14 KB compressed, so it is not worth the reflow risk; listed, not recommended.Not fixable from the app: RobotoMono (85 KB, 48 KB compressed) is declared by syncfusion_flutter_pdfviewer and merged into the manifest by the build; only dropping the package removes it.After 6a–6c, the font phase is ~9 requests / ~190 KB compressed instead of 15 / 1 138 KB: ≈ 0.15–0.2 s instead of 0.71 s on a fast link, ≈ 0.3 s instead of 1.26 s at 8 Mbit/s.
7

Step 7 — Login page images: 520 KB → ~95 KB (admin upload + 3 asset files)

  • Auth background (platform_config → auth page image, served from the backend /storage/): the uploaded file is 5120 × 2880 px, 412 KB for a panel that is at most ~900 px wide. Re-export at 1600 × 900, JPEG q80 or WebP (≈ 80–100 KB), re-upload in the platform configuration. Optional backend follow-up: resize on upload in PlatformThemeController::updateThemeSettings so the next admin cannot reintroduce it.
  • Language flags assets/images/english.png (979 × 512, 104 KB), arabic.png (767 × 512, 62 KB), french.png (768 × 512, 15 KB) are drawn at ~24 px. Resize to 64 × 64 (≈ 2–3 KB each). Only the current language’s flag loads on the login page, but all three ship.
  • Both load after the form, so they do not move “login form painted”, only “fully painted” (−0.4 s cold fast link, −0.9 s at 8 Mbit/s) and the cold-boot bytes.
8

Step 8 — Boot paint: login from the cached theme, no white page (frontend)

  • Stale-while-revalidate for the theme. handleCompanyTheme() already persists the two colors in SharedPreferences; also persist auth_page_image, platform_logo, remove_auth_image and company_name, and on boot resolve immediately from the cache when it exists, then run GET /platform/theme in the background and notifyListeners() only if something changed. The login form then paints right after the fonts (≈ 2.7 s cold instead of 3.25 s; ≈ 0.7 s warm instead of 1.15 s). The very first visit still waits for the call. Step 1 cuts that call’s preflight on every vhost that got the nginx block.
  • No SizedBox.shrink() on web while authResult == null (app.dart): show the same LoadingProgressWidget the phone shows, or keep the HTML .loader until the first route (remove it from index.html in a post-frame callback of the first page). Today the user sees white between the engine’s first frame and the theme response.
  • authUserAndRole() on boot is also what step 2 splits: bootstrapOnce() is the only place that runs the theme + device + language work.
web/index.html gotcha (hit on 19/10/2026, Update 127 “PWA removed”). The page boots with its own script (the flutter_js and flutter_build_config tokens followed by _flutter.loader.load() without serviceWorkerSettings). A comment in that block mentioned the default bootstrap token by name, and flutter build web substitutes every double-brace token it finds, comments included, with the full multi-line bootstrap script. Everything after its first newline fell outside the // comment, the script block failed to parse (SyntaxError: Unexpected identifier 'more'), _flutter.loader.load() never ran and the deployed test site showed the HTML spinner forever. Fix: no token text anywhere in index.html except where it must be substituted. Check a change to that file before deploying by running the substitution locally and node --check on every inline script (the JSON-LD block is data, not JavaScript).

3.1 Optional: paint the dashboard before the login response lands

After step 4 there is a single request left, so the usual “navigate now, fill in later” trick buys very little (~bcrypt + payload ≈ 300–400 ms of skeleton). It is listed here so the trade-off is on record and not recommended: the dashboard would briefly show empty stat cards and an empty “Continue learning” list, which is exactly the pop-in the user wants to avoid. Keep the button spinner; make the first frame the complete one.

4. Rollout order and compatibility

  1. Step 1 (CORS max_age + nginx preflight block): no app change. 1a is a backend deploy; 1b is pasted into the Plesk nginx directives of backend-test first, measured, then dev and prod. Preflight responses are cached per browser, so there is nothing to invalidate; if it ever has to be rolled back (remove the block, or max_age back to 0), old cache entries expire within 2 h.
  2. Steps 2 + 3 (frontend): work with any backend, web and mobile. Ship as one Update in major_code_updates.md with the before/after numbers of §5.
  3. Step 4: backend first (the extra user key is ignored by current app builds), then the frontend (which falls back to GET /auth/user when user is missing, so a stale backend keeps working). The docs page follows the backend commit.
  4. Step 5 items are independent; do 5.1 (opcache / optimize) first, it is the biggest and needs no code.
Nothing here changes the shape of GET /auth/user, the ?legacy=1 flag, the 2FA flow, the mobile restricted-role rule, or the first-login language dialog + tour.

5. How to measure before / after

In the browser (the number that matters). Chrome DevTools → Network, tick Preserve log, clear, click Login, stop when progress/leaderboard appears. Read:
  • the count of rows between auth/login and the first dashboard image (today 8 before the route, target 1–2);
  • the Waterfall column: the gap from the POST auth/login start to the GET progress/leaderboard start is “click → dashboard built”. Record it 3 times, take the median.
Performance panel → Record across the click gives the same gap as “first dashboard frame” plus the Dart build time of the shell (expected ≈ 50–120 ms, unchanged by this plan). On the server, without credentials (the §1.3 commands, repeat after step 1 and step 5.1):
With a token (from the app’s Network tab, never commit it): time GET /auth/user and POST /auth/login the same way; after step 4 compare the login total against the old login + auth/user sum. The app’s first load (§1.5) was captured with a small puppeteer-core script over the DevTools protocol (Network.enable, Network.clearBrowserCache, Network.emulateNetworkConditions for the 8 Mbit/s run), recording every request’s start, end and encodedDataLength, and waiting for flt-semantics-placeholder then network idle. Chrome DevTools → Network with Disable cache ticked gives the same waterfall by hand; the numbers to read are the end of main.dart.js, the end of the last font, the platform/theme response and the end of the auth background image.

5.1 Before / after, phase by phase

“Before” is what was measured on 19/10/2026 (backend-test / elearning-test, dev machine, fast link unless stated). “After” is the estimate once the named steps are in; the last column is for the real measurement when each step ships (put the same numbers in the Update entry). Totals, in seconds (app opened → login form on screen → Login clicked → dashboard built; the time the user spends typing is not counted; login → dashboard is the §1.3 estimate, so on the 8 Mbit/s line add ~0.1–0.2 s per remaining round trip): Reading the table: the bundle (3.3 MB) and its compile (0.5 s) stay as they are. That is the floor of a cold first load without the renderer work that is out of scope. Everything else on the page, fonts, theme, images, preflights, serial calls, is addressable in small steps.

6. Deliberately not in this plan

  • Flutter renderer / wasm: excluded by the user for now. Nothing above depends on it.
  • The Dart bundle (main.dart.js, 12.4 MB raw / 3.3 MB Brotli) and its 0.5 s compile. The only app-side lever is code splitting with deferred imports (import '…' deferred as): the student shell would stop loading the admin / instructor / content-manager / super-admin pages, the Syncfusion grids and the editors on first load. It is a routing refactor with its own test pass, so it is a separate plan, not a step here. Numbers to decide with: the bundle is 1.05 s of the 3.7 s cold load on a fast link and 5.1 s of the 8.6 s at 8 Mbit/s.
  • Caching the login payload in localStorage for F5 boots (the old §7.9 idea): a different path (cold boot, not login) and a stale-data risk; revisit only if the F5 path is still slow after steps 1 and 5.
  • A service worker / HTTP cache for API JSON: the payload is per-user and changes with every lesson; not worth the invalidation rules.
  • Lowering bcrypt rounds: listed in 5.5 as a decision, not a recommendation.
  • Removing Turnstile from login: no; 5.2 hides its cost instead.
  • Skeleton dashboard before the response (§3.1): would reintroduce pop-in.

7. Files touched

Related pages: Auth User Optimize (the payload work this builds on), Auth API §9 (the slim GET /auth/user shape), Student boot payload (history of the old flow).

8. Optional task — student cold load: the four ideas, evaluated

Asked on 19/10/2026: “use fewer fonts on the login / dashboard? skip the company theme endpoint on the login page and consume it after login? switch the icons? compress the default login right-side image?” Each idea is answered against the §1.5 capture and a count of the real call sites in lib/. Short version: three of the four are already most of steps 6–8; what they add on top is small, and the honest ceiling for a student cold load without touching the bundle is ≈ 2.65 s to the login form on a fast link (from 3.25 s today).
Status: OPTIONAL, not started. Do steps 6–8 first; the items below are the extra 5–10 % and the two “don’t bother” verdicts, kept here so nobody re-investigates them.

8.1 “Use fewer fonts on the login / dashboard” — partly yes, after step 6a

Facts (grep of FontWeight.w* on 19/10/2026):
  • Per-page font loading does not exist on Flutter web: whatever is in the manifest is downloaded before the first frame (§1.5). The only “later” mechanism is FontLoader for files kept out of fonts:; at 14 KB per subset weight (after 6a) it is not worth the reflow when the late weight arrives. So “fewer fonts for the login page” has to mean fewer fonts for the app.
  • Drop Light (300) and ExtraBold (800) altogether: 5 call sites each in the whole app, none on the login page or the dashboard. Replace them by 400 / 700 at those 10 sites (Flutter would snap to the nearest weight anyway) and delete the two files + their pubspec.yaml lines. Saves 2 requests and ~28 KB compressed after 6a (~300 KB today, before the subset).
  • Keep 400 / 500 / 600 / 700: 1 469 call sites; the dashboard uses all four.
  • Drop cupertino_icons: CupertinoIcons. has 0 uses in lib/; the package is only there from the Flutter template, and it puts CupertinoIcons.ttf (3 KB) into the manifest. Remove the dependency: 1 request fewer. (flutter_quill / Syncfusion do not declare it.)
  • FontAwesomeBrands carries 2 icons (facebookF, instagram, 1.9 KB file): replacing them with two tiny SVG assets removes 1 request. Marginal; do it only if the social links move anyway.
  • Result on top of step 6: the font phase goes from 9 requests / ~190 KB to 6 requests / ~150 KB (Poppins 400–700, MaterialIcons, FA solid + regular, Syncfusion grid icons, RobotoMono, and Roboto unless 6d). ≈ 0.05 s on a fast link, ≈ 0.1 s at 8 Mbit/s. Worth doing because it is also less to maintain, not because it is big.

8.2 “Ignore the theme endpoint on the login page, consume it after login” — no as stated, yes as “paint first, theme second”

  • The login page is the branded surface: GET /platform/theme gives it the two colors, the right-side image (auth_page_image), remove_auth_image, the logo and the company name. Fetching it only after login would show every white-label company the default green login on every visit. Not recommended.
  • What gives the same speed without losing branding, in this order:
    1. Cached theme (step 8): every visit after the first paints the login immediately from SharedPreferences, the call refreshes it in the background. This already removes the 0.55 s (cold) / 0.45 s (warm) wait on every return visit.
    2. First visit: render with the built-in defaults and do not wait either. handleCompanyTheme() already falls back to 119B78 / D48941 and the local authBackground.jpg; make authUserAndRole() resolve the login route before the theme call returns and let ThemeProvider.notifyListeners() recolor the page when it lands (one color flip, ~0.5 s, first visit only). The dashboard never sees this: by the time the user has typed a password the theme is in.
    3. Step 1b on the backend vhost makes that call’s preflight (376 of the 545 ms) disappear anyway, and step 2 stops the app from calling it a second time right after login.
  • Net: the theme is off the critical path in every scenario; the only visible trade is a one-time color flip on a brand-new browser. If that flip is unacceptable for a given tenant, keep the current blocking behavior for the first visit only (cache miss) and you still win on every later visit.

8.3 “Switch the icons with other ones” — don’t

  • Icon fonts are tree-shaken by the release build, so they are already small: MaterialIcons 44 KB (21 KB Brotli), Font Awesome solid + regular + brands 23 KB (~12 KB), Cupertino 3 KB, Syncfusion grid icons 3.6 KB. **74 KB and 7 requests today, ~40 KB and 5 requests after 6b
    • 8.1.**
  • Switching them to SVG widgets (what heroicons already is) would trade those ~40 KB for an SVG parse per icon at build time and a code change at ~290 FontAwesomeIcons.* call sites (fa_compat.dart). No measurable gain on the cold load; a real risk of regressions. The cheap wins are the two removals in 8.1 and the compression in 6b.

8.4 “Compress the default login right-side image” — yes, both of them

There are two images for that panel, and the test site shows the second: Both load after the form (CachedAuthImage / DecorationImage), so they move “fully painted”, not “form painted”: −0.3 s cold fast link, −0.8 s at 8 Mbit/s for the uploaded one; the default only matters on deployments without an uploaded image. Add the resize-on-upload of step 7 so a future upload cannot bring the 5120 px file back, and resize the three flag PNGs at the same time (second bullet of step 7).

8.5 Two small extras in the same waterfall

  • Cropper.js CSS + JS from cdnjs on every page load (13 KB, a third origin with its own DNS + TLS, defer): it is only used by the image cropper. Load it on demand from the widget that needs it (inject the <script> when the cropper opens) and drop the two <link> / <script> tags from web/index.html. ≈ 0.1 s of parallel work and one origin fewer on the cold load; nothing on warm loads.
  • AssetManifest.bin.json (22 KB) is fetched after the theme call, i.e. on the critical path of the first image. Nothing to change in the app (the engine requests it on the first rootBundle use); it just disappears behind the fonts once step 8 removes the theme wait.
Past this point the student cold load is the Dart bundle and its compile. The next lever is the code-splitting plan noted in §6, not fonts, icons or images.