Skip to content

Split Prebid into deferred bundle to reduce render-blocking JS#393

Open
aram356 wants to merge 10 commits intomainfrom
split-prebid-deferred-bundle
Open

Split Prebid into deferred bundle to reduce render-blocking JS#393
aram356 wants to merge 10 commits intomainfrom
split-prebid-deferred-bundle

Conversation

@aram356
Copy link
Collaborator

@aram356 aram356 commented Mar 3, 2026

Summary

Closes #358

Prebid.js (168 KB raw) was 80% of the TSJS unified bundle, served as a single render-blocking <script> in <head>. This splits it into a separate <script defer> tag so the critical-path bundle drops from 190 KB to 22 KB.

Performance results (local dev environment, Chrome DevTools trace, no network throttling)

Metric Before After Delta
Render-blocking JS 190,626 bytes 21,869 bytes -88.5%
TTFB 107 ms 94 ms -13 ms (-12%)
First Paint 676 ms 296 ms -380 ms (-56%)
First Contentful Paint 676 ms 296 ms -380 ms (-56%)
DOM Content Loaded 685 ms 323 ms -362 ms (-53%)
DOM Complete 716 ms 343 ms -373 ms (-52%)
LCP 672 ms 293 ms -379 ms (-56%)
CLS 0.00 0.00 no change

Performance results (staging vs production, p95 over 10 runs, Chrome 1440x900)

Metric Production (no split) Staging (split) Delta
Render-blocking JS 228,522 bytes 46,156 bytes -80%
TTFB 231 ms 212 ms -8%
First Paint 444 ms 347 ms -22%
First Contentful Paint 444 ms 347 ms -22%
DOM Content Loaded 453 ms 374 ms -17%
DOM Complete 456 ms 377 ms -17%
LCP 444 ms 347 ms -22%
CLS 0.019 0.020 ~same

Staging serves the split bundle: 46 KB blocking (unified) + 182 KB non-blocking (prebid deferred). Production serves a single 228 KB blocking bundle.

Metric definitions
  • Render-blocking JS — Total bytes of JavaScript that must be downloaded and executed before the browser can render any content. Directly delays all paint metrics.
  • TTFB (Time to First Byte) — Time from the browser's navigation request until the first byte of the HTML response arrives. Measures server responsiveness. Unaffected by this change (included as baseline).
  • First Paint (FP) — When the browser renders the first pixel to the screen (background color, border, etc.). Indicates the page is starting to load visually.
  • First Contentful Paint (FCP) — When the browser renders the first piece of actual content (text, image, canvas). This is when users first see something meaningful. Core Web Vital.
  • DOM Content Loaded — When the HTML has been fully parsed and all deferred scripts have executed. JavaScript that listens for DOMContentLoaded can now run.
  • DOM Complete — When the page and all sub-resources (images, stylesheets, iframes) have finished loading.
  • LCP (Largest Contentful Paint) — When the largest visible element (hero image, heading block, etc.) finishes rendering. Measures perceived load speed. Core Web Vital that directly affects search ranking.
  • CLS (Cumulative Layout Shift) — Measures unexpected visual movement of page content during loading (e.g., elements shifting as images or ads load). 0.00 = no shifts. Core Web Vital that directly affects search ranking.

Why this is safe

  • Each integration is already built as a separate IIFE by build-all.mjs
  • Prebid's IIFE inlines its own dependencies (esbuild handles this at build time)
  • Prebid auto-initializes on load and reads window.__tsjs_prebid (injected before it via inline script)
  • defer preserves document order — inline config runs first, then main bundle, then prebid
  • No other integration depends on prebid being loaded
  • Publishers use pbjs.que queue pattern, so commands are queued until prebid loads

What changed

Split Prebid into deferred bundle (8 files, Rust-only — no JS or build changes)

  • crates/js/src/bundle.rs — Added single_module_hash() for per-module cache busting.
  • crates/js/src/lib.rs — Exported new functions.
  • crates/common/src/tsjs.rs — Added deferred script tag helpers (tsjs_deferred_script_src(), tsjs_deferred_script_tag(), tsjs_deferred_script_tags()).
  • crates/common/src/integrations/registry.rs — Added js_module_ids_immediate() and js_module_ids_deferred() with tests.
  • crates/common/src/html_processor.rs — Updated <head> injection to emit main bundle + deferred tags separately.
  • crates/common/src/publisher.rs — Updated handle_tsjs_dynamic to serve both unified and deferred bundles with allowlist validation.
  • crates/common/src/integrations/prebid.rs — Updated test assertions for deferred tag.
  • docs/guide/integration-guide.md — Updated Prebid load timing documentation.

Replace hardcoded constant with per-integration builder flag (10 files)

