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.
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)
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 againstbackend-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).
Table of contents
- TL;DR
- What happens today (traced and measured)
- Target sequence
- The plan, ranked by gain per effort
- Rollout order and compatibility
- How to measure before / after
- Deliberately not in this plan
- Files touched
- Optional task — student cold load: the four ideas, evaluated
1. What happens today (traced and measured)
1.1 The sequence, file by file
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 setsContent-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.
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.
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:
1.4 Small things that are fine (don’t spend time on them)
SecureStorage.saveToken()on web (WebCrypto +localStorage): a few ms.ThemeProvider.setPrimaryColor()→ twoSharedPreferenceswrites: 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/ shellSelector5recompute progress on every build; with card-levelprogressfrom 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.
flutter/engine/src/flutter/lib/web_ui):
- Flutter web downloads every font listed in
FontManifest.jsonbefore the first frame.initializeEngineServicesawaitsFuture.wait([initializeRenderer, _downloadAssetFonts]), andloadAssetFontsfetches 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’spubspec.yamlfonts:plus every font declared by a dependency (syncfusion_flutter_pdfvieweradds RobotoMono,font_awesome_flutterthree files,syncfusion_flutter_datagridtwo,cupertino_iconsone). - Poppins is 6 × ~153 KB because the files carry Devanagari.
Poppins-Regular.ttfhas 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 withpyftsubset, 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 forar). - The frontend vhost serves
font/ttfuncompressed (content-encodingabsent on every font, while.js/.jsonarrive as Brotli): the nginxbrotli_types/gzip_typeslist 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 nocache-controlheader (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
Robotofamily (loadAssetFonts,loadedRoboto). - Any emoji on screen triggers a 709 KB download (
Noto Color Emojisubset 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.dartrendersSizedBox.shrink()whileauthResult == 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.
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
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
Step 1 — Preflights: cache them (1 line) and answer them in nginx (1 directive block)
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.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.- It mirrors what
config/cors.phpdoes 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 servelocally 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 … lastinteracts with Plesk’s PHP location. Then uselocation ^~ /api/ { if ($request_method = OPTIONS) { … return 204; } … }and copy Plesk’sproxy_pass/proxy_set_headerlines from the generated/var/www/vhosts/system/<domain>/conf/nginx.confinto it. - Apply on
backend-testfirst, measure with the §5 commands (theOPTIONSrow should fall from ~250 ms to ~60–90 ms total from the dev machine), thendevandprod.
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.Step 2 — Don't re-run the boot work after login (frontend only)
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.Step 3 — Take the statistics call off the critical path (frontend only)
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()setsstudentDatawithoutnotifyListeners(); today that is harmless only because the call is awaited before the dashboard exists. AddisLoading+notifyListeners()on success and failure.LearningActivityCardalready doescontext.watch<StudentStatisticProvider>()and the phone dashboard uses aConsumer, so they repaint on their own once the provider notifies. Show the card’s loading state whileisLoadingso it doesn’t flash “Nothing to chart yet” first.handleStudentRole(): replaceawait statisticswithunawaited(statistics)(thecatchErrorinside the provider already swallows failures).refreshAuthUser()(phone pull-to-refresh) may keep awaiting so the refresh indicator spins until the chart is fresh.
Step 4 — POST /auth/login returns the boot payload (backend → frontend → docs)
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
POSTstill answers402; the secondPOST(with2fa_code/2fa_recovery_code) returns theuserblock too. - No
?legacy=1on login: only new app builds readuser; old builds ignore the key and keep callingGET /auth/user. The 28-query budget ofAuthUserPayloadTestapplies here too. - Test: in
tests/Feature/AuthUserPayloadTest.php(or a newLoginPayloadTest), log in with the seeded student and assertresponse['user']equalsGET /auth/userfor the same user (minus volatile fields), and that a staff login carries no student blocks.
-
LoginResponse: addfinal User? user;(@JsonKey(name: 'user')), regenerate withdart 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.
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).Step 5 — Server hot-path hygiene (each one small, all measurable)
-
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 withopcache.validate_timestamps=0in production (php -i | grep opcacheon the box). An emptyOPTIONSat 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 honorspublic/.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’spublic/and.htaccessis 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 onbackend-testonly, with the §5 numbers before / after; switching the handler back in Plesk is the rollback. -
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):Savesmin(bcrypt, Cloudflare RTT)≈ 100–250 ms per production login. Read the secret fromconfig('services.turnstile.secret')(fed byCLOUDFLARE_TURNSTILE_SECRET_KEY, already in.env.example) instead of a literal in the controller. -
UserPolicy::viewruns$user->permissions()->pluck('id')(one query) before testing$user->id === $model->id. Put the self check first: one query less onGET /auth/userand on the login payload. -
throttle:apiwithCACHE_DRIVER=filedoes a locked file read + write per request. If the vhost has APCu or Redis, pointCACHE_DRIVERat it (also speeds up every other request). Minor, but free. -
bcrypt rounds:
BCRYPT_ROUNDSdefaults 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. -
Sanctum stateful domains: if a production
.envlists the frontend domain inSANCTUM_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.
Step 6 — Fonts: 1 138 KB before the first frame → ~190 KB (frontend + Plesk)
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: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.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 inPlatformThemeController::updateThemeSettingsso 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.
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 inSharedPreferences; also persistauth_page_image,platform_logo,remove_auth_imageandcompany_name, and on boot resolve immediately from the cache when it exists, then runGET /platform/themein the background andnotifyListeners()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 whileauthResult == null(app.dart): show the sameLoadingProgressWidgetthe phone shows, or keep the HTML.loaderuntil the first route (remove it fromindex.htmlin 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
- Step 1 (CORS
max_age+ nginx preflight block): no app change. 1a is a backend deploy; 1b is pasted into the Plesk nginx directives ofbackend-testfirst, measured, thendevandprod. Preflight responses are cached per browser, so there is nothing to invalidate; if it ever has to be rolled back (remove the block, ormax_ageback to 0), old cache entries expire within 2 h. - Steps 2 + 3 (frontend): work with any backend, web and mobile. Ship as one Update in
major_code_updates.mdwith the before/after numbers of §5. - Step 4: backend first (the extra
userkey is ignored by current app builds), then the frontend (which falls back toGET /auth/userwhenuseris missing, so a stale backend keeps working). The docs page follows the backend commit. - Step 5 items are independent; do 5.1 (opcache /
optimize) first, it is the biggest and needs no code.
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 whenprogress/leaderboard appears. Read:
- the count of rows between
auth/loginand the first dashboard image (today 8 before the route, target 1–2); - the Waterfall column: the gap from the
POST auth/loginstart to theGET progress/leaderboardstart is “click → dashboard built”. Record it 3 times, take the median.
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).
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
localStoragefor 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
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 inlib/. 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).
8.1 “Use fewer fonts on the login / dashboard” — partly yes, after step 6a
Facts (grep ofFontWeight.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
FontLoaderfor files kept out offonts:; 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.yamllines. 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 inlib/; the package is only there from the Flutter template, and it putsCupertinoIcons.ttf(3 KB) into the manifest. Remove the dependency: 1 request fewer. (flutter_quill/ Syncfusion do not declare it.) FontAwesomeBrandscarries 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/themegives 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:
- 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. - First visit: render with the built-in defaults and do not wait either.
handleCompanyTheme()already falls back to119B78/D48941and the localauthBackground.jpg; makeauthUserAndRole()resolve the login route before the theme call returns and letThemeProvider.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. - 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.
- Cached theme (step 8): every visit after the first paints the login immediately from
- 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
heroiconsalready is) would trade those ~40 KB for an SVG parse per icon at build time and a code change at ~290FontAwesomeIcons.*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: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 fromweb/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 firstrootBundleuse); it just disappears behind the fonts once step 8 removes the theme wait.