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:
- Read the real upload limit
- Enqueue the JavaScript file on admin pages
- 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.fetchwhen 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:
window.fetch— the REST funnel both apiFetch versions usewindow.wp.apiFetch— classic editor scripts- the
plupload.Uploaderconstructor — 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:
| Version | What we patched | Why it failed |
|---|---|---|
| 1.0.0 | wp.Uploader.prototype.upload | Removed in WP 6.8. The prototype is only moxie EventTarget methods. |
| 1.0.1 | plupload.Uploader.prototype.addFile | Not a prototype method. It is an instance method in WP 6.8. |
| 1.0.2 | plupload.Uploader constructor | Works for the media modal, but Gutenberg uses REST, not plupload. |
| 1.0.3 | window.wp.apiFetch | Gutenberg bundles its own apiFetch. The global is only for classic scripts. |
| 1.0.4 | window.fetch | The bundle captured native fetch before our script loaded. |
| 1.0.5 | window.fetch from the page head | Works. The resize fired. Then the server rejected a 16 MB file. |
| 1.0.6 | Read the real PHP limit | The 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.
Leave a Reply