Performance & Asset Caching
How PPPHP delivers its module code to the browser, why the Designer used to re–download ~840 KB on every visit, and how to use the same caching for your modules. Also: the measuring habits that stop you from optimising the wrong thing.
Contents
- The problem — inlined assets can never be cached
- The solution —
ModuleAssets - Using it in your own module (3 lines)
- How cache-busting works — the
?v=token - What the asset route refuses to serve
- Measured results
- The 78 KB duplicate-CSS story
- Incremental saves — sending only what changed
- How to measure before you optimise
- Checklist & troubleshooting
The problem — inlined assets can never be cached
Only the public/ folder is reachable from the web. Everything under
modules/ is deliberately not, which protects your controllers and config.
That left module views one way to deliver their JavaScript and CSS — print the file
into the HTML:
<!-- the old pattern, still used by several modules -->
<style><?php readfile(__DIR__ . '/styles.css'); ?></style>
<script><?php readfile(__DIR__ . '/app.js'); ?></script>
It works, and for a small file it is perfectly fine. The catch is what it costs on a second visit. A browser can only cache something that has its own URL. Code printed inside the HTML has no URL of its own, so it is part of the page — and the page is re-sent every single time.
The solution — ModuleAssets
core/ModuleAssets.php gives module files a real, public, versioned URL
without moving them out of the module:
/module-asset/Designer/Views/app/registry.js?v=4f0dea4479
└── route └── module └── path inside the module └── content version
Three things make this fast:
- It answers early. The handler runs in
public/index.phpbefore the container, module discovery and the database are touched. An asset request costs astat()and areadfile(), not a full application boot. - It is cached for a year. Responses carry
Cache-Control: public, max-age=31536000, immutable. The browser will not even ask again — it reads its own copy. - It still updates instantly. The
?v=token is derived from the file, so editing the file changes the URL, and a changed URL is a cache miss. No "clear your cache" ritual, ever.
Using it in your own module (3 lines)
Say your module is modules/Reports/ with
Views/assets/reports.js and Views/assets/reports.css.
1. Import the helper at the top of your view:
use PpPhp\Core\ModuleAssets;
2. In <head>, replace the inlined style:
<!-- before -->
<style><?php readfile(__DIR__ . '/assets/reports.css'); ?></style>
<!-- after -->
<?= ModuleAssets::style('Reports', 'Views/assets/reports.css', $baseUrl) ?>
3. Before </body>, replace the inlined script:
<!-- before -->
<script><?php readfile(__DIR__ . '/assets/reports.js'); ?></script>
<!-- after -->
<?= ModuleAssets::script('Reports', 'Views/assets/reports.js', $baseUrl) ?>
That is the whole change. If you need only the URL — for a preload hint, a web worker, or an image — use:
$url = ModuleAssets::url('Reports', 'Views/assets/logo.svg', $baseUrl);
// /module-asset/Reports/Views/assets/logo.svg?v=1a2b3c4d5e
<script src> tags run
in the order they appear, exactly as inlined blocks did. The Designer relies on this
(store → api → model → registry → theme → renderer → inspector → panels → app)
and needed no code changes. Do not add async to
order-dependent scripts.
How cache-busting works — the ?v= token
The token is a short hash of the file's modification time and size:
$st = stat($file);
$version = substr(md5($st['mtime'] . ':' . $st['size']), 0, 10);
That is a stat() — the file is never read just to compute a version,
so building the page stays cheap. The lifecycle:
- You edit
reports.js. Its mtime changes. - The next page render emits
reports.js?v=newtoken. - The browser has never seen that URL, so it downloads it. Your users get the update on a normal reload.
- Every later visit re-uses the cached copy — zero bytes over the wire.
Two conditional-request shortcuts are also honoured, for proxies or clients that ask
anyway. Both answer 304 Not Modified with an empty body:
Request : If-None-Match: "4f0dea4479" → 304, 0 bytes
Request : If-Modified-Since: <a later date> → 304, 0 bytes
What the asset route refuses to serve
A public route that reads files from disk must be strict. This one is allow-list based, not block-list based:
- Extension allow-list. Only
js, mjs, css, map, svg, png, jpg, jpeg, gif, webp, woff, woff2. A.phpfile is never served, so your controllers andmodule.jsonstay private. - Module names are restricted to
[A-Za-z0-9_]+. - Traversal is blocked twice — literal
..is rejected, and the resolved path (afterrealpath()) must still sit inside the module folder. That defeats symlink tricks too. X-Content-Type-Options: nosniffis always sent.
Verified behaviour — every one of these returns 404:
/module-asset/Designer/Views/index.php 404 (.php not allowed)
/module-asset/Designer/Controllers/DesignerController.php 404 (.php not allowed)
/module-asset/Designer/module.json 404 (extension not allowed)
/module-asset/Designer/../../config/database.php 404 (traversal)
/module-asset/Designer/Views/app/../../../../core/DB.php 404 (traversal)
/module-asset/Bad-Module!/x.js 404 (illegal module name)
Measured results
The Designer, before and after (same page, same features):
| Measurement | Before | After |
|---|---|---|
| HTML document | 929 KB | 50 KB (−94.5%) |
| First visit, total | 929 KB | 869 KB |
| Every later visit | 929 KB | 50 KB |
| Saved per repeat visit | — | 819 KB |
The first visit barely changes — the same bytes still have to arrive once. The win is every visit after that, which is what a tool you open all day actually does.
The 78 KB duplicate-CSS story
While auditing, designer.css turned out to contain an exact copy of itself:
lines 168–1033 repeated verbatim at 1034–1899. A copy–paste accident, and
a quiet hazard — edit one copy and not the other and the two silently disagree.
Deleting one copy was safe to prove rather than assume, because the copies were identical and adjacent, so the cascade could not change. The proof is a technique worth re-using for any risky CSS change — a computed-style fingerprint:
// Run in the browser console BEFORE the change, then again AFTER.
// Identical hash = the change is visually a no-op, across the whole page.
(() => {
const PROPS = ['display','position','width','height','margin','padding','border',
'background-color','color','font-size','font-weight',
'grid-template-columns','gap','border-radius','box-shadow','flex','opacity','z-index'];
const fp = [...document.querySelectorAll('*')].map(el => {
const cs = getComputedStyle(el);
return el.tagName + '|' + PROPS.map(p => cs.getPropertyValue(p)).join('~');
});
let h = 0; const s = fp.join('\n');
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return { elements: fp.length, hash: h };
})()
Result: the same hash over 794 elements, with 593 fewer CSS rules parsed. The duplicate
was also being copied into every generated app, so each built app's
components.css shrank from 84 KB to 64 KB as a bonus.
Incremental saves — sending only what changed
The whole design is one JSON document. Autosave used to POST all of it one second after every edit — fine for a two-table project, wasteful for a real one:
| Project size | Every autosave used to send |
|---|---|
| 1 table | ~53 KB |
| 20 tables | ~470 KB |
| …while a single page slot is about 3 KB. | |
Now the client compares the current state against the last copy the server acknowledged and sends only the slots that differ:
POST /project/{slug}/designer/save
{
"ops": [
{ "path": ["pages", "customers", "add"], "value": { …the page slot… } }
],
"baseToken": "9f2c1ad39b4e77a1"
}
Why a diff and not "dirty" flags
The obvious design is to mark things dirty as the user edits them. It is also the design that loses work: one mutation path that forgets to set its flag, and that edit is never sent — silently, and usually only in production. So the changed slots are worked out by comparing the data. A diff cannot forget; its worst failure is being too generous and sending a slot that did not really change.
The four safety rails
- Only page containers are diffed —
pages,templates,common. If any other top-level key changed, the client sends the whole document instead. - The base token. Every save returns a fingerprint of the stored
design. The next delta sends it back; if it does not match, the stored design moved
on (a second tab, another user) and the server answers
{ needFull: true }rather than merging onto a state nobody saw. The client then resends everything, automatically. - The server validates every path. Only those three roots, only at
slot depth, segments restricted to
[A-Za-z0-9_.-], values must be objects. Anything else is refused and the client falls back to a full save. - Any failure degrades to the old behaviour. A network error clears the baseline so the next save is a full one. The first save of a session is always full — that is what establishes the token.
Measured
A realistic session over a 20-table project — five edits touching two pages and one template — verified by replaying the ops through the real server merge and comparing the result against a full save of the same final state:
server state after 5 delta saves === full-save state : IDENTICAL
bytes if every save were full : 71.3 KB
bytes actually sent (delta) : 0.8 KB
reduction : 98.9%
{} into an empty array [], because PHP has one array type for
both. That has always happened on full saves too, and nothing reads those values
differently — but it is why the correct test compares "after delta saves" against
"after a full save", not against the raw browser state.
How to measure before you optimise
The audit's most useful outcome was the list of things that turned out not to need fixing. The canvas re-renders by replacing all of its HTML on every change, which sounds expensive. Measured, it is not:
// Average a real operation over many runs — one run is mostly noise.
const time = (label, fn, n = 20) => {
const t0 = performance.now();
for (let i = 0; i < n; i++) fn();
console.log(label, ((performance.now() - t0) / n).toFixed(2) + ' ms');
};
time('render (typical page)', () => PPD.renderer.render());
| Operation | Time |
|---|---|
| Render, typical page | 0.3 ms |
| Render, 48-field Cell Grid (794 DOM nodes) | 1.0 ms |
| Render, 120 fields | 1.6 ms |
| Selection change (canvas + properties panel) | ~2.5 ms |
Rewriting that render path would have been days of risk for an invisible gain. The real costs were elsewhere entirely — bytes on the wire and a duplicated file.
Checklist & troubleshooting
- My edit isn't showing up. Confirm the URL's
?v=changed. If it did not, the file's mtime did not change (some editors write in place) —touchthe file. - 404 on my asset. Check the extension is on the allow-list, the path
is relative to
modules/<Module>/(so it starts withViews/…), and the module folder name matches exactly — it is case-sensitive. - Scripts run in the wrong order. Emit them in dependency order and do
not add
async. - Keep inlining when it is genuinely per-request. Bootstrap data such
as
window.PpDesignerBootstrapchanges with every page and every project, so it belongs in the HTML. Cache what is static; inline what is not. - Every module is migrated. All 98 inlined includes across 18 module
views now use
ModuleAssets. The biggest single win was the Style module, which was inlining the Designer'sregistry.jsanddesigner.css— 467 KB — into every page load. New views should follow the same pattern; only genuinely per-request data stays inline.
Related: Building Designer Components and
Building Field Plugins in this Help section. Implementation lives in
core/ModuleAssets.php, wired in public/index.php.