Overview
Client-side media processing handles image compression, resizing, format conversion, rotation, and thumbnail generation in the browser using WebAssembly, rather than on the server. It is enabled by default in WordPress 7.1 for supported browsers and transparently falls back to server-side processing when unavailable.
This guide covers how plugin and theme developers can interact with, customize, and troubleshoot client-side media processing.
For a deep dive into the architecture, see Client-side media processing architecture.
Requirements
Client-side media processing activates only when the browser, device, and network all meet the thresholds below. Every check must pass; if any fails, WordPress transparently falls back to server-side processing with no user-facing change.
Browser
The pipeline depends on Document-Isolation-Policy (DIP) to enable SharedArrayBuffer, which is currently limited to Chromium-based browsers.
| Browser | Minimum version | Notes |
|---|---|---|
| Chrome | 137+ | Full support via Document-Isolation-Policy. |
| Edge | 137+ | Full support via Document-Isolation-Policy. |
| Firefox | — | Not supported (no Document-Isolation-Policy support). |
| Safari | — | Not supported for the WASM pipeline; the HEIC canvas fallback still works. |
Browser capabilities
In addition to a supported browser, these APIs must be available:
- WebAssembly — required for wasm-vips.
- SharedArrayBuffer — required for WASM threading (implies cross-origin isolation via DIP is active).
- Web Worker — required for the off-main-thread vips worker.
- CSP allows
blob:workers — theworker-srcdirective must includeblob:(see Content Security Policy requirements).
Device and network
| Requirement | Threshold | Reason |
|---|---|---|
| Device memory | > 2 GB | WASM image processing holds the full image plus working buffers in memory; devices reporting ≤ 2 GB RAM are excluded to avoid out-of-memory crashes. |
| CPU cores | ≥ 2 | A WASM worker can monopolize a core during encode; at least two cores keeps the UI thread responsive. |
| Network connection | Not 2g/slow-2g, Save-Data off |
The ~13 MB worker download is gated to faster connections; 3g and above are allowed. |
Note: HEIC/HEIF decoding uses a separate, looser gate. It needs only
createImageBitmapandOffscreenCanvas, so Safari can convert iPhone photos even though it can’t run the WASM pipeline. See Supported file formats.
For the full list of checks and the exact detection logic, see the architecture documentation.
Disabling client-side media processing
Use the wp_client_side_media_processing_enabled filter to disable client-side processing.
Disable site-wide
add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
Disable conditionally
add_filter( 'wp_client_side_media_processing_enabled', 'example_disable_for_non_admins' );
function example_disable_for_non_admins( $enabled ) {
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
return $enabled;
}
When disabled, all image processing reverts to the traditional server-side pipeline. No other changes are needed.
Customizing image processing settings
Client-side processing respects existing WordPress image filters. These filters are read from the server via the REST API index and applied during client-side processing.
Big image size threshold
The big_image_size_threshold filter controls the maximum dimension for uploaded images. Images larger than this threshold are scaled down. The default is 2560 pixels.
// Change the big image threshold to 1920px.
add_filter( 'big_image_size_threshold', function () {
return 1920;
} );
This works the same way whether processing happens client-side or server-side.
Image output format conversion
The image_editor_output_format filter controls automatic format conversion (e.g., converting JPEG uploads to WebP).
// Convert all JPEG uploads to WebP.
add_filter( 'image_editor_output_format', function ( $formats ) {
$formats['image/jpeg'] = 'image/webp';
return $formats;
} );
Client-side processing will apply this conversion before uploading sub-sized images. If a PNG has transparency and the target format is JPEG, the conversion is skipped to preserve the alpha channel.
UltraHDR JPEGs (photos with an embedded HDR gain map — the backwards-compatible HDR approach supported by Google’s UltraHDR, Apple’s Adaptive HDR, and Adobe’s tools) are also excluded from format conversion — converting to a different codec would strip the gain map. They upload unmodified, and their sub-sizes are generated as UltraHDR JPEGs with the gain map preserved. See the architecture documentation for details.
Progressive/interlaced image output
The image_save_progressive filter controls whether images are saved with progressive (JPEG) or interlaced (PNG, GIF) encoding.
// Enable progressive JPEG output.
add_filter( 'image_save_progressive', function ( $interlaced, $mime_type ) {
if ( 'image/jpeg' === $mime_type ) {
return true;
}
return $interlaced;
}, 10, 2 );
Metadata stripping
The image_strip_meta filter controls whether metadata is stripped from generated images. By default, generated sub-sizes keep only color profiles (and, for UltraHDR, gain maps), matching server-side behavior. Returning false keeps all metadata (EXIF, XMP, IPTC) on generated images:
// Keep all metadata on generated sub-sizes.
add_filter( 'image_strip_meta', '__return_false' );
Maximum bit depth
The image_max_bit_depth filter caps the bit depth of generated images. By default, high-bit-depth AVIF sources (10- or 12-bit, common for HDR photos) keep their bit depth in generated sub-sizes; a lower cap reduces file size at the cost of dynamic range:
// Cap generated images at 8 bits per channel.
add_filter( 'image_max_bit_depth', function () {
return 8;
} );
The client snaps the cap to the depths the AVIF encoder supports (8, 10, or 12 bits).
Supported file formats
Client-side processing handles the following MIME types in the WASM/vips pipeline: image/jpeg, image/png, image/gif, image/webp, image/avif. Files outside this set fall through to one of two paths depending on type:
- HEIC/HEIF (
image/heic,image/heif): the client decodes the file withcreateImageBitmap, anHTMLImageElement/OffscreenCanvasstrategy, or a HEIC-container + WebCodecsVideoDecoderpath (Chromium 107+ via the platform codec — VideoToolbox on macOS, the Microsoft HEVC Video Extension on Windows), exports a JPEG (.jpg, MIMEimage/jpeg), and lets the server generate sub-sizes from the JPEG. The original HEIC is kept as a companion file in$metadata['original']and deleted with the attachment. - Other (video, audio, documents): the file is uploaded directly to the server with default parameters and standard server-side processing. No client-side step runs.
The set of MIME types eligible for the WASM pipeline is fixed at CLIENT_SIDE_SUPPORTED_MIME_TYPES in packages/upload-media/src/store/constants.ts — there is no public filter for adding or removing types.
Animated GIF to video conversion
When an opaque animated GIF is uploaded, the editor converts it to a companion MP4 (or WebM) video. A “Display as video” toolbar control on the Image block then lets the user switch the block to a “GIF” variation of the Video block that plays like the original GIF. This reduces download size dramatically while preserving the look-and-feel of an animated GIF. Conversion happens client-side via the browser’s WebCodecs APIs and the @wordpress/video-conversion package; for the full pipeline see the architecture documentation.
What plugin and theme developers should know:
- The attachment stays a GIF. An uploaded GIF remains a single
image/gifattachment in the media library. The converted video and a first-frame poster are stored as companion files, recorded inmedia_details.animated_videoandmedia_details.animated_video_poster— they are never separate attachments. They are removed automatically when the GIF attachment is deleted. - The front end renders a real video block. The swap is a user-initiated block switch in the editor, not a render-time filter. The published content is a native
core/videoblock (<video autoplay loop muted playsinline poster>), so there is no GIF-specific output filtering to account for. - Transparent GIFs are not converted. A
<video>cannot reproduce GIF transparency, so transparent animated GIFs upload as ordinary images with no companion. - It is opt-in and reversible. The block is never switched automatically — users convert via the “Display as video” toolbar control on the Image block, and a “Display as GIF” control on the resulting video block switches it back to the original GIF.
- Galleries, Media & Text, and Cover are unaffected. Only standalone Image blocks offer the switch; GIFs inside a Gallery stay GIFs, and Media & Text / Cover media is untouched.
- Browser support. Conversion requires WebCodecs video encoding (
ImageDecoder+VideoEncoder). Browsers without it — notably Firefox — transparently upload the original GIF instead, with no error.
There is no public filter to opt a site out of GIF conversion specifically; disabling client-side media processing entirely (see Disabling client-side media processing) also disables it.
Working with the upload store (JavaScript)
Client-side media processing is managed by the core/upload-media data store. Plugin developers can use its public API to monitor and interact with uploads.
Checking upload status
import { select } from '@wordpress/data';
import { store as uploadStore } from '@wordpress/upload-media';
// Check if any upload is in progress.
const uploading = select( uploadStore ).isUploading();
// Get all items in the upload queue.
const items = select( uploadStore ).getItems();
Adding items to the upload queue
import { dispatch } from '@wordpress/data';
import { store as uploadStore } from '@wordpress/upload-media';
dispatch( uploadStore ).addItems( {
files: [ file1, file2 ],
onSuccess( attachment ) {
console.log( 'Uploaded:', attachment.id );
},
onError( error ) {
console.error( 'Upload failed:', error.message );
},
onBatchSuccess() {
console.log( 'All files in batch uploaded.' );
},
additionalData: {
post: postId,
},
} );
Available selectors
| Selector | Returns | Description |
|---|---|---|
isUploading() |
boolean |
Whether any upload is currently active. |
isUploadingById( id ) |
boolean |
Whether a specific attachment ID is being uploaded. |
isUploadingByUrl( url ) |
boolean |
Whether a specific URL is being uploaded. |
getItems() |
array |
All items in the upload queue. |
getSettings() |
object |
Current upload settings (concurrency limits, allowed types, etc.). |
Available actions
| Action | Description |
|---|---|
addItems( args ) |
Add files to the upload queue. Accepts files, onChange, onSuccess, onBatchSuccess, onError, additionalData, allowedTypes. |
cancelItem( id, error ) |
Cancel an in-progress upload and clean up resources. |
retryItem( id ) |
Retry a failed upload. |
Server-side plugin compatibility
When client-side processing is active, the wp_generate_attachment_metadata filter fires the same way it does for a server-side upload: once with context 'create' during the initial upload, and again with 'update' after WordPress calls the finalize endpoint (POST /wp/v2/media/{id}/finalize) once all client-side sub-size sideloads complete. Plugins that rely on this hook for watermarking, CDN sync, custom image sizes, or other post-processing continue to work without modification — write them idempotently so they handle both passes correctly.
This double-fire pattern is the same one WordPress uses for big-image uploads on the server, where sub-size generation is deferred and triggers a second 'update' pass. Plugins that already work with big-image uploads accommodate it without changes.
If the finalize request fails, the error is logged but the upload still succeeds — finalization is best-effort so that a plugin failure does not block the user’s upload.
Which hooks fire during a client-side upload
A client-side upload reaches the server as a series of REST requests, each firing the standard hooks (a complete audit across all supported image types is in #80210):
- Create (
POST /wp/v2/media): the usual upload surface fires —wp_handle_upload_prefilter,wp_check_filetype_and_ext,wp_handle_upload,add_attachment,rest_insert_attachment/rest_after_insert_attachment,wp_read_image_metadata, andwp_generate_attachment_metadata(context'create'). - Sideload (
POST /wp/v2/media/{id}/sideload, once per generated file):wp_handle_upload_prefilterandwp_handle_uploadfire for every client-generated sub-size, with the final file path. A plugin that needs to see each generated file (offloaders, optimizers) gets exactly onewp_handle_uploadcall per file. Companion files (the HEIC original, the animated GIF’s video and poster) go through the same endpoint and fire the same hooks. - Finalize (
POST /wp/v2/media/{id}/finalize):wp_generate_attachment_metadata(context'update') fires with the completesizesarray — the “everything is done” signal.
Hooks that no longer apply
Because decoding, scaling, and encoding happen in the browser, no server-side WP_Image_Editor is instantiated, and three hooks never fire on the client path:
wp_image_editors— there is no server-side editor to swap out. If your site depends on a custom image editor implementation, disable client-side processing with thewp_client_side_media_processing_enabledfilter.image_make_intermediate_size— use the per-sideloadwp_handle_uploadfiring (above) to see each generated file, and the finalize pass for the complete metadata.image_memory_limit— PHP memory is not involved in client-side processing.
The other image-processing filters (image_editor_output_format, wp_editor_set_quality, jpeg_quality, big_image_size_threshold, image_save_progressive, image_strip_meta, image_max_bit_depth) continue to work: their values are read on the server and shipped to the client, which honors them during processing. See Customizing image processing settings.
EXIF rotation
wp_image_maybe_exif_rotate still fires for images that carry an EXIF orientation, but the server forces its value to false during a client-side upload: the client applies the rotation itself, using the exif_orientation field from the upload response.
Controlling image quality
Client-side encoding honors the same PHP filters that control server-side image quality — wp_editor_set_quality and, for JPEG output, jpeg_quality. The filtered values are carried to the client in the upload response’s size-aware image_quality field, so the same code that tunes server-side quality works unchanged:
/*
* Size-aware: drop JPEG thumbnails (300px wide or less) to quality 60,
* leave larger sizes untouched.
*/
add_filter(
'wp_editor_set_quality',
function ( $quality, $mime_type, $size ) {
if ( 'image/jpeg' === $mime_type && isset( $size['width'] ) && $size['width'] <= 300 ) {
return 60;
}
return $quality;
},
10,
3
);
Quality uses the WordPress 1–100 scale; the client converts it to the 0–1 scale the vips worker expects and applies it per sub-size during resize and transcode. When the server does not report the field, the client falls back to a default of 0.82. There is no separate JavaScript quality filter.
Using the finalize endpoint
After all client-side thumbnail sideloads complete, the finalize endpoint triggers server-side post-processing:
POST /wp/v2/media/{id}/finalize
This endpoint applies the wp_generate_attachment_metadata filter with context 'update', then saves the updated metadata. It requires edit_post and upload_files capabilities. This is the second of two passes during a client-side upload — see Server-side plugin compatibility for the full picture.
WordPress calls this endpoint automatically as part of the client-side upload pipeline. Plugin developers do not need to call it manually — it is documented here for context on how server-side hooks are preserved.
Cross-origin isolation considerations
Client-side media processing requires SharedArrayBuffer for WASM threading. WordPress enables this automatically on block editor screens using Document-Isolation-Policy (DIP), which provides per-document cross-origin isolation without affecting other iframes on the page.
Impact on plugins
- External scripts: Scripts loaded from other origins will automatically get a
crossorigin="anonymous"attribute added, handled by WordPress server-side (via HTML processing) and client-side (via a MutationObserver). - Third-party page builders: DIP is skipped on admin pages with an
actionparameter other thanedit, to avoid conflicts with page builders that rely on same-origin iframe access. - External images: Importing an external image into the media library is handled server-side — the editor sends the image URL to
POST /wp/v2/media(theurlparameter) and the server downloads and sideloads the file. A browser cross-origin fetch would be subject to CORS and fails in acredentiallessisolated document, so plugins importing remote media should use the same server-side path (exposed to the editor as themediaSideloadFromUrlsetting) rather thanfetch()ing image bytes in the browser.
Content Security Policy (CSP) requirements
If your site or plugin sets a Content Security Policy, the worker-src directive must include blob: to allow inline worker creation:
Content-Security-Policy: worker-src 'self' blob:;
Without this, the WASM processing worker cannot be created and client-side processing will fall back to server-side.
Checking isolation status
You can verify cross-origin isolation is active in the browser console:
console.log( window.crossOriginIsolated ); // true if isolation is active
Using the sideload endpoint
The sideload endpoint allows uploading pre-processed image variants (thumbnails, scaled versions) to an existing attachment:
POST /wp/v2/media/{id}/sideload
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
image_size |
string | string[] | Yes | The image size name (e.g., thumbnail, medium, large, scaled, original). Pass an array to register a single physical file under multiple sizes that share dimensions. |
generate_sub_sizes |
boolean | No | Whether the server should generate additional sub-sizes from this upload. Default true. |
convert_format |
boolean | No | Whether to apply server-side format conversion via image_editor_output_format. Default true. |
replace_file |
boolean | No | When true, replaces the attachment’s main file with the uploaded file, updating the MIME type and metadata and deleting the old file. Default false. Used by the HEIC JPEG companion path. |
Example
const formData = new FormData();
formData.append( 'file', thumbnailBlob, 'image-150x150.webp' );
await wp.apiFetch( {
path: `/wp/v2/media/123/sideload?image_size=thumbnail`,
method: 'POST',
body: formData,
} );
This endpoint requires both edit_post and upload_files capabilities.
Dimension validation
The endpoint checks that the uploaded file’s dimensions are appropriate for the image_size you declare. An original upload must match the attachment’s stored dimensions exactly; a regular registered size (thumbnail, medium, and so on) must not exceed that size’s registered width or height (with a 1px rounding tolerance); and every size must have positive dimensions. The scaled, full, and original-heic sizes are exempt from the maximum-dimension check. If validation fails, the uploaded file is discarded and the request returns a 400 response with one of rest_upload_dimension_mismatch, rest_upload_invalid_dimensions, or rest_upload_unknown_size.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Client-side processing not activating | Browser lacks Document-Isolation-Policy support | Client-side processing requires Chrome/Edge 137+. Check window.crossOriginIsolated in the browser console. |
| Client-side processing not activating on Chrome 137+ | Document-Isolation-Policy header not sent |
Verify you’re editing a post or page (the header is skipped on admin pages with an action other than edit). Some third-party page builders may suppress the header. |
| CSP blocks worker creation | worker-src directive too restrictive |
Add blob: to the worker-src CSP directive: worker-src 'self' blob: |
| Processing falls back on capable browser | Feature disabled server-side | Check that wp_client_side_media_processing_enabled filter is not returning false. |
| Large images cause browser to slow down | Insufficient device memory | Devices with ≤ 2 GB RAM are automatically excluded. Consider reducing the big image size threshold for your site. |
| Feature disabled on a low-spec laptop | Below 2 CPU cores or 2 GB RAM | These thresholds protect underpowered devices from out-of-memory crashes and are not configurable. Affected devices fall back to server-side processing. |
| Feature disabled on slow network | 2g/slow-2g connection or Save-Data is on |
The ~13 MB worker download is gated behind effectiveType and saveData. 3g and faster connections are allowed. |
| HEIC upload error on a server without HEIC support | Server-side HEIC decoder missing | The client converts HEIC to JPEG before upload — this should not occur in browsers that match isHeicCanvasSupported(). Verify the browser provides createImageBitmap and OffscreenCanvas. |
| Upload fails with “image transcoding error” | Unsupported format or corrupt file | Verify the file is a supported format (JPEG, PNG, WebP, AVIF, GIF, HEIC). |
| Save Draft button stays disabled | Lock not released | Ensure all upload items have completed or been cancelled. The lock releases when the upload queue is empty. |
| Sideload request rejected with a 400 dimension error | Uploaded variant doesn’t match the declared image_size |
The sideload endpoint validates dimensions. Make sure the file matches the size in image_size: an exact match for original, or within the registered maximum for a named size like thumbnail. |
| A plugin’s image-processing hook stopped running | Hook doesn’t fire on the client path | wp_image_editors, image_make_intermediate_size, and image_memory_limit never fire when processing happens in the browser. See Hooks that no longer apply for replacements. |
Debugging the WASM processor
The WASM image processor (wasm-vips) writes internal stdout/stderr messages (for example, non-actionable AVIF codec warnings) that are suppressed by default to keep the browser console clean. To capture this output while debugging, assign a function to globalThis.__vipsDebug before uploading:
globalThis.__vipsDebug = ( text ) => console.log( '[vips]', text );
Set it back to undefined when you’re done.