A vendor tag appears in the waterfall as a staircase: a small loader, then a core bundle that starts only after it, then two modules that start only after that — four levels of latency for four files that could have been fetched together.

Triage: Measure the Depth, Not the Size

  1. Open Network, disable the cache, throttle to Slow 4G, and reload. Sort by start time.
  2. Identify the vendor’s requests and read them as a staircase: each step down that begins after the previous one ends is a level.
  3. Check the Initiator column. A chain shows each request initiated by the previous script rather than by the document.
  4. Record the depth and the total elapsed time from the first request to the last byte.
Depth costs latency, not bandwidth A four-level vendor chain on a throttled connection. Each level costs a full round trip before the next request can even be discovered, so four small files take roughly 1.2 seconds despite totalling only ninety kilobytes. The same four files declared up front and fetched in parallel complete in roughly 400 milliseconds, because only one round trip is on the critical path. loader.js level 1 core.js level 2 — starts after 1 module-a.js level 3 module-b.js level 4 Ninety kilobytes total, 1.2 seconds elapsed. The bytes are not the problem. Declared up front, the same four files complete in roughly one round trip.

Root Cause: Each Level Must Be Parsed Before the Next Is Known

A chain exists because each script’s dependencies are expressed inside it. The browser cannot know that core.js exists until loader.js has been downloaded, parsed and executed, so every level costs a full request-response cycle before the next can begin — and on a mobile connection that cycle is dominated by latency, typically 100 to 200 ms regardless of how small the file is.

That is why depth matters more than size. Four 20 KB files in a chain cost four round trips; one 80 KB file costs one. The chain is slower despite transferring identical bytes, and no amount of bandwidth improves it, because the connection spends most of its time idle waiting for the next request to be discovered.

Vendors build chains for good reasons on their side: a small loader lets them change the rest without customers updating a tag, and lazy modules avoid shipping code most customers do not use. Neither reason benefits you, and where the vendor also publishes direct URLs for the pieces, declaring them yourself converts their flexibility into your latency saving.

What the chain buys the vendor, and costs you The chain serves the vendor: a small loader lets them change downstream files without customers editing a tag, and lazy modules avoid shipping unused code. It costs you: each level is a round trip on the critical path, the URLs are opaque so nothing can be pinned or preconnected, and the dependency set is invisible to your inventory. the vendor gains change downstream freely ship modules lazily no customer coordination you pay a round trip per level nothing pinnable or preconnectable an invisible dependency set A reasonable trade for them. Declaring the chain yourself takes the latency half back.

Resolution: Declare the Levels, Restore Order With defer

<!-- Warm the origin once. All four requests reuse this connection. -->
<link rel="preconnect" href="https://cdn.vendor.example" crossorigin>

<!-- Declare every level. All four are discovered by the preload scanner and
     fetched in parallel; defer restores execution in document order, which is
     what the chain was implicitly providing. -->
<script src="https://cdn.vendor.example/3.4.1/loader.js"   defer crossorigin="anonymous"></script>
<script src="https://cdn.vendor.example/3.4.1/core.js"     defer crossorigin="anonymous"></script>
<script src="https://cdn.vendor.example/3.4.1/module-a.js" defer crossorigin="anonymous"></script>
<script src="https://cdn.vendor.example/3.4.1/module-b.js" defer crossorigin="anonymous"></script>

The defer attribute is doing essential work here and swapping it for async breaks the refactor silently. In the chain, fetch order enforced execution order as a side effect; once all four are declared, fetch order becomes arbitrary and defer is the only thing supplying the guarantee that core.js runs after loader.js. This is the async versus defer decision appearing as a correctness requirement rather than a preference.

Two preconditions make this safe. The URLs must be stable — a version segment you control, not a path the loader computes — and the vendor must tolerate its modules being present before it asks for them, which most do because they check for existing globals. Confirm both before shipping, and pin the versions so a vendor release cannot silently change the set.

Verification: One Wave, Same Behaviour

Re-record the throttled trace. The four requests should now start together rather than in sequence, and the elapsed time from first request to last byte should fall to roughly one round trip plus the largest transfer. Then confirm the vendor still works — the behavioural check matters as much as the timing one.

