Quick answer: Godot’s web export fetches each asset file over HTTP as needed, so players see textures pop in one by one during gameplay. Enable Embed PCK in the export preset to ship all assets in the wasm bundle, preload critical textures with ResourceLoader.load_threaded_request before leaving the loading screen, and tune import settings (lossy compression, disabled mipmaps for UI) to shrink total payload.

Your game runs beautifully on desktop. You export to HTML5, the menu loads, the player clicks Play, and the world appears as a surreal collage of magenta missing textures that pop into place one by one over the next 10 seconds. Welcome to the number-one complaint about Godot web builds: progressive texture loading that never should have been progressive.

Why the Web Export Loads Progressively

On desktop, the .pck file is local, and ResourceLoader.load() is effectively instant. On the web, the exporter ships a directory structure where each .import/remap entry points to an asset file (a .ctex, .res, etc.). When a scene instantiates a Sprite2D with a texture reference, the engine kicks off an HTTP fetch for that single file.

Browsers prioritize whatever they think you need now, and they parallelize requests up to a limit (usually 6 per origin). Hundreds of asset fetches queue up, complete in arbitrary order, and the scene renders with placeholder textures until each fetch lands. This is fine for a gallery app. It is terrible for a game.

Step 1: Embed the PCK in the Wasm Bundle

In the export preset, toggle Variant → Embed PCK on. This packs the entire PCK into the main bundle that loads at boot. Instead of hundreds of small HTTP requests during gameplay, the browser downloads one large file during the loading page. Once that is resolved, every ResourceLoader.load pulls from memory.

The trade-off is a longer initial load. A 40 MB bundle takes ~10 seconds on a typical connection, but that time is spent on the loading screen where the player expects to wait. In exchange, gameplay is pop-free.

# Sanity-check at runtime that PCK is embedded
func _ready():
    var embedded = OS.get_cmdline_user_args().find("--main-pack") == -1
    print("PCK embedded: %s" % embedded)

Step 2: Preload Critical Assets with a Loading Screen

Even with an embedded PCK, the resource system does JIT decoding — textures are decompressed from their .ctex format on first use, which can stall the frame. Preload everything the first playable scene needs during the loading screen:

extends Control

const CRITICAL = [
    "res://scenes/level_01.tscn",
    "res://textures/player_atlas.tres",
    "res://textures/enemy_atlas.tres",
    "res://audio/music/level_01.ogg",
]

var _loaded: Array = []

func _ready():
    for path in CRITICAL:
        ResourceLoader.load_threaded_request(path)

func _process(_delta):
    var progress: Array = []
    var all_done = true
    for path in CRITICAL:
        var status = ResourceLoader.load_threaded_get_status(path, progress)
        if status != ResourceLoader.THREAD_LOAD_LOADED:
            all_done = false
    $ProgressBar.value = _average_progress()

    if all_done:
        get_tree().change_scene_to_packed(
            ResourceLoader.load_threaded_get("res://scenes/level_01.tscn"))

This shows a progress bar, loads in parallel, and only transitions when every asset is fully decoded into memory. No pop-in during gameplay.

Step 3: Tune Import Settings per Category

Total bundle size matters a lot on the web. Fine-tune each asset category in the Import dock:

Step 4: Split by Scene with Lazy-Load Prefetch

If your full game is 300 MB, embedding everything is not realistic — the initial download becomes prohibitive. Keep the first scene’s assets embedded and lazy-fetch subsequent scenes, but prefetch during gameplay so the next scene is already in memory when the player reaches the transition:

func _on_player_reached_checkpoint():
    # Player is about 30s from level_02; start fetching now
    ResourceLoader.load_threaded_request("res://scenes/level_02.tscn")

By the time the level transition fires, the scene is ready and the load is instant.

Step 5: Respect the Browser’s Connection Limit

Chromium and Firefox cap concurrent connections at 6 per origin. If you lazy-load 200 assets simultaneously, 194 of them wait. Chunk your prefetch into batches of 4 or 5:

func prefetch_batched(paths: Array, batch_size := 4):
    var i = 0
    while i < paths.size():
        var batch = paths.slice(i, i + batch_size)
        for p in batch:
            ResourceLoader.load_threaded_request(p)
        for p in batch:
            while ResourceLoader.load_threaded_get_status(p) == ResourceLoader.THREAD_LOAD_IN_PROGRESS:
                await get_tree().process_frame
        i += batch_size

“Web gamers have no tolerance for pop-in. They expect instant — so hide your loading under a honest progress bar, then deliver a seamless experience.”

Verifying the Fix

Open Chrome DevTools, go to the Network tab, throttle to Fast 3G, and load your export. You should see one big bundle finishing, then gameplay starts with zero further asset requests for the first scene. If you still see per-asset fetches during gameplay, Embed PCK did not take — recheck the export preset.

Related Issues

If audio stutters on first play, see Web Audio First-Play Silence. For excessive wasm size, read Web Export Bundle Bloat.

Embed PCK + ResourceLoader.load_threaded_request + lossy import tuning = no texture pop-in on web.