Replaced the DEFERRED_MODULE_IDS constant in tsjs.rs with a .with_deferred_js() method on IntegrationRegistrationBuilder. Each integration now declares its own loading mode instead of maintaining a central list.

  • crates/common/src/integrations/registry.rs — Added js_deferred field to IntegrationRegistration, .with_deferred_js() builder method, and deferred_js_ids storage in registry. Updated js_module_ids_immediate()/js_module_ids_deferred() to use it.
  • crates/common/src/integrations/prebid.rs — Added .with_deferred_js() to registration.
  • crates/common/src/tsjs.rs — Removed DEFERRED_MODULE_IDS constant and _all() convenience functions.
  • crates/common/src/creative.rs — Uses explicit module list &["creative"] instead of _all().
  • crates/common/src/integrations/testlight.rs — Uses explicit &["testlight"] instead of _all().
  • crates/common/src/publisher.rsparse_deferred_module_filename validates against all_module_ids() (deferred check in caller).
  • crates/js/src/bundle.rs — Removed all_module_ids_excluding().
  • crates/js/src/lib.rs — Removed export.
  • CLAUDE.md, docs/guide/integration-guide.md — Updated docs.

Fix cache-busting hash for unified bundle (3 files)

  • crates/common/src/tsjs.rs — Added tsjs_unified_script_src() and tsjs_unified_script_tag() that hash all immediate module IDs (not just a subset).
  • crates/common/src/creative.rs — Uses tsjs_unified_script_tag().
  • crates/common/src/integrations/testlight.rs — Uses tsjs_unified_script_src().

Resulting HTML output

When prebid is enabled:

<script>window.__tsjs_prebid={...};</script>
<script src="/static/tsjs=tsjs-unified.min.js?v=abc" id="trustedserver-js"></script>
<script src="/static/tsjs=tsjs-prebid.min.js?v=def" defer></script>

When prebid is disabled, no deferred tag is injected.

Test plan

  • cargo test --workspace — all 424 tests pass
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • Chrome DevTools performance trace confirms split loading (local)
  • Verify on staging with prebid enabled: two script tags, correct sizes
  • Verify on staging with prebid disabled: no deferred tag, no 404s
  • Confirm bid responses still flow through /auction orchestrator

Prebid.js (168 KB) was 80% of the TSJS unified bundle, blocking page
rendering. Split it into a separate `<script defer>` tag so the
critical-path bundle drops from 190 KB to 22 KB.

Changes across 8 files:

- crates/js/src/bundle.rs: add `single_module_hash()` and
  `all_module_ids_excluding()` for deferred module serving
- crates/js/src/lib.rs: export new bundle functions
- crates/common/src/tsjs.rs: add `DEFERRED_MODULE_IDS` constant (single
  source of truth), deferred script tag generation functions, update
  `_all()` variants to exclude deferred modules
- crates/common/src/integrations/registry.rs: add
  `js_module_ids_immediate()` and `js_module_ids_deferred()` with tests
- crates/common/src/html_processor.rs: inject split tags at `<head>`:
  synchronous main bundle + deferred prebid tag
- crates/common/src/publisher.rs: serve deferred modules at
  `/static/tsjs=tsjs-{id}.min.js` with allowlist validation and tests
- crates/common/src/integrations/prebid.rs: fix test assertion for new
  deferred tag presence
- docs/guide/integration-guide.md: update Prebid load timing docs

Closes #358
@aram356 aram356 changed the title Split Prebid into deferred bundle to reduce render-blocking JS by 88% Split Prebid into deferred bundle to reduce render-blocking JS Mar 3, 2026
@aram356 aram356 marked this pull request as draft March 3, 2026 06:40
aram356 added 4 commits March 2, 2026 22:42
Update CLAUDE.md and integration-guide.md to indicate which
integrations use deferred loading and explain the two loading modes.
Each integration now declares deferred JS loading via .with_deferred_js()
on IntegrationRegistrationBuilder, replacing the static constant that
required manual maintenance. Removes unused _all() convenience functions
from tsjs.rs and all_module_ids_excluding() from bundle.rs.
@aram356 aram356 self-assigned this Mar 3, 2026
aram356 added 2 commits March 3, 2026 12:02
tsjs_script_src(&["testlight"]) hashed only core+testlight, but the
unified bundle contains all immediate modules. Added tsjs_unified_script_src()
and tsjs_unified_script_tag() that hash all module IDs so the cache key
matches the actual served content.
@aram356 aram356 marked this pull request as ready for review March 4, 2026 08:27
Copy link
Collaborator

@prk-Jr prk-Jr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This PR achieves a strong perf win by splitting Prebid out of the render-blocking bundle. I found one high-severity compatibility risk with deferred loading and two low-priority cleanup items.

Findings

🔧 High (P1)

  • Deferred Prebid can break pages with bare pbjs calls: The HTML rewrite removes publisher Prebid script tags and loads Prebid via <script defer>, but there is no early window.pbjs bootstrap. Inline scripts that call pbjs.que.push(...) without first initializing pbjs can throw before deferred Prebid executes.

⛏ Low (P3)

  • Docs inconsistency after deferred split: docs/guide/integration-guide.md still has earlier sections describing Prebid as part of the unified sync bundle and describing head insert ordering that no longer matches implementation.

CI Status

  • cargo fmt: PASS
  • cargo test: PASS
  • vitest: PASS

Inject window.pbjs, pbjs.que, and pbjs.cmd in the head injector inline
script so publisher pages with bare pbjs.que.push() calls don't throw
before the deferred bundle executes. Also fix stale doc references that
still described Prebid as part of the unified sync bundle.
Copy link
Collaborator

@ChristianPavilonis ChristianPavilonis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good.

@aram356 aram356 requested a review from prk-Jr March 5, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Try loading TSJS with async

3 participants