post

Your Users Are Already Telling You How to Bundle

· 11 min read

Every browser session that hits your application is a vote. Chunk A and chunk B loaded together in 93% of sessions? They should be one chunk. Chunk C only appears alongside chunk D when the user opens settings? Don’t ship them in the initial load.

Your bundler doesn’t know any of this. Chunk boundaries are set at build time from static analysis of the import graph: which modules share a dynamic import, which packages are vendor code, which routes are lazy. Reasonable defaults. But they’re guesses. The optimal grouping depends on how users actually navigate, and that’s runtime data the build never sees.

The users already have the answer. Every session tells you which chunks co-occur. Collect enough sessions and the optimal grouping emerges from data, not from heuristics.

This is Cloudpack’s PGO layer. Profile-guided optimization applied to HTTP chunk grouping. The feedback loop runs on live traffic. No rebuild required.

The co-request matrix

The Adaptive Bundle Service logs every session’s chunk fetch sequence: which chunks, what order, which entry point. Each session produces one JSONL record. The PGO store ingests these into a SQLite table:

CREATE TABLE IF NOT EXISTS sessions (
    session_id   TEXT    NOT NULL,
    entry_point  TEXT    NOT NULL,
    chunk_id     TEXT    NOT NULL,
    load_order   INTEGER NOT NULL,
    timestamp_ms INTEGER NOT NULL,
    PRIMARY KEY (session_id, chunk_id)
);
CREATE INDEX IF NOT EXISTS idx_chunk ON sessions(chunk_id);
CREATE INDEX IF NOT EXISTS idx_entry ON sessions(entry_point, chunk_id);

One row per (session_id, chunk_id) pair. load_order preserves position: 0 was the first chunk fetched, 1 was second. Every clustering query reduces to SQL aggregates over this single table.

Two questions give you everything you need.

How many sessions loaded chunk A? COUNT(DISTINCT session_id) WHERE chunk_id = 'A'.

How many sessions loaded both A and B?

SELECT COUNT(DISTINCT a.session_id)
FROM sessions a
JOIN sessions b ON a.session_id = b.session_id
WHERE a.chunk_id = ?1 AND b.chunk_id = ?2

Divide the second by the first: P(B|A), the probability that a session loading chunk A also loads chunk B. Compute both directions. When P(B|A) > 0.70 and P(A|B) > 0.70, those chunks belong together.

The Feedback Loop Browser Sessions JSONL Records chunk IDs + load order SQLite PGO Store co-request matrix builds C³ Clustering merge candidates ranked Manifest Hints advisory fields updated ABS Delivery grouping improves the loop runs continuously on live data Production traffic continuously improves chunk layout. No rebuild required.

C³: Conditional Co-request Clustering

The algorithm is called C³. It descends directly from BOLT’s C³ clustering, which reorders basic blocks in compiled binaries based on measured call frequencies. BOLT proved in 2019 that static code layout loses to measured co-occurrence data. Google’s Propeller applied the same principle at link time. Cloudpack applies it to HTTP chunk delivery.

The Rust implementation:

/// Tuning knobs for the C³ algorithm.
pub struct C3Config {
    /// Both P(b|a) and P(a|b) must exceed this for a merge.
    /// Default: 0.70.
    pub merge_threshold: f64,
    /// Max combined module count after merge. Default: 500.
    pub max_merge_modules: usize,
    /// No clustering below this session count. Default: 100.
    pub min_sessions: usize,
}

Three knobs. The threshold at 0.70 means both conditional probabilities must exceed 70%. Not one direction. Both. If P(B|A) is 0.95 but P(A|B) is 0.30, chunk B almost always appears when A does, but A loads in many sessions without B. That’s a prefetch candidate, not a merge candidate.

The min_sessions gate prevents decisions on insufficient data. Below 100 sessions, every chunk returns None as its merge suggestion. No data is better than noisy data.

The core algorithm:

pub fn compute_clusters(
    store: &PgoStore,
    chunk_ids: &[String],
    config: &C3Config,
) -> anyhow::Result<HashMap<String, Option<String>>> {
    let mut result: HashMap<String, Option<String>> =
        chunk_ids.iter().map(|id| (id.clone(), None)).collect();

    if store.session_count()? < config.min_sessions {
        return Ok(result);
    }

    let mut candidates: Vec<(f64, String, String)> = Vec::new();
    let n = chunk_ids.len();
    for i in 0..n {
        for j in (i + 1)..n {
            let (a, b) = if chunk_ids[i] <= chunk_ids[j] {
                (&chunk_ids[i], &chunk_ids[j])
            } else {
                (&chunk_ids[j], &chunk_ids[i])
            };

            let co = store.co_load_count(a, b)?;
            let na = store.chunk_load_count(a)?;
            let nb = store.chunk_load_count(b)?;
            if na == 0 || nb == 0 { continue; }

            let p_ab = co as f64 / na as f64; // P(b|a)
            let p_ba = co as f64 / nb as f64; // P(a|b)

            if p_ab > config.merge_threshold
                && p_ba > config.merge_threshold
            {
                candidates.push((p_ab + p_ba, a.clone(), b.clone()));
            }
        }
    }

    // Sort by combined score descending.
    candidates.sort_by(|x, y| {
        y.0.partial_cmp(&x.0)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| x.1.cmp(&y.1))
    });

    // Greedy assignment: once a chunk is taken, it can't merge again.
    let mut assigned: HashSet<String> = HashSet::new();
    for (_, a, b) in &candidates {
        if assigned.contains(a.as_str())
            || assigned.contains(b.as_str()) { continue; }
        result.insert(a.clone(), Some(b.clone()));
        result.insert(b.clone(), Some(a.clone()));
        assigned.insert(a.clone());
        assigned.insert(b.clone());
    }

    Ok(result)
}

