Performance
This page lists what the code does, not general advice. Numbers come from the files cited.
Reference frontend (front/web)
Section titled “Reference frontend (front/web)”Budget and measurement
Section titled “Budget and measurement”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.
corepack yarn workspace @ulams/web buildcorepack yarn workspace @ulams/web start & # port 4321, reads front/web/.envcorepack yarn workspace @ulams/web perf # WEB_BASE_PORT for another portThe script is not part of CI.
Zero JavaScript by default
Section titled “Zero JavaScript by default”- 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 infront/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.cssto avoid layout shift. - Links are prefetched on hover or focus (
prefetchAll, strategyhover), andexperimental.clientPrerenderprerenders them in Chromium.
Server-side caches
Section titled “Server-side caches”| 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.
Response cache
Section titled “Response cache”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
Section titled “Valkey”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>_).
Queues and long jobs
Section titled “Queues and long jobs”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-1ondefaultwith 3 processes inlocalandstageand 10 inproduction;supervisor-builderonbuilder(timeout 1800, 3 processes);supervisor-long-jobonqueue-long-job(timeout 18000) with 10 processes inlocalandproductionand 1 instage. - Tenants:
api/queue.shstartsapi/workers.sh queue, which keeps three long-livedqueue:workprocesses 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 everyWORKERS_CHECK_INTERVALseconds, and a worker timeout is always at least the job timeout and belowretry_after.
HLS video
Section titled “HLS video”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.
Images
Section titled “Images”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.
PHP runtime
Section titled “PHP runtime”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) |
Load testing
Section titled “Load testing”There is no load-test suite in the repository. api/.gitignore lists a k6app/ directory, but
none is committed.