Skip to content

Performance

This page lists what the code does, not general advice. Numbers come from the files cited.

The budget in the front/web README is LCP under 1.0 s, JavaScript under 30 KB gzip on landing and course pages, and CLS under 0.05. The latest measured numbers are in docs/plans/phase-5-reference-frontend.md.

front/web/tests/perf/measure.mjs measures a running production build with Chromium: LCP, CLS, TTFB, load time, JavaScript transferred and total transfer for twelve pages (the platform landing, and landing, course, lesson, account and events pages of the demo tenants). Each page is loaded once to warm the server cache, then measured cold (new browser context) and warm (second visit), scrolling through the page so layout shifts below the fold count.

Terminal window
corepack yarn workspace @ulams/web build
corepack yarn workspace @ulams/web start & # port 4321, reads front/web/.env
corepack yarn workspace @ulams/web perf # WEB_BASE_PORT for another port

The script is not part of CI.

  • Pages are server-rendered on every request (output: "server"). Catalogue components are Astro components and send no JavaScript; interactive behaviour lives in small web components in front/ui/src/elements (video, quiz, progress, H5P bridge, navigation), loaded only by the components that use them.
  • HLS video loads hls.js (the light build) with a dynamic import when the learner presses play (front/ui/src/elements/video.ts).
  • Stylesheets are inlined (build.inlineStylesheets: "always").
  • Fonts are self-hosted through Astro’s font support, with fallback faces whose metrics are measured in @ulams/ui/styles/fallbacks.css to avoid layout shift.
  • Links are prefetched on hover or focus (prefetchAll, strategy hover), and experimental.clientPrerender prerenders them in Chromium.
Cache File Behaviour
Public API data src/lib/cache.ts, src/lib/data.ts In-process stale-while-revalidate: fresh for ULAMS_CACHE_TTL seconds (default 45), then served stale for up to 30 minutes while one deduplicated request refreshes it; a failed refresh keeps the stale value; at most 500 entries
Warm start src/lib/data.ts Tenants in ULAMS_WARM_TENANTS are fetched when the server starts
Images src/lib/image-cache.ts Results of Astro’s /_image endpoint (sharp) kept in memory for an hour, up to 64 MB, served with Cache-Control: public, max-age=31536000, immutable and X-Ulams-Image-Cache: hit

Both caches are per process. The code notes that a multi-instance deployment needs a shared cache or a CDN in front instead.

spatie/laravel-responsecache is registered by the courses package and applied, through the cacheResponse middleware alias, to two routes: GET /api/courses/{course}/program and GET /api/topics/{topic_id}/resources. Config in api/packages/courses/config/responsecache.php: file store by default (RESPONSE_CACHE_DRIVER), lifetime seven days (RESPONSE_CACHE_LIFETIME), RESPONSE_CACHE_ENABLED to switch it off. The cache key includes the server name, so tenants do not share entries (CacheGetRequestService). The whole response cache is cleared on every created, updated or deleted Eloquent event of any Ulams* model (packages/courses/src/Providers/EventServiceProvider.php), so on a busy tenant it is short-lived.

Valkey 8 (Redis protocol, BSD-3-Clause) is the cache store, the queue backend and the Horizon store in the compose stack (LARAVEL_CACHE_DRIVER=redis, LARAVEL_QUEUE_CONNECTION=redis). Each tenant gets its own key prefix (ulams_<slug>_).

Queue connections in api/config/queue.php. A job that can run longer than retry_after is picked up by a second worker while it still runs, so every long job has a connection whose retry_after is above its $timeout plus a margin, in a database and a redis variant (the jobs follow QUEUE_CONNECTION). Each long queue has its own queue name, because the name is shared by all connections of a driver.

Connection Queue retry_after Used for
redis, database default (also broadcast, video for tenant workers) 90 s everything else
redis-builder, database-builder builder 2400 s (BUILDER_QUEUE_RETRY_AFTER) Course Builder and Living Course steps (up to 1800 s), Adapt builds
redis-long-job, database-long-job queue-long-job 19000 s video processing, course clone (18000 s)

H5P and SCORM imports and PDF rendering are synchronous HTTP calls, not queued jobs. tests/Integrations/QueueRetryAfterConfigTest.php fails when a job’s connection has a retry_after below its timeout, when a job with a long timeout is not routed, or when the worker timeouts differ.

Workers (ULAMS_WORKERS_MODE=per-tenant; local development and the demo profile run lean, see Queues and the scheduler):

  • Platform: Horizon (api/config/horizon.php). supervisor-1 on default with 3 processes in local and stage and 10 in production; supervisor-builder on builder (timeout 1800, 3 processes); supervisor-long-job on queue-long-job (timeout 18000) with 10 processes in local and production and 1 in stage.
  • Tenants: api/queue.sh starts api/workers.sh queue, which keeps three long-lived queue:work processes per tenant domain: default,broadcast,video; the builder queue with --timeout=1800; the long-job queue with --timeout=18000. The tenant list is re-read every WORKERS_CHECK_INTERVAL seconds, and a worker timeout is always at least the job timeout and below retry_after.

packages/video converts an uploaded video topic into HLS with pbmedia/laravel-ffmpeg (ffmpeg is in the PHP image). The ProcessVideo job runs on <driver>-long-job / queue-long-job (VIDEO_QUEUE_CONNECTION, VIDEO_QUEUE), with $timeout = 18000 and 5 tries. Default renditions are 250, 500 and 1000 kbit/s (bitrates in packages/video/src/config.php); VIDEO_PROCESSING_ENABLE=false turns it off. DetectStuckVideo reports jobs that did not finish.

packages/images resizes images on request (GET /api/images/img, throttled by the images.render rate limiter; the batch form POST /api/images/img takes at most 20 paths, 4096 px per side, and is always throttled) within configured limits (max 1000×1000, a thumbnail size of 400×300), and spatie/laravel-image-optimizer uses the jpegoptim, optipng, pngquant and gifsicle binaries from the PHP image.

From api/docker/conf/php/ulams-custom-php.ini and the php-fpm pool files:

Setting Value Note
opcache.enable On opcache.enable_cli=Off
opcache.memory_consumption 128 MB max_accelerated_files=10000, interned_strings_buffer=8
opcache.validate_timestamps On, revalidate_freq=2 code changes are picked up within 2 s, in development and in the published image
opcache.jit tracing but opcache.jit_buffer_size=0, so the JIT is not active
memory_limit 2G max_execution_time=3600
realpath_cache_size 256k TTL 120 s
php-fpm pm.max_requests=500, listen.backlog=1024 request_terminate_timeout 3600 s (app pool file overrides the base 120 s)

There is no load-test suite in the repository. api/.gitignore lists a k6app/ directory, but none is committed.