Two things to verify, not one Two checks after flattening. The timing check confirms the four requests now start together and the elapsed time has fallen to roughly one round trip. The behavioural check confirms the vendor still initialises correctly and produces its normal output, because a flattening that breaks execution order is faster and useless. timing four requests start together one round trip, not four behaviour vendor still initialises normal output produced timing only faster and broken the usual failure Check the vendor still works before celebrating the waterfall. Order is easy to break here.

When Not to Flatten

The refactor is not free, and two situations make it a poor trade. If the vendor computes URLs at runtime — appending a customer identifier, selecting a build by feature detection, or resolving a region — then the URLs you hard-code will eventually be wrong, and the failure will be an outage rather than a slowdown.

The other is a chain whose later levels are genuinely conditional. A loader that fetches a module only for visitors who trigger a particular feature is doing something useful: most visitors never pay for that module at all, and declaring it up front makes everyone pay for it to save latency for a minority. Read the chain before flattening it — if a level is fetched on every load, it belongs in the markup; if it is fetched on some, it does not.

Where the URLs are unstable but the origin is not, there is a middle option worth taking: keep the chain and add preconnect. It costs one line, removes the handshake from the first level, and leaves the vendor free to change everything below it. That is often most of the available saving on a chain whose depth is two rather than four.

Common Pitfalls

  • Using async after flattening. Fetch order no longer implies execution order, so an ordering the chain guaranteed is silently lost.

  • Hard-coding a computed URL. A path the loader builds at runtime will change, and your declaration will break rather than degrade.

  • Flattening a conditional level. Declaring a module only some visitors needed makes everyone download it.

  • Omitting version pins. A flattened chain of rolling URLs breaks the first time the vendor changes the set of files.

  • Flattening without adding preconnect. The four parallel requests still need a warm connection, and on a cold origin the handshake now blocks all four rather than only the first.

  • Leaving the original loader tag in place. A flattened set alongside the vendor’s own tag loads the chain twice, which is slower than either arrangement alone and produces duplicate initialisation.


Frequently Asked Questions

How do I find the URLs the loader fetches?

From the waterfall rather than the documentation. Load the page with the cache disabled, let the chain complete, and copy the URLs directly from the Network panel — that is the authoritative list, and it frequently differs from what the vendor’s integration guide describes.

Repeat it on a second page type and in a second region before hard-coding anything. A set that differs between the two is a set that is computed rather than fixed, which is the signal that flattening is the wrong technique for this vendor.

Will the vendor support this configuration?

Formally, often not — their supported integration is the tag they document, and declaring their internal modules directly is outside it. In practice it usually works, because the modules are written to check for existing globals rather than to assume a load order, but you are taking on the risk of a change they have no obligation to announce.

Mitigate rather than assume. Pin the versions so a release cannot change the set silently, add the scheduled digest check so a change is noticed the same day, and keep a note of the original tag so restoring it is a revert rather than a reconstruction.

Does HTTP/2 multiplexing make this unnecessary?

No, and this is the most common misconception about chains. Multiplexing removes the connection cost of additional requests, so four parallel requests on one connection are nearly as cheap as one. It does nothing about requests that cannot be issued in parallel, because the browser does not yet know they exist.

The chain’s cost is discovery latency rather than connection overhead, and discovery is serial by construction. That is precisely why declaring the levels up front helps so much on HTTP/2: the four requests you declared do multiplex beautifully, whereas the four the loader discovers one at a time cannot.

What if only part of the chain has stable URLs?

Flatten the stable part and leave the rest. A chain whose first two levels are versioned and whose third is computed can still be shortened from four round trips to two, which is most of the available saving — the deepest levels are usually the smallest files, so the latency they cost is out of proportion to their bytes.

Declare the stable levels in document order and let the loader discover the remainder as it does now. The result is a shorter staircase rather than a flat graph, and it degrades safely: if the vendor changes the computed portion, only the part you did not declare is affected.

Should the flattened tags go in the head or at the end of the body?

In the head, and this is one of the few cases where that is unambiguous. The whole point of declaring the levels up front is that the preload scanner discovers them as early as possible, and the scanner reads the head first — placing them at the end of the body delays discovery by however long the body takes to arrive, which gives back much of the saving.

Because every tag carries defer, head placement costs nothing in parsing: none of them block, and all of them execute after parsing in document order. That combination — early discovery, late execution — is exactly what the refactor is trying to achieve, and it is only available from the head.


Up: Optimizing the Network Waterfall for External Assets