Four steps. Enumerate all pairs. Compute both conditional probabilities from the store. Sort by combined score descending. Greedy-assign, highest-scoring pairs first.

The greedy constraint matters. Once a chunk is assigned, it can’t participate in another merge. This prevents chain merges where A joins B, B joins C, and you end up with a mega-chunk containing half the application. Each chunk gets at most one merge partner.

Co-Request Clustering MERGE SUGGESTED chunk-a 850 sessions chunk-b 900 sessions co-loaded: 782 P(B|A)=0.92 P(A|B)=0.87 Both exceed 0.70 threshold suggestedMerge: "chunk-b" Both directions agree: these chunks almost always load together. NO MERGE chunk-a 850 sessions chunk-c 200 sessions co-loaded: 170 P(C|A)=0.20 P(A|C)=0.85 P(C|A) = 0.20 < 0.70 suggestedMerge: None C almost always loads with A, but A loads without C 80% of the time. Bidirectional requirement prevents merging a rare chunk into a common one. Threshold: both P(B|A) and P(A|B) must exceed 0.70

Advisory fields: no rebuild required

The clustering output writes back into the ChunkManifest through three advisory fields per chunk:

pub struct ChunkHint {
    pub co_request_score: f64,
    pub median_load_order: f64,
    pub suggested_merge: Option<String>,
}

co_request_score: the fraction of sessions that loaded this chunk within the first three load positions. High score means it’s part of nearly every initial page load. median_load_order: the median position in the load waterfall across all sessions. Low value means early. suggested_merge: the chunk this one should merge with, or None.

The compute_hints function wires the store, clustering, and per-chunk metrics together:

pub fn compute_hints(
    store: &PgoStore,
    manifest_build_id: &str,
    chunk_ids: &[String],
    cluster_config: &C3Config,
) -> anyhow::Result<PgoHints> {
    let session_count = store.session_count()?;
    let clusters = compute_clusters(
        store, chunk_ids, cluster_config)?;

    let mut chunk_hints = HashMap::new();
    for chunk_id in chunk_ids {
        let co_request_score = if session_count == 0 { 0.0 }
            else {
                store.initial_load_count(chunk_id, 3)?
                    as f64 / session_count as f64
            };
        let median_load_order =
            store.median_load_order(chunk_id)?;
        let suggested_merge =
            clusters.get(chunk_id).and_then(|v| v.clone());

        chunk_hints.insert(chunk_id.clone(), ChunkHint {
            co_request_score,
            median_load_order,
            suggested_merge,
        });
    }

    Ok(PgoHints {
        build_id: manifest_build_id.to_string(),
        chunk_hints,
    })
}

For each chunk: what fraction of sessions load it in the first three positions? What’s its median waterfall position? Should it merge? Three numbers per chunk. That’s the entire PGO output.

These fields are excluded from the content signature. A chunk’s content hash is computed from its module contents alone. Updating PGO hints changes no hash, invalidates no CDN edge, requires no rebuild. The code is identical. The delivery grouping shifts because the data said it should.

Chunk {
  id, modules: ContentHash[], hash: ContentHash
  loadCondition: INITIAL | LAZY | PREFETCH
  coRequestScore?:  number    // PGO-populated
  suggestedMerge?:  ChunkId   // PGO recommendation
}

The ? fields are absent on first deploy. They appear after the PGO store accumulates enough data. The manifest grows advisory metadata over time without any structural change to the build output.

The deployment timeline

Deploy 1 ships with static chunk boundaries from the build. No PGO data exists. The manifest has no coRequestScore, no suggestedMerge.

Sessions accumulate. After 100 sessions (the min_sessions gate), C³ starts producing suggestions. At 1,000 sessions the co-request matrix stabilizes. The 0.70 threshold is conservative by design: both directions must exceed 70%, which means 30% of sessions can deviate without triggering a merge. False positives (merging chunks that shouldn’t be merged) waste bandwidth by sending unnecessary bytes. False negatives (not merging chunks that should be) preserve the status quo. The threshold favors the status quo.

The ABS applies hints on the next manifest response. When suggestedMerge says chunk A belongs with chunk B, the service can group both in a single HTTP response. Fewer requests, better compression, closer match to actual usage. The chunks themselves are unchanged. Their content hashes are identical. Only the packaging shifted.

New sessions update the matrix. C³ re-runs. Hints update. The system converges toward the chunk layout that matches how users actually navigate, continuously, without a developer touching the build config.