feat(web): implement image decoding throttling for HTML images (#186032)

Introduces a centralized ImageDecodingManager to coordinate the
concurrency and memory footprint of `HTMLImageElement.decode()` calls.
This prevents resource exhaustion and silent crashes on browsers like
iOS Safari when handling high volumes of large image assets.
    
- Added ImageDecodingManager to enforce safety limits (20 decodes /
128MB).
- Refactored HtmlImageElementCodec to use a multi-phase throttled
decode.
- Implemented aggressive resource reclamation by clearing img.src on
disposal.
- Added unit tests for the manager and updated codec integration tests.

Largely alleviates https://github.com/flutter/flutter/issues/152709

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

If this change needs to override an active code freeze, provide a
comment explaining why. The code freeze workflow can be overridden by
code reviewers. See pinned issues for any active code freezes with
guidance.

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[AI contribution guidelines]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING.md b/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING.md
new file mode 100644
index 0000000..e938141
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING.md
@@ -0,0 +1,105 @@
+# Image Decoding in Flutter Web
+
+## Overview
+
+The image decoding system in the Flutter Web engine is designed to provide high-performance, memory-efficient image loading and rendering across a wide variety of browsers. Its primary purpose is to bridge the gap between Flutter's `dart:ui` API and the various image decoding capabilities provided by modern web browsers.
+
+### Implementation Sketch
+
+The system is built on a pluggable architecture that adapts to the active rendering backend (**CanvasKit** or **Skwasm**) and the capabilities of the host browser.
+
+1.  **Backend Abstraction**: The `Renderer` class serves as the entry point. It delegates image codec instantiation to backend-specific implementations, ensuring that the resulting `ui.Image` objects (e.g., `CkImage` or `SkwasmImage`) are compatible with the current rendering pipeline.
+2.  **Multi-Path Decoding**:
+    *   **WebCodecs (`ImageDecoder` API)**: The primary, high-performance path. It uses hardware-accelerated decoding to produce `VideoFrame` objects, supporting both static and animated images.
+    *   **HTML `<img>` Element**: A robust fallback for static images. It utilizes the browser's native `HTMLImageElement.decode()` API to decode images asynchronously.
+    *   **WASM-based Decoders**: Fallback decoders implemented in WebAssembly (e.g., Skia's built-in codecs) are used for animated images when the `ImageDecoder` API is not available.
+3.  **Source Preservation**: `ui.Image` implementations often maintain a reference to their original browser-native source (like an `ImageBitmap` or `HTMLImageElement`). This allows for efficient pixel read-back and avoids slow GPU-to-CPU memory transfers.
+4.  **Transformation and Optimization**:
+    *   **Resizing Codecs**: Images can be resized immediately after decoding to minimize memory usage.
+    *   **Iterative Downscaling**: To maintain high visual quality, the engine performs multi-step downscaling for large scale factors, bypassing limitations in browser-side mipmap generation.
+
+## Entrypoints
+
+The Flutter framework interacts with the web engine through a set of APIs defined in `dart:ui`. These APIs are the starting point for any image loading operation.
+
+### Codec Instantiation
+
+The most common way images are loaded is by creating a `ui.Codec`, which manages the decoding and frame-by-frame access of an image.
+
+*   **`instantiateImageCodec(Uint8List list, ...)`**: The primary entrypoint for decoding encoded image bytes (JPEG, PNG, GIF, etc.).
+*   **`instantiateImageCodecFromBuffer(ImmutableBuffer buffer, ...)`**: Similar to the above, but uses an `ImmutableBuffer` for memory efficiency.
+*   **`instantiateImageCodecWithSize(ImmutableBuffer buffer, ...)`**: Allows the framework to request a specific target size during the decoding process.
+*   **`ImageDescriptor.instantiateCodec(...)`**: Decodes an image based on a descriptor that provides metadata like width, height, and pixel format.
+
+On the framework side, these are typically invoked by an `ImageProvider` (like `NetworkImage` or `AssetImage`) during the image resolution process.
+
+### Direct Decoding
+
+For simpler use cases or raw pixel data, the following APIs are used:
+
+*   **`decodeImageFromList(Uint8List list, ...)`**: A convenience wrapper that decodes an image and returns a single `ui.Image` via a callback.
+*   **`decodeImageFromPixels(Uint8List pixels, ...)`**: Creates a `ui.Image` directly from a buffer of raw pixel data.
+
+### Rendering Entrypoints
+
+Once an image is decoded into a `ui.Image` object, it is displayed using the `Canvas` API:
+
+*   **`Canvas.drawImage(ui.Image image, Offset p, Paint paint)`**: Draws the entire image at a specific point.
+*   **`Canvas.drawImageRect(ui.Image image, Rect src, Rect dst, Paint paint)`**: Draws a sub-region of the image into a target rectangle on the canvas. This is where the engine's **Iterative Downscaling** logic is often triggered if the destination rectangle is significantly smaller than the source.
+
+## Memory Management
+
+Memory management for images in Flutter Web is a multi-layered process involving the Dart VM, the browser's JavaScript/DOM environment, and the WebAssembly (WASM) heap used by the renderers.
+
+### Reference Counting and Disposal
+
+Because the rendering backends (CanvasKit and Skwasm) store image data in a private WASM heap, the Dart garbage collector cannot automatically reclaim that memory.
+
+*   **`CountedRef`**: The engine uses a reference-counting mechanism (`CkCountedRef` in CanvasKit) to track how many Dart-side proxies are pointing to a single WASM-side image object.
+*   **Explicit Disposal**: It is critical that the Flutter framework calls `image.dispose()` when an image is no longer needed. This decrements the reference count and, when it reaches zero, triggers the actual deletion of the object from the WASM heap.
+
+### Preservation of Original Image Source
+
+In addition to the WASM-side representation, the engine typically retains a reference to the **original browser-native source** (e.g., an `HTMLImageElement`, `ImageBitmap`, or `VideoFrame`).
+
+*   **Workaround for CanvasKit Bug**: This is primarily done to work around a bug in CanvasKit where calling `readPixels` on a texture-backed `SkImage` can fail and return entirely black pixels. By keeping the DOM source, the engine can reliably extract pixel data for `toByteData()`.
+*   **Ref-Counting of the Source**: The `ImageSource` object itself is ref-counted separately from the WASM handle. When a `ui.Image` is cloned, the new instance increments the `refCount` on the same `ImageSource`. This ensures that the browser-native resource (like an `ImageBitmap`) is only closed/released when all clones that depend on it have been disposed.
+
+### Lazy Texture Uploads
+
+The CanvasKit backend makes extensive use of Skia's **Lazy Images** (e.g., `MakeLazyImageFromImageBitmap`).
+
+*   **On-Demand Upload**: Instead of immediately copying the image pixels into a GPU texture, the engine creates a "lazy" wrapper. The actual texture upload to the WebGL/WebGPU context happens at the last possible moment—right before the image is drawn to a surface.
+*   **Multi-Surface Support**: This lazy behavior is what enables the **`MultiSurfaceRasterizer`** to work. Since the texture isn't tied to a specific context until draw time, it can be uploaded to different canvases or handled correctly if a WebGL context is lost and needs to be recovered.
+*   **Skwasm Note**: While Skwasm also uses texture sources, its current implementation is more tightly coupled to the active surface, and it does not yet support the `MultiSurfaceRasterizer`.
+
+### Resource Copies and Footprint
+
+At any given time, an active image might have several representations in memory:
+1.  **Encoded Bytes**: Present briefly during the initial fetch/load phase.
+2.  **Browser-Native Source**: The decoded `ImageBitmap` or `HTMLImageElement` managed by the browser.
+3.  **WASM Wrapper**: A small handle in the WASM heap representing the Skia/Skwasm image object.
+4.  **GPU Texture(s)**: One or more actual textures in GPU memory, potentially duplicated if the image is being drawn across multiple independent WebGL contexts (in `MultiSurfaceRasterizer` mode).
+
+## Relevant Files
+
+The following files constitute the core of the image decoding and rendering system in the Flutter Web engine.
+
+### Core Abstractions and Shared Logic
+
+*   **`lib/painting.dart`**: Defines the `ui.Image`, `ui.Codec`, and `ui.ImmutableBuffer` interfaces as part of the `dart:ui` library. It also contains utility methods for image decoding like `decodeImageFromList`.
+*   **`lib/src/engine/renderer.dart`**: Contains the `Renderer` base class, which defines the interface for creating image codecs and images across different backends.
+*   **`lib/src/engine/image_decoder.dart`**: Implements `BrowserImageDecoder`, the base class for decoders using the browser's `ImageDecoder` (WebCodecs) API. It also contains `ResizingCodec` and the general `scaleImageIfNeeded` logic.
+*   **`lib/src/engine/html_image_element_codec.dart`**: Provides the base `HtmlImageElementCodec` which uses an off-screen HTML `<img>` tag to decode static images asynchronously.
+
+### CanvasKit Backend (Skia-WASM)
+
+*   **`lib/src/engine/canvaskit/renderer.dart`**: Implements `CanvasKitRenderer`, delegating image operations to `skiaInstantiateImageCodec` and backend-specific image creation methods.
+*   **`lib/src/engine/canvaskit/image.dart`**: The "brain" of CanvasKit image logic. It manages the selection between WebCodecs, `<img>` tags, and Skia's own decoders. It also defines `CkImage`, which wraps a Skia `SkImage` while optionally retaining a reference to the original DOM source.
+*   **`lib/src/engine/canvaskit/canvas.dart`**: Implements the drawing commands for CanvasKit. It includes the `shouldIterativelyDownscale` check and calls into `getOrCreateDownscaledImage` to ensure high-quality rendering of large images.
+
+### Skwasm Backend (FFI-WASM)
+
+*   **`lib/src/engine/skwasm/skwasm_impl/renderer.dart`**: Implements `SkwasmRenderer`, managing the lifecycle of `SkwasmImage` objects and choosing between `SkwasmBrowserImageDecoder` and other fallback strategies.
+*   **`lib/src/engine/skwasm/skwasm_impl/codecs.dart`**: Contains Skwasm-specific implementations of the decoding paths, including `SkwasmBrowserImageDecoder` and a WASM-based `SkwasmAnimatedImageDecoder`.
+*   **`lib/src/engine/skwasm/skwasm_impl/canvas.dart`**: Implements drawing logic for the Skwasm backend, mirroring the iterative downscaling optimizations found in CanvasKit.
diff --git a/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING_THROTTLING.md b/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING_THROTTLING.md
new file mode 100644
index 0000000..f9d903a
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/docs/IMAGE_DECODING_THROTTLING.md
@@ -0,0 +1,110 @@
+# Image Decoding Throttling in Flutter Web
+
+## Section Zero: Business Problem Description
+
+The primary goal of this feature is to eliminate silent application crashes and rendering failures in Flutter Web applications that handle high volumes of image assets, specifically on browsers that rely on the `HTMLImageElement.decode()` API for image processing.
+
+Modern browsers that support the high-performance `ImageDecoder` (WebCodecs) API, such as Chrome, are generally robust when handling concurrent image decodes. However, for browsers where this API is unavailable—most notably **iOS Safari**—or in scenarios where the engine must fall back to using the standard HTML `<img>` element for decoding, the system is highly susceptible to resource exhaustion.
+
+When a Flutter Web app attempts to decode many large images simultaneously using this fallback path, it can overwhelm the browser's internal image subsystem. This manifests in two critical ways:
+1.  **Silent Crashes (iOS Safari):** The most severe failure mode, where the entire web page crashes or reloads without any logged errors, providing a poor user experience.
+2.  **Encoding Errors:** On other browsers, forcing many simultaneous decodes through the `HTMLImageElement` path can trigger "EncodingErrors," causing assets to fail to render entirely.
+
+Currently, the Flutter Web engine issues these decoding requests as fast as the framework demands them. By introducing a "traffic controller" specifically for the `HTMLImageElement` decoding path, we aim to:
+
+*   **Ensure Application Stability:** Prevent fatal browser crashes on mobile devices by smoothing out the resource demand and staying within the browser's concurrent processing limits.
+*   **Improve Rendering Reliability:** Ensure that every image intended for display is successfully processed, rather than failing due to browser-level synchronization or memory limits.
+*   **Optimize Memory Lifecycle:** Implement aggressive signaling to the browser to release heavy bitmap memory as soon as it is no longer needed, reducing the cumulative memory pressure that leads to these crashes.
+
+## Section One: Technical Implementation Plan
+
+The technical implementation introduces a centralized resource coordinator to manage the concurrency and memory impact of the `HTMLImageElement.decode()` execution path. By moving from an "eager" decoding model to a "throttled" model, we can prevent the browser's background decoding threads from exceeding system resource limits.
+
+### Core Components
+
+1.  **The `ImageDecodingManager` (Resource Coordinator):**
+    A centralized singleton responsible for tracking active decoding operations. It manages a FIFO (First-In-First-Out) queue and enforces two primary safety constraints:
+    *   **Concurrency Limit:** A maximum of 8 simultaneous `decode()` operations.
+    *   **Memory Footprint Limit:** A maximum cumulative estimated footprint (128MB) for all in-flight decodes.
+    *   **The "Greedy First" Rule:** To prevent deadlocks when an image exceeds the total budget (e.g., a single 200MB asset), the manager always allows the first item in the queue to proceed if no other decodes are active.
+    *   **`cancel(Request request)`:** The manager provides an explicit `cancel` method. If an image is disposed of while waiting in the queue, this method is used to remove the request and reclaim the potential slot immediately.
+    *   **Defensive Timeout:** To prevent a "hung" browser decode from permanently leaking a resource slot, a defensive timeout (e.g., 30 seconds) will be implemented. If `img.decode()` does not resolve within this window, the slot will be forcibly released to prevent a system-wide deadlock.
+
+2.  **Refactored Codec Lifecycle:**
+    The `HtmlImageElementCodec` will be updated to split the image preparation into two distinct asynchronous phases:
+    *   **Phase 1 (Sizing):** The image `src` is set, and we wait for the browser's `onload` or `onerror` event. If `onerror` fires, the process terminates with an error before requesting a slot from the manager. If `onload` fires, we obtain the `naturalWidth` and `naturalHeight` required to estimate the memory footprint (`width * height * 4`).
+    *   **Phase 2 (Throttled Decode):** The codec requests a slot from the `ImageDecodingManager`. Once granted, it executes the high-latency `img.decode()` call. A `finally` block ensures that the manager is notified to release the resource slot regardless of the outcome.
+    *   **Disposal during Queueing:** If `dispose()` is called while the codec is waiting in Phase 2, the codec must call `ImageDecodingManager.instance.cancel(request)` to remove itself from the queue and abort the process. This prevents wasting budget and avoid late-failure errors.
+
+3.  **Aggressive Resource Reclamation:**
+    To mitigate "sticky" memory in iOS Safari, we will update the `ImageSource` disposal logic. Instead of relying solely on garbage collection, we will explicitly clear the `src` attribute and revoke object URLs immediately upon disposal. This signals the browser to purge the associated bitmap from its internal cache.
+
+### System Interaction Flow
+
+When the Flutter framework requests an image via `instantiateImageCodec`, the system follows this coordinated path:
+
+1.  **Preparation:** The codec initializes the `HTMLImageElement` and waits for the metadata to load (`onload`).
+2.  **Accounting:** The codec calculates the estimated RGBA footprint and enters the `ImageDecodingManager` queue.
+3.  **Throttling:** The manager pauses the execution of the codec's `decode()` call until the active concurrency and memory usage fall within safe thresholds.
+4.  **Execution:** The browser performs the background CPU/GPU work to decompress the image data.
+5.  **Resolution:** The manager releases the reserved capacity, and the framework receives a `ui.Image` ready for rendering.
+6.  **Disposal:** When the framework disposes of the image, the engine explicitly unlinks the resource to reclaim memory.
+
+### Ecosystem Integration
+
+This change is internal to the Flutter Web engine's implementation of `dart:ui`. It specifically hardens the `HTMLImageElement` fallback path used by browsers like Safari without affecting the high-performance `ImageDecoder` (WebCodecs) path used by Chrome.
+
+## Section Two: Alternatives Considered
+
+### 1. Eliminating the `decode()` Call Entirely
+We considered removing the call to `HTMLImageElement.decode()` and simply waiting for the `onload` event.
+*   **Why it was ruled out:** Removing `decode()` forces the browser to perform image decompression synchronously on the main thread during the next frame paint. This would introduce significant "jank" (dropped frames). Furthermore, it would remove our mechanism for controlling concurrency, potentially leading to the same crashes when multiple images are drawn for the first time in a single frame.
+
+### 2. Throttling Based on Encoded File Size
+We initially discussed using the size of the encoded image bytes as the primary metric.
+*   **Why it was ruled out:** Encoded size is an unreliable proxy for actual memory pressure. A highly compressed 1MB JPEG could expand into a massive 40MB bitmap. Additionally, we cannot easily determine the file size of a URL-based image without an extra network request. Using a dimension-based estimate (`width * height * 4`) provides a more accurate and consistent measure.
+
+### 3. Automatic Dimension Sniffing via Header Parsing
+We explored the idea of "sniffing" image file headers to determine dimensions before starting the loading process.
+*   **Why it was ruled out:** Image formats have complex bytecode standards. Writing a robust, cross-format header parser adds significant complexity. Waiting for the browser’s native `onload` event is a much more reliable way to obtain accurate dimensions.
+
+### 4. Implementing a "Retry-on-Error" Strategy
+Since Chrome returns a catchable `EncodingError` when it is overwhelmed, we considered simply catching that error and retrying.
+*   **Why it was ruled out:** This approach does not work for **iOS Safari**, which simply crashes the entire process without throwing a catchable error. A proactive throttling strategy is required.
+
+## Section Three: Detailed Implementation Plan
+
+### 1. Core Logic & Coordination
+
+**File:** `lib/src/engine/image_decoding_manager.dart` (New File)
+*   **Rationale:** Central coordinator for image decoding resources.
+*   **Implementation:** Singleton `ImageDecodingManager` tracking `activeDecodesCount` and `activeDecodesBytes` with a FIFO queue and "Greedy First" logic.
+
+**File:** `lib/src/engine.dart`
+*   **Rationale:** Export the new manager.
+
+### 2. Refactoring the HTML Decoding Path
+
+**File:** `lib/src/engine/html_image_element_codec.dart`
+*   **Rationale:** Update base class for `<img>` based decoding to support the throttled two-phase process.
+*   **Implementation:** Refactor `decode()` to wait for `onload`, then wait for a manager slot, then call `img.decode()`. Update `dispose()` to clear `src`.
+
+### 3. Backend-Specific Codec Updates
+
+**File:** `lib/src/engine/canvaskit/image.dart`
+*   **Rationale:** Update CanvasKit image sources for aggressive reclamation.
+*   **Implementation:** Update `_doClose()` in `ImageElementImageSource` and `ImageBitmapImageSource` to explicitly release browser resources.
+
+**File:** `lib/src/engine/skwasm/skwasm_impl/codecs.dart`
+*   **Rationale:** Ensure Skwasm-specific codecs benefit from the new logic.
+
+### 4. Verification & Testing
+
+**File:** `test/engine/image_decoding_manager_test.dart` (New File)
+*   **Rationale:** Unit test the manager's throttling and queueing logic.
+
+**File:** `test/ui/image/html_image_element_codec_test.dart` (Existing File)
+*   **Rationale:** Verify that the codec respects concurrency limits and correctly clears resources on disposal.
+
+**File:** `test/canvaskit/image_test.dart` (Existing File)
+*   **Rationale:** Regression testing for the CanvasKit pipeline.
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
index 7b5f729..ca2b3f8 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart
@@ -62,6 +62,7 @@
 export 'engine/frame_timing_recorder.dart';
 export 'engine/html_image_element_codec.dart';
 export 'engine/image_decoder.dart';
+export 'engine/image_decoding_manager.dart';
 export 'engine/image_downscaler.dart';
 export 'engine/image_format_detector.dart';
 export 'engine/initialization.dart';
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart
index cf26270..db0a71d 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/image.dart
@@ -692,8 +692,7 @@
 
   @override
   void _doClose() {
-    // There's no way to immediately close the <img> element. Just let the
-    // browser garbage collect it.
+    imageElement.src = '';
   }
 
   @override
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/html_image_element_codec.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/html_image_element_codec.dart
index 8c63b04..57d7e5f 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/html_image_element_codec.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/html_image_element_codec.dart
@@ -8,6 +8,40 @@
 import 'package:ui/ui.dart' as ui;
 import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
 
+/// The lifecycle status of an [HtmlImageElementCodec].
+enum _HtmlCodecStatus {
+  /// The codec has been created but has not yet started loading.
+  initial,
+
+  /// The codec is waiting for the browser to load the image metadata (width/height).
+  loadingMetadata,
+
+  /// The codec has loaded metadata and is now waiting for an available slot in
+  /// the [ImageDecodingManager] to begin the heavy decoding process.
+  waitingForSlot,
+
+  /// The codec has been granted a slot and is currently executing the
+  /// [HTMLImageElement.decode] operation.
+  decoding,
+
+  /// The image has been successfully loaded and decoded.
+  success,
+
+  /// An error occurred during the loading or decoding process.
+  failed,
+
+  /// The codec has been disposed and should no longer be used.
+  disposed,
+}
+
+/// Exception thrown when a codec is disposed while it is still decoding.
+class _HtmlCodecDisposedException implements Exception {
+  const _HtmlCodecDisposedException();
+
+  @override
+  String toString() => 'HtmlCodec was disposed.';
+}
+
 abstract class HtmlImageElementCodec implements ui.Codec {
   HtmlImageElementCodec(this.src, {this.chunkCallback, this.debugSource});
 
@@ -28,44 +62,151 @@
   /// been loaded and decoded.
   Future<void>? decodeFuture;
 
-  Future<void> decode() async {
-    if (decodeFuture != null) {
-      return decodeFuture;
+  ImageDecodingRequest? _decodingRequest;
+  _HtmlCodecStatus _status = _HtmlCodecStatus.initial;
+  Completer<void>? _loadCompleter;
+
+  /// Whether a [ui.Image] has been created and returned by [getNextFrame].
+  ///
+  /// This is used during [dispose] to determine if it is safe to clear the
+  /// `src` attribute of [imgElement]. If the image has been handed out, the
+  /// [ui.Image] might still be using the element for rendering, and clearing
+  /// the `src` could disrupt the browser's internal image state.
+  bool _imageHandedOut = false;
+
+  Future<void> decode() {
+    decodeFuture ??= _performDecode();
+    return decodeFuture!;
+  }
+
+  void _checkDisposed() {
+    if (_status == _HtmlCodecStatus.disposed) {
+      throw const _HtmlCodecDisposedException();
     }
-    final completer = Completer<void>();
-    decodeFuture = completer.future;
+  }
+
+  Future<void> _performDecode() async {
+    try {
+      _checkDisposed();
+      await _waitForMetadata();
+      await _executeThrottledDecode();
+
+      _status = _HtmlCodecStatus.success;
+      chunkCallback?.call(100, 100);
+    } on _HtmlCodecDisposedException {
+      _status = _HtmlCodecStatus.disposed;
+    } on ImageDecodingCancelledException {
+      _status = _HtmlCodecStatus.disposed;
+    } catch (e) {
+      _status = _HtmlCodecStatus.failed;
+      if (!_imageHandedOut) {
+        imgElement?.src = '';
+      }
+      rethrow;
+    } finally {
+      _cleanupDecodingSlot();
+    }
+  }
+
+  Future<void> _waitForMetadata() async {
+    _status = _HtmlCodecStatus.loadingMetadata;
     // Currently there is no way to watch decode progress, so
     // we add 0/100 , 100/100 progress callbacks to enable loading progress
     // builders to create UI.
     chunkCallback?.call(0, 100);
-    imgElement = createDomHTMLImageElement();
-    imgElement!.crossOrigin = 'anonymous';
-    imgElement!
-      ..decoding = 'async'
-      ..src = src;
 
-    // Ignoring the returned future on purpose because we're communicating
-    // through the `completer`.
-    unawaited(
-      imgElement!
-          .decode()
-          .then((dynamic _) {
-            chunkCallback?.call(100, 100);
-            completer.complete();
-          })
-          .catchError((dynamic e) {
-            completer.completeError(e.toString());
-          }),
-    );
-    return completer.future;
+    imgElement = createDomHTMLImageElement();
+
+    // The 'anonymous' cross-origin setting is required for CanvasKit-based
+    // rendering. Without it, the browser would "taint" the image when it's
+    // drawn to a canvas, preventing us from reading the pixels back or
+    // converting it into a texture.
+    imgElement!.crossOrigin = 'anonymous';
+
+    // We set decoding to 'async' to hint to the browser that it should perform
+    // image decompression off the main thread. This helps prevent jank
+    // during the loading process.
+    imgElement!.decoding = 'async';
+
+    _loadCompleter = Completer<void>();
+
+    // We use a local listener to ensure we can properly remove it in the
+    // finally block. This prevents potential memory leaks or multiple
+    // resolutions of the completer.
+    final DomEventListener loadListener = createDomEventListener((DomEvent event) {
+      _loadCompleter?.complete();
+    });
+    final DomEventListener errorListener = createDomEventListener((DomEvent event) {
+      _loadCompleter?.completeError(ImageCodecException('Failed to load image: $src'));
+    });
+
+    imgElement!.addEventListener('load', loadListener);
+    imgElement!.addEventListener('error', errorListener);
+
+    // Setting the src attribute triggers the browser's image loading process.
+    imgElement!.src = src;
+
+    try {
+      await _loadCompleter!.future;
+    } finally {
+      // It's critical to remove the listeners to avoid leaks, as the
+      // HTMLImageElement might persist if it's cached by the browser or
+      // referenced elsewhere.
+      imgElement!.removeEventListener('load', loadListener);
+      imgElement!.removeEventListener('error', errorListener);
+      _loadCompleter = null;
+    }
+    _checkDisposed();
+  }
+
+  Future<void> _executeThrottledDecode() async {
+    _status = _HtmlCodecStatus.waitingForSlot;
+    final int width = imgElement!.naturalWidth.toInt();
+    final int height = imgElement!.naturalHeight.toInt();
+
+    _decodingRequest = ImageDecodingManager.instance.requestDecodingSlot(width, height);
+    await _decodingRequest!.future;
+    _checkDisposed();
+
+    _status = _HtmlCodecStatus.decoding;
+    // We use a timeout to prevent the decoder from hanging indefinitely and
+    // blocking the queue.
+    try {
+      await imgElement!.decode().timeout(const Duration(seconds: 30));
+    } on TimeoutException {
+      throw ImageCodecException('Timed out decoding image: $src');
+    } catch (e) {
+      throw ImageCodecException('Failed to decode image: $src. Error: $e');
+    }
+    _checkDisposed();
+  }
+
+  void _cleanupDecodingSlot() {
+    if (_decodingRequest != null) {
+      ImageDecodingManager.instance.releaseDecodingSlot(_decodingRequest!);
+      _decodingRequest = null;
+    }
   }
 
   @override
   Future<ui.FrameInfo> getNextFrame() async {
     await decode();
+    if (_status == _HtmlCodecStatus.disposed) {
+      throw StateError('Codec has been disposed');
+    }
     int naturalWidth = imgElement!.naturalWidth.toInt();
     int naturalHeight = imgElement!.naturalHeight.toInt();
+
     // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=700533.
+    //
+    // In some versions of Firefox, certain image formats (like SVG or
+    // very large JPEGs) may report a natural size of 0x0 even after the
+    // 'load' event has fired if the browser hasn't fully computed the
+    // intrinsic dimensions.
+    //
+    // Since Flutter requires a non-zero size to create a [ui.Image], we fall
+    // back to a default size (300x300) to allow the image to be processed
+    // and rendered, albeit potentially at a scaled size.
     if (naturalWidth == 0 &&
         naturalHeight == 0 &&
         ui_web.browser.browserEngine == ui_web.BrowserEngine.firefox) {
@@ -78,6 +219,7 @@
       naturalWidth,
       naturalHeight,
     );
+    _imageHandedOut = true;
     return SingleFrameInfo(image);
   }
 
@@ -89,7 +231,24 @@
   );
 
   @override
-  void dispose() {}
+  void dispose() {
+    if (_status == _HtmlCodecStatus.disposed) {
+      return;
+    }
+    final _HtmlCodecStatus oldStatus = _status;
+    _status = _HtmlCodecStatus.disposed;
+
+    if (oldStatus == _HtmlCodecStatus.loadingMetadata) {
+      _loadCompleter?.completeError(const _HtmlCodecDisposedException());
+    } else if (oldStatus == _HtmlCodecStatus.waitingForSlot) {
+      if (_decodingRequest != null) {
+        ImageDecodingManager.instance.cancel(_decodingRequest!);
+      }
+    }
+    if (!_imageHandedOut) {
+      imgElement?.src = '';
+    }
+  }
 }
 
 abstract class HtmlBlobCodec extends HtmlImageElementCodec {
@@ -100,6 +259,7 @@
 
   @override
   void dispose() {
+    super.dispose();
     domWindow.URL.revokeObjectURL(src);
   }
 }
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/image_decoding_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/image_decoding_manager.dart
new file mode 100644
index 0000000..e2deb11
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/image_decoding_manager.dart
@@ -0,0 +1,133 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:collection';
+
+import 'package:meta/meta.dart';
+
+/// Manages the concurrency and memory impact of `HTMLImageElement.decode()`.
+///
+/// This manager ensures that we don't overwhelm the browser's image subsystem
+/// by limiting the number of simultaneous decodes and the cumulative memory
+/// footprint of in-flight decodes.
+class ImageDecodingManager {
+  ImageDecodingManager._();
+
+  /// The shared instance of [ImageDecodingManager].
+  static final ImageDecodingManager instance = ImageDecodingManager._();
+
+  static const int _maxConcurrentDecodes = 8;
+  static const int _maxConcurrentBytes = 128 * 1024 * 1024; // 128MB
+
+  int _activeDecodesCount = 0;
+  int _activeDecodesBytes = 0;
+
+  final ListQueue<ImageDecodingRequest> _pendingRequests = ListQueue<ImageDecodingRequest>();
+
+  /// Requests a slot for decoding an image with the given [width] and [height].
+  ///
+  /// Returns an [ImageDecodingRequest] that will complete when a slot is
+  /// available.
+  ImageDecodingRequest requestDecodingSlot(int width, int height) {
+    final int estimatedBytes = width * height * 4;
+    final completer = Completer<void>();
+    final request = ImageDecodingRequest._(estimatedBytes, completer);
+    _pendingRequests.add(request);
+    _runNext();
+    return request;
+  }
+
+  /// Releases a decoding slot previously obtained via [requestDecodingSlot].
+  void releaseDecodingSlot(ImageDecodingRequest request) {
+    if (!request._granted) {
+      _pendingRequests.remove(request);
+      return;
+    }
+    request._granted = false;
+    _activeDecodesCount--;
+    _activeDecodesBytes -= request._estimatedBytes;
+    _runNext();
+  }
+
+  /// Cancels a pending decoding request.
+  void cancel(ImageDecodingRequest request) {
+    if (_pendingRequests.remove(request)) {
+      request._completer.completeError(const ImageDecodingCancelledException());
+    }
+  }
+
+  @visibleForTesting
+  int get debugActiveDecodesCount => _activeDecodesCount;
+
+  @visibleForTesting
+  int get debugActiveDecodesBytes => _activeDecodesBytes;
+
+  @visibleForTesting
+  void debugReset() {
+    _activeDecodesCount = 0;
+    _activeDecodesBytes = 0;
+    _pendingRequests.clear();
+  }
+
+  void _runNext() {
+    // We attempt to process as many pending requests as possible given our
+    // current resource availability.
+    while (_pendingRequests.isNotEmpty) {
+      final ImageDecodingRequest request = _pendingRequests.first;
+
+      // We use a "Greedy First" rule to determine if the next request in the
+      // FIFO queue can proceed.
+      var canProceed = false;
+
+      // If there are no other decodes currently in flight, we ALWAYS allow the
+      // first item in the queue to proceed. This is critical to prevent
+      // deadlocks where a single extremely large image (e.g., > 128MB) would
+      // otherwise be permanently blocked by the memory limit.
+      if (_activeDecodesCount == 0) {
+        canProceed = true;
+      } else if (_activeDecodesCount < _maxConcurrentDecodes &&
+          _activeDecodesBytes + request._estimatedBytes <= _maxConcurrentBytes) {
+        // If we have active decodes, we only proceed if we are under BOTH the
+        // concurrency limit and the cumulative memory footprint limit.
+        canProceed = true;
+      }
+
+      if (canProceed) {
+        // The request has been granted a slot. We remove it from the queue,
+        // update our accounting of active resources, and notify the requester.
+        _pendingRequests.removeFirst();
+        request._granted = true;
+        _activeDecodesCount++;
+        _activeDecodesBytes += request._estimatedBytes;
+        request._completer.complete();
+      } else {
+        // Since we are enforcing a strict FIFO order, if the first item in the
+        // queue cannot proceed due to resource limits, we must stop and wait
+        // for an active decode to complete and release its slot.
+        break;
+      }
+    }
+  }
+}
+
+/// A request for a decoding slot from [ImageDecodingManager].
+class ImageDecodingRequest {
+  ImageDecodingRequest._(this._estimatedBytes, this._completer);
+
+  final int _estimatedBytes;
+  final Completer<void> _completer;
+  bool _granted = false;
+
+  /// A future that completes when the decoding slot has been granted.
+  Future<void> get future => _completer.future;
+}
+
+/// Exception thrown when an image decoding request is cancelled.
+class ImageDecodingCancelledException implements Exception {
+  const ImageDecodingCancelledException();
+
+  @override
+  String toString() => 'Image decoding request was cancelled.';
+}
diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/image_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/image_test.dart
index 115bc92..26f4634 100644
--- a/engine/src/flutter/lib/web_ui/test/canvaskit/image_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/canvaskit/image_test.dart
@@ -91,6 +91,16 @@
     image3.dispose();
     expect(imageSource.debugIsClosed, isTrue);
   });
+
+  test('ImageElementImageSource clears src on closure', () async {
+    final DomHTMLImageElement imageElement = createDomHTMLImageElement();
+    imageElement.src = 'sample_image1.png';
+    final ImageSource imageSource = ImageElementImageSource(imageElement);
+
+    expect(imageElement.src, contains('sample_image1.png'));
+    imageSource.close();
+    expect(imageElement.src, isNot(contains('sample_image1.png')));
+  });
 }
 
 Future<ui.Image> _createImage() => _createPicture().toImage(10, 10);
