A Client-Side Image Resizer for WordPress: How a Seasoned Developer Builds a Plugin

WordPress rejected our images. The upload limit on our server is 2 MB. Every modern phone produces a file that is larger. We built a plugin that fixes the problem in the browser, before the upload starts. This article shows how a seasoned developer builds a WordPress plugin, and why client-side processing beats server-side processing for this job. New to plugin development? Read our beginner guide first. It builds a plugin from scratch and explains every term.

The Problem: A Server Limit That Lies

Our site runs in a Docker stack. PHP allows 2 MB uploads (upload_max_filesize) and 8 MB request bodies (post_max_size). WordPress rejects any image over 2 MB with this error:

rest_upload_unknown_error: The uploaded file exceeds the upload_max_filesize directive in php.ini.

Then we found the trap. WordPress reported the limit as 32 MB. The function wp_max_upload_size() returned 32 MB because another plugin filtered the value upward. That plugin changed what WordPress displays. It cannot change what PHP accepts. The admin screen lied. The server still rejected the file.

Lesson one: read the server limit, not the WordPress display value.

Why Client-Side Processing?

Most image plugins resize on the server. Imsanity, EWWW Image Optimizer and Imagify all use PHP and GD. A server-side resize costs CPU time. It needs GD or ImageMagick on the host. It can time out on huge files. It loads the server exactly when many users upload at once.

Client-side processing moves the work to the visitor’s browser. The Canvas API can decode an image, draw it at a smaller size, and export a compressed blob. No server CPU. No GD. No timeouts. The file that leaves the browser is already small enough for the server.

Lesson two: move image work to the browser when the server is the constraint.

How a WordPress Plugin Works

A plugin is a folder in wp-content/plugins. The main PHP file starts with a header comment. WordPress reads that comment to list the plugin:

<?php
/**
 * Plugin Name: Client-Side Image Resizer
 * Description: Downsamples large images in the browser before upload.
 * Version: 1.0.6
 * License: GPL-2.0-or-later
 */
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

Plugins register behaviour through hooks. Hooks are events. add_action() runs a function when an event fires. add_filter() changes a value. Common events are admin_enqueue_scripts, admin_menu and init.

Our plugin needs three PHP jobs:

  1. Read the real upload limit
  2. Enqueue the JavaScript file on admin pages
  3. Pass the limit to JavaScript

The function wp_localize_script() passes PHP values to JavaScript. The script reads them from a global object. This is the standard, safe way to share server data with a browser script.

How We Built It

Step 1: Read the real limit

We compute the limit from PHP itself. We do not trust wp_max_upload_size():

public static function real_max_upload_size(): int {
    $upload = wp_convert_hr_to_bytes( ini_get( 'upload_max_filesize' ) );
    $post   = wp_convert_hr_to_bytes( ini_get( 'post_max_size' ) );

    if ( $upload <= 0 ) { $upload = wp_max_upload_size(); }
    if ( $post   <= 0 ) { $post   = $upload; }

    return (int) apply_filters( 'csir_max_upload_size', min( $upload, $post ) );
}

This code cannot be fooled by a display filter. Site owners can still override the result with the csir_max_upload_size filter.

Step 2: Intercept the upload

This is where it gets interesting. We had to learn how WordPress uploads files, in detail. The upload path changed across versions:

  • Older WordPress: wp.Uploader.prototype.upload() added files
  • WP 6.3+: media views call plupload addFile() directly
  • WP 6.8: addFile() is an instance method, not a prototype method
  • Gutenberg: the block editor bundles its own apiFetch client
  • The bundle captures window.fetch when it loads

Each change broke our first attempt. A seasoned developer does not fight the framework. They find the lowest-level funnel that never moves. We patch three points:

  1. window.fetch — the REST funnel both apiFetch versions use
  2. window.wp.apiFetch — classic editor scripts
  3. the plupload.Uploader constructor — the media modal uploader

Timing matters. Gutenberg captures fetch when its script runs. Our script must run first. We enqueue it in the page head with no dependencies. The head script runs before Gutenberg. Gutenberg then captures our patched fetch.

Step 3: The resize loop

The JavaScript decodes the image, then re-encodes it smaller until it fits the limit:

// simplified resize loop
for ( let i = 0; i < 15; i++ ) {
    const blob = await canvasToBlob( source, w, h, format, quality );
    if ( blob.size <= limit ) return makeFile( blob, file );
    quality -= 0.08;              // JPEG / WebP: lower quality first
    if ( quality < 0.5 ) w *= 0.88; // then shrink dimensions
}

Details matter:

  • PNG ignores the quality parameter. It shrinks by dimensions only. This keeps transparency.
  • Small files pass through untouched. We never upscale.
  • If the image cannot shrink enough, we send the original. WordPress shows its normal error.

The WP 6.8 Rabbit Hole

We shipped six versions in one day. Each version fixed a real bug we found on the live site:

VersionWhat we patchedWhy it failed
1.0.0wp.Uploader.prototype.uploadRemoved in WP 6.8. The prototype is only moxie EventTarget methods.
1.0.1plupload.Uploader.prototype.addFileNot a prototype method. It is an instance method in WP 6.8.
1.0.2plupload.Uploader constructorWorks for the media modal, but Gutenberg uses REST, not plupload.
1.0.3window.wp.apiFetchGutenberg bundles its own apiFetch. The global is only for classic scripts.
1.0.4window.fetchThe bundle captured native fetch before our script loaded.
1.0.5window.fetch from the page headWorks. The resize fired. Then the server rejected a 16 MB file.
1.0.6Read the real PHP limitThe 32 MB display value was a lie. The real limit is 2 MB.

The final bug was the best. The resize worked. The file was 16 MB. The server still rejected it. The reason: the request body exceeded post_max_size (8 MB). PHP truncated the multipart data. The truncated file then failed the type check. The fix was not in the resize. The fix was in the target size. We resized to the real 2 MB limit, not the reported 32 MB.

Verification

We test in a real browser, not by eye. A small harness page generates a large noise image, runs the resize, and checks the result against the limit. Then we repeated the test on the live site. The server returned:

{
  "status": 201,
  "sourceBytes": 34290811,   // 34.3 MB input
  "filesize": 650656,        // 0.62 MB uploaded
  "width": 1567,
  "height": 1253             // source was 6500 x 5200
}

The server accepted a 34.3 MB image after the browser resized it to 0.62 MB. The upload succeeded with status 201 Created.

What Makes This Different

We checked the WordPress plugin directory before building. The closest plugin, Resize Image Before Upload, resizes to a fixed pixel size. Cimo converts images to WebP in the browser. Squeeze compresses in the browser. None of them target the server’s actual upload limit.

Our plugin reads the real PHP limit and fits each image to it. That is the difference. It works on shared hosts, Docker stacks, and anything else, because it asks the server for the truth.

Summary

  • A WordPress plugin is a folder, a header comment, and hooks
  • Read the real PHP limit, not the filtered display value
  • Intercept at the lowest funnel: window.fetch, loaded in the head
  • Resize in the browser with Canvas; the server never rejects the file
  • Test in a real browser with generated noise images

Status (27 August 2026): the plugin is submitted to the WordPress.org plugin directory under the name UploadFit, and it is awaiting review. Source code lives on GitHub. It already fixes a real problem on this site: every image upload, from the editor or by paste, now fits the server limit before it leaves the browser.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *