How to speed up page load time

A browser cannot request a page's largest image until the image appears in the HTML or another early discovery signal. A hero inserted later by JavaScript can therefore dominate Largest Contentful Paint (LCP) even when the image file transfers quickly.

A server-rendered PHP storefront runs from /srv/storefront. Its active template is /srv/storefront/public/index.php, the Docker Compose build target is the web service, and that service runs the image tagged storefront-web:current.

Apply the image repair only when Lighthouse identifies the intended above-the-fold image as the LCP element and reports Resource load delay as the dominant subpart. A large Time to first byte, resource transfer duration, or element render delay needs a different repair.

Steps to speed up a server-rendered PHP page with Lighthouse:

  1. Change into the active storefront directory containing compose.yaml and the web build context.
    $ cd /srv/storefront
  2. Create a unique audit directory for the two Lighthouse reports, the template backup, and audit-only browser data.
    $ AUDIT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/page-load-audit.XXXXXX")

    mktemp creates the directory atomically and exits nonzero instead of reusing an existing path.

  3. Create the baseline Chrome profile inside the new audit directory.
    $ mkdir -m 700 "$AUDIT_DIR/chrome-before"
  4. Capture a mobile performance baseline for the public page served by this storefront.
    $ lighthouse http://www.example.com:8080/ --form-factor=mobile --only-categories=performance --output=json --output-path="$AUDIT_DIR/lighthouse-before.json" --chrome-flags="--headless=new --no-first-run --disable-gpu --user-data-dir=$AUDIT_DIR/chrome-before" --disable-storage-reset --quiet

    http://www.example.com:8080/ represents the public URL for the affected template; the baseline and retest URLs must be identical.

  5. Inspect every warning retained by the baseline report.
    $ jq '.runWarnings' "$AUDIT_DIR/lighthouse-before.json"
    []

    A nonempty array can indicate browser startup, storage, navigation, or measurement conditions that make the baseline unsuitable.

  6. Inspect the baseline LCP element, four subparts, and reconciled total.
    $ jq '(.audits["lcp-breakdown-insight"].details.items[] | select(.type == "table").items) as $p | {observed_lcp_ms: .audits.metrics.details.items[0].observedLargestContentfulPaint, subparts: ($p | map({label, duration_ms: (.duration | round)})), subpart_total_ms: ($p | map(.duration) | add | round), element: (.audits["lcp-breakdown-insight"].details.items[] | select(.type == "node") | .snippet)}' "$AUDIT_DIR/lighthouse-before.json"
    {
      "observed_lcp_ms": 1887,
      "subparts": [
        {"label":"Time to first byte","duration_ms":50},
        {"label":"Resource load delay","duration_ms":1814},
        {"label":"Resource load duration","duration_ms":2},
        {"label":"Element render delay","duration_ms":21}
      ],
      "subpart_total_ms": 1887,
      "element": "<img src=\"/images/hero.svg\" alt=\"Summer collection\">"
    }

    The displayed repair fits this result because the subparts reconcile to the observed 1,887 ms LCP and resource load delay accounts for 1,814 ms.

  7. Inspect the intrinsic dimensions of the LCP image in the storefront source tree.
    $ magick identify -format 'width=%w height=%h\n' public/images/hero.svg
    width=1620 height=838
  8. Back up the active PHP template inside the unique audit directory.
    $ cp -p public/index.php "$AUDIT_DIR/index.php.before"

    A shared home-page template affects every request using it; keep this byte-for-byte source backup until the new image is accepted.

  9. Preserve the currently deployed storefront image under the rollback tag.
    $ docker image tag storefront-web:current storefront-web:rollback

    The running container still uses its existing image while the replacement image is built.

  10. Replace the JavaScript-populated hero slot in /srv/storefront/public/index.php with the server-rendered LCP image element.
    public/index.php
    <img class="hero" src="/images/hero.svg"
         width="1620" height="838"
         loading="eager" fetchpriority="high"
         alt="Summer collection">

    The displayed dimensions belong to this storefront asset; another asset requires its own returned values.

  11. Remove the deferred /assets/hero-loader.js script tag from /srv/storefront/public/index.php.

    The removal is limited to the tag that inserts this same hero image because a shared loader may control unrelated page behavior.

  12. Build the web service image from the edited storefront source.
    $ docker compose build web
  13. Validate the PHP syntax inside the newly built storefront-web:current image.
    $ docker run --rm storefront-web:current php -l /var/www/html/index.php
    No syntax errors detected in /var/www/html/index.php
  14. Render the candidate PHP template into the audit directory without changing the running service.
    $ docker run --rm storefront-web:current php /var/www/html/index.php > "$AUDIT_DIR/candidate.html"
  15. Confirm that the candidate rendering contains the server-rendered LCP image.
    $ grep --fixed-strings '<img class="hero" src="/images/hero.svg"' "$AUDIT_DIR/candidate.html"
        <img class="hero" src="/images/hero.svg"
  16. Recreate only the web service from the validated image with health-check waiting enabled.
    $ docker compose up --detach --no-deps --force-recreate --wait web

    This command replaces the public application container without recreating dependent services. The rollback tag and saved template preserve the previous deployment and source for recovery.

  17. Download the public page's initial HTML response without running JavaScript.
    $ curl --fail --silent --show-error --output "$AUDIT_DIR/initial-after.html" http://www.example.com:8080/
  18. Confirm that the public initial HTML contains the server-rendered LCP image.
    $ grep --fixed-strings '<img class="hero" src="/images/hero.svg"' "$AUDIT_DIR/initial-after.html"
        <img class="hero" src="/images/hero.svg"

    No match makes grep exit nonzero and means the deployed response still hides the image from the preload scanner.

  19. Retag the rollback image as storefront-web:current if either public page check fails.
    $ docker image tag storefront-web:rollback storefront-web:current
  20. Recreate only the web service from the restored image if either public page check fails.
    $ docker compose up --detach --no-deps --force-recreate --wait web
  21. Create the retest Chrome profile after both public page checks pass.
    $ mkdir -m 700 "$AUDIT_DIR/chrome-after"
  22. Capture a mobile retest for the unchanged public URL.
    $ lighthouse http://www.example.com:8080/ --form-factor=mobile --only-categories=performance --output=json --output-path="$AUDIT_DIR/lighthouse-after.json" --chrome-flags="--headless=new --no-first-run --disable-gpu --user-data-dir=$AUDIT_DIR/chrome-after" --disable-storage-reset --quiet
  23. Inspect every warning retained by the retest report.
    $ jq '.runWarnings' "$AUDIT_DIR/lighthouse-after.json"
    []
  24. Inspect the retest LCP value, element, and reconciled subpart total.
    $ jq '(.audits["lcp-breakdown-insight"].details.items[] | select(.type == "table").items) as $p | {observed_lcp_ms: .audits.metrics.details.items[0].observedLargestContentfulPaint, subpart_total_ms: ($p | map(.duration) | add | round), element: (.audits["lcp-breakdown-insight"].details.items[] | select(.type == "node") | .snippet)}' "$AUDIT_DIR/lighthouse-after.json"
    {
      "observed_lcp_ms": 75,
      "subpart_total_ms": 75,
      "element": "<img class=\"hero\" src=\"/images/hero.svg\" width=\"1620\" height=\"838\" loading=\"eager\" fetchpriority=\"high\" alt=\"Summer collection\">"
    }
  25. Remove the audit-only baseline Chrome profile.
    $ rm -r "$AUDIT_DIR/chrome-before"
  26. Remove the audit-only retest Chrome profile.
    $ rm -r "$AUDIT_DIR/chrome-after"
  27. Confirm that both audit-only Chrome profiles are absent.
    $ test ! -e "$AUDIT_DIR/chrome-before" -a ! -e "$AUDIT_DIR/chrome-after"

    The reports, source backup, candidate rendering, and public initial response remain as the deployment record.

  28. Confirm that the cleaned audit records a lower LCP for the same public URL.
    $ jq --exit-status --slurp '.[0].finalDisplayedUrl == .[1].finalDisplayedUrl and (.[1].audits.metrics.details.items[0].observedLargestContentfulPaint < .[0].audits.metrics.details.items[0].observedLargestContentfulPaint)' "$AUDIT_DIR/lighthouse-before.json" "$AUDIT_DIR/lighthouse-after.json"
    true

    The command exits nonzero when the reports target different final URLs or the retest did not improve.