diff --git a/engine/src/flutter/lib/web_ui/test/engine/image_decoding_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/image_decoding_manager_test.dart
new file mode 100644
index 0000000..1882fda
--- /dev/null
+++ b/engine/src/flutter/lib/web_ui/test/engine/image_decoding_manager_test.dart
@@ -0,0 +1,161 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+import 'package:ui/src/engine.dart';
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+void testMain() {
+  group('ImageDecodingManager', () {
+    late ImageDecodingManager manager;
+
+    setUp(() {
+      manager = ImageDecodingManager.instance;
+      manager.debugReset();
+    });
+
+    test('throttles concurrency', () async {
+      final requests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 25; i++) {
+        requests.add(manager.requestDecodingSlot(100, 100));
+      }
+
+      var grantedCount = 0;
+      for (var i = 0; i < 25; i++) {
+        unawaited(requests[i].future.then((_) => grantedCount++));
+      }
+
+      await Future<void>.delayed(Duration.zero);
+      expect(grantedCount, 8);
+
+      // Release one slot
+      manager.releaseDecodingSlot(requests[0]);
+      await Future<void>.delayed(Duration.zero);
+      expect(grantedCount, 9);
+    });
+
+    test('throttles memory', () async {
+      // 128MB limit. 2000x2000x4 = 16MB. 8 such images = 128MB.
+      final requests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 20; i++) {
+        requests.add(manager.requestDecodingSlot(2000, 2000));
+      }
+
+      var grantedCount = 0;
+      for (var i = 0; i < 20; i++) {
+        unawaited(requests[i].future.then((_) => grantedCount++));
+      }
+
+      await Future<void>.delayed(Duration.zero);
+      expect(grantedCount, 8);
+
+      // Release one slot
+      manager.releaseDecodingSlot(requests[0]);
+      await Future<void>.delayed(Duration.zero);
+      expect(grantedCount, 9);
+    });
+
+    test('Greedy First rule', () async {
+      // Request a huge image that exceeds the budget
+      // 200MB image: 5000x10000x4 = 200MB.
+      final ImageDecodingRequest request = manager.requestDecodingSlot(5000, 10000);
+
+      var granted = false;
+      unawaited(request.future.then((_) => granted = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted, true); // Should be granted because it's the first and nothing else is active.
+
+      // While huge image is active, another small request should be blocked by memory limit.
+      final ImageDecodingRequest request2 = manager.requestDecodingSlot(100, 100);
+      var granted2 = false;
+      unawaited(request2.future.then((_) => granted2 = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted2, false);
+
+      // Release huge image
+      manager.releaseDecodingSlot(request);
+      await Future<void>.delayed(Duration.zero);
+      expect(granted2, true);
+    });
+
+    test('cancel request', () async {
+      final activeRequests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 8; i++) {
+        activeRequests.add(manager.requestDecodingSlot(100, 100));
+      }
+
+      final ImageDecodingRequest request = manager.requestDecodingSlot(100, 100);
+      var granted = false;
+      Object? error;
+      unawaited(request.future.then((_) => granted = true, onError: (Object e) => error = e));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted, false);
+      expect(error, isNull);
+
+      manager.cancel(request);
+      await Future<void>.delayed(Duration.zero);
+      expect(error, isA<ImageDecodingCancelledException>());
+
+      // Release a slot, the cancelled request should not be granted.
+      manager.releaseDecodingSlot(activeRequests[0]);
+      await Future<void>.delayed(Duration.zero);
+      expect(granted, false);
+
+      // A new request should get the slot.
+      final ImageDecodingRequest request2 = manager.requestDecodingSlot(100, 100);
+      var granted2 = false;
+      unawaited(request2.future.then((_) => granted2 = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted2, true);
+    });
+
+    test('releasing pending request before grant', () async {
+      // Occupy all slots
+      final activeRequests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 8; i++) {
+        activeRequests.add(manager.requestDecodingSlot(100, 100));
+      }
+
+      final int initialCount = manager.debugActiveDecodesCount;
+      final int initialBytes = manager.debugActiveDecodesBytes;
+      expect(initialCount, 8);
+
+      // Request another slot (will be pending)
+      final ImageDecodingRequest pendingRequest = manager.requestDecodingSlot(100, 100);
+      var granted = false;
+      unawaited(pendingRequest.future.then((_) => granted = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted, false);
+
+      // Release the pending request before it's granted
+      manager.releaseDecodingSlot(pendingRequest);
+
+      // Verify that accounting hasn't changed
+      expect(manager.debugActiveDecodesCount, initialCount);
+      expect(manager.debugActiveDecodesBytes, initialBytes);
+
+      // Release an active slot
+      manager.releaseDecodingSlot(activeRequests[0]);
+      await Future<void>.delayed(Duration.zero);
+
+      // The pending request should never have been granted
+      expect(granted, false);
+      expect(manager.debugActiveDecodesCount, 7);
+
+      // A new request should still be able to get a slot
+      final ImageDecodingRequest newRequest = manager.requestDecodingSlot(100, 100);
+      var newGranted = false;
+      unawaited(newRequest.future.then((_) => newGranted = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(newGranted, true);
+      expect(manager.debugActiveDecodesCount, 8);
+    });
+  });
+}
diff --git a/engine/src/flutter/lib/web_ui/test/ui/image/html_image_element_codec_test.dart b/engine/src/flutter/lib/web_ui/test/ui/image/html_image_element_codec_test.dart
index 2bb93a7..366e172 100644
--- a/engine/src/flutter/lib/web_ui/test/ui/image/html_image_element_codec_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/ui/image/html_image_element_codec_test.dart
@@ -7,8 +7,7 @@
 
 import 'package:test/bootstrap/browser.dart';
 import 'package:test/test.dart';
-import 'package:ui/src/engine/canvaskit/image.dart';
-import 'package:ui/src/engine/html_image_element_codec.dart';
+import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart' as ui;
 import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
 
@@ -21,6 +20,9 @@
 
 Future<void> testMain() async {
   setUpUnitTests();
+  setUp(() {
+    ImageDecodingManager.instance.debugReset();
+  });
   group('$HtmlImageElementCodec', () {
     test('supports raw images - RGBA8888', () async {
       final completer = Completer<ui.Image>();
@@ -93,6 +95,102 @@
       expect(buffer.toString(), '0/100,100/100,');
     });
 
+    test('uses ImageDecodingManager', () async {
+      final ImageDecodingManager manager = ImageDecodingManager.instance;
+      // Occupy all slots
+      final requests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 8; i++) {
+        requests.add(manager.requestDecodingSlot(100, 100));
+      }
+
+      final HtmlImageElementCodec codec = CkImageElementCodec('sample_image1.png');
+      var decoded = false;
+      final Future<void> decodeFuture = codec.decode().then((_) => decoded = true);
+
+      // Give it some time to load (Phase 1)
+      await Future<void>.delayed(const Duration(milliseconds: 100));
+      expect(decoded, false); // Should be blocked in Phase 2
+
+      // Release one slot
+      manager.releaseDecodingSlot(requests[0]);
+
+      // Wait for it to decode (Phase 3)
+      await decodeFuture;
+      expect(decoded, true);
+
+      // Clean up remaining slots
+      for (var i = 1; i < 8; i++) {
+        manager.releaseDecodingSlot(requests[i]);
+      }
+    });
+
+    test('dispose unblocks ImageDecodingManager queue', () async {
+      final ImageDecodingManager manager = ImageDecodingManager.instance;
+      // Occupy all slots
+      final requests = <ImageDecodingRequest>[];
+      for (var i = 0; i < 8; i++) {
+        requests.add(manager.requestDecodingSlot(100, 100));
+      }
+
+      final HtmlImageElementCodec codec = CkImageElementCodec('sample_image1.png');
+      var decodeFinished = false;
+      unawaited(codec.decode().whenComplete(() => decodeFinished = true));
+
+      // Give it some time to load (Phase 1)
+      await Future<void>.delayed(const Duration(milliseconds: 100));
+      expect(decodeFinished, false); // Should be blocked in Phase 2
+
+      // Dispose the codec while it's in the queue
+      codec.dispose();
+
+      // The decode future should complete (as requested in the plan)
+      await Future<void>.delayed(Duration.zero);
+      expect(decodeFinished, true);
+
+      // A new request should be able to get a slot if we release one.
+      manager.releaseDecodingSlot(requests[0]);
+      final ImageDecodingRequest request2 = manager.requestDecodingSlot(100, 100);
+      var granted2 = false;
+      unawaited(request2.future.then((_) => granted2 = true));
+      await Future<void>.delayed(Duration.zero);
+      expect(granted2, true);
+
+      // Clean up
+      for (var i = 1; i < 8; i++) {
+        manager.releaseDecodingSlot(requests[i]);
+      }
+      manager.releaseDecodingSlot(request2);
+    });
+
+    test('getNextFrame() throws StateError if disposed', () async {
+      final HtmlImageElementCodec codec = CkImageElementCodec('sample_image1.png');
+      codec.dispose();
+      expect(() => codec.getNextFrame(), throwsStateError);
+    });
+
+    test('clears src on loading failure', () async {
+      final HtmlImageElementCodec codec = CkImageElementCodec('non_existent_image.png');
+      try {
+        await codec.getNextFrame();
+        fail('Should have thrown an exception');
+      } catch (e) {
+        expect(e, isA<ImageCodecException>());
+      }
+      expect(codec.imgElement?.src, isNot(contains('non_existent_image.png')));
+    });
+
+    test('dispose does not clear src if image handed out', () async {
+      final HtmlImageElementCodec codec = CkImageElementCodec('sample_image1.png');
+      final ui.FrameInfo frame = await codec.getNextFrame();
+      final String? src = codec.imgElement?.src;
+      expect(src, contains('sample_image1.png'));
+
+      codec.dispose();
+      expect(codec.imgElement?.src, src); // Should NOT be cleared
+
+      frame.image.dispose();
+    });
+
     /// Regression test for Firefox
     /// https://github.com/flutter/flutter/issues/66412
     test('Returns nonzero natural width/height', () async {