react-media-compressor
v0.1.1
Published
Compress images and videos in React and React Native. Set an exact size limit like 300 KB and get it, or use auto presets. Works on iOS, Android and web with no FFmpeg and no dependencies.
Maintainers
Readme
react-media-compressor
One image and video compressor for React and React Native. Same imports, same options, same result object on both.
import { compressImage, AUTO, MAX, SIZE } from 'react-media-compressor';
const result = await compressImage(file); // auto — the default
// or ask for a ceiling instead:
// { mode: MAX(SIZE.KB[450]) } at most 450 KB
// { mode: '<=450kb' } the same thing
// { mode: AUTO.MB(2) } auto quality, capped at 2 MB
if (result.status) upload(result.file);
else showError(result.issue);
MAXis a ceiling, not an exact size. No encoder can hit an exact byte count — they take a quality knob or a bitrate, and a size falls out. Ask forMAX(SIZE.KB[300])and you get at most 300 KB, usually a little under. That is also the contract every real constraint has: upload limits and API caps are "no bigger than", never "exactly".
Everything you call comes from index.ts; everything you type against comes
from types.ts. Nothing else is public.
Why this exists
react-native-compressor is excellent at what it does, but its auto mode for
images is a fixed recipe — resize the long edge to 1280, encode at quality 0.8 —
and there is no way to say "make this 500 KB". If you need a size, you write
the retry loop yourself, re-decoding the image on every pass.
This library keeps the decision-making in TypeScript and treats the platform encoder as a backend:
| | react-native-compressor | this |
|---|---|---|
| Cap the output size (<=500kb, SIZE.MB[1]) | — | yes, in ~2 encodes |
| Runs on the web | — | yes, no dependencies |
| Warns when quality was destroyed | — | yes, warningCode |
| Predict output size before encoding | — | estimate() |
| Result on failure | throws | { status: false, issue } |
| Auto mode | fixed 1280 / q0.8 | four presets, adapts to the source |
| Decode cost of an N-pass search | N decodes | 1 decode |
Install
npm install react-media-compressorReact (web)
Nothing else. No native build, no config, no runtime dependencies. Works in Vite, Next.js, CRA, Remix and anything else.
import { compressImage, MAX, SIZE } from 'react-media-compressor';
const result = await compressImage(file, { mode: MAX(SIZE.KB[300]) });On SSR frameworks, call it from the browser. Importing it on the server is
safe — it never touches window at import time — it just cannot compress there.
React Native
npx pod-install ios # iOS onlyThen rebuild the app once — a native module needs a new binary, not just a
reload. Autolinking does the rest: nothing to add to settings.gradle,
MainApplication or your Info.plist.
| | |
|---|---|
| iOS | 13.4+ |
| Android | minSdkVersion 24+, JVM target 17 |
| APK growth | +0.4 MB (measured) |
| Permissions | none — the package declares no uses-permission and adds no Info.plist keys |
Whatever permissions your app needs are your picker's requirements, not this library's. It reads the uri you hand it using access your app already has.
A ph://… uri (returned by @react-native-camera-roll/camera-roll) is a
reference into the photo library rather than a file, so reading it needs your
app to declare:
<key>NSPhotoLibraryUsageDescription</key>
<string>We compress the photos you choose before uploading them.</string>Without it the library returns an explanatory failure rather than letting iOS
terminate your app. Most pickers — expo-image-picker,
react-native-image-picker, react-native-image-crop-picker — copy the asset
out and hand you a file:// uri, which needs nothing.
Likewise on Android, content:// uris from a picker carry their own read grant.
You only need READ_MEDIA_IMAGES / READ_MEDIA_VIDEO if you build a MediaStore
uri yourself instead of getting one from a picker.
Expo
Needs a development build or prebuild — it is a native module, so not Expo Go.
npx expo install react-media-compressor
npx expo prebuild
npx expo run:ios # or run:androidCheck it worked
import { detectRuntime, listEngines } from 'react-media-compressor';
console.log(detectRuntime(), listEngines());
// react-native -> { image: ['native-image'], video: ['native-video'] }
// web -> { image: ['web-canvas'], video: ['web-mediarecorder'] }Empty on React Native means the JS loaded but the binary did not — rebuild, don't just reload the bundle.
The modes
Two, not three. auto decides the size from the content; max is handed a
ceiling and respects it. (Your dynamic/fixed split turned out to be the same
mode twice — see the note at the end of this section.)
auto — best size the picture can take without falling apart
The default. Caps the dimensions, then encodes at a quality chosen for the preset. One pass.
await compressImage(file); // AUTO.balanced
await compressImage(file, { mode: 'tiny' });
await compressImage(file, { mode: AUTO.best });| preset | long edge | quality | for |
|---|---|---|---|
| best | 4096 | 0.92 | archival, print, editing |
| balanced (default) | 2560 | 0.82 | almost everything |
| small | 1600 | 0.70 | feeds, chat |
| tiny | 1080 | 0.55 | thumbnails, previews |
Auto also checks how compressed the source already is. A photo that has been through a lossy encoder before gets encoded at a higher quality than the preset asks for, because stacking a second generation of artefacts onto the first is what makes re-shared pictures look mushy.
auto with a ceiling
AUTO.MB(n) and AUTO.KB(n) mean "look the way auto normally would, but never
exceed this". If the auto pass already fits, that is the answer — no extra work.
If it does not, the image is made smaller rather than blurrier: quality holds
at the preset and the pixel dimensions come down.
await compressImage(file, { mode: AUTO.MB(2) });
await compressImage(file, { mode: AUTO.KB(300) });
await compressImage(file, { mode: AUTO.max('1.5mb', 'small') });This is the one place the two modes differ in spirit. AUTO.MB(2) treats the
number as a limit it hopes not to need; MAX(SIZE.MB[2]) treats it as an
allowance to spend. Same guarantee, different ambition.
max — an upper bound on the file size
The output will be at or under the limit. Never over it.
await compressImage(file, { mode: MAX(SIZE.KB[300]) }); // preset
await compressImage(file, { mode: MAX.KB(300) }); // same thing
await compressImage(file, { mode: MAX('1.5mb') }); // parsed
await compressImage(file, { mode: MAX(remainingQuota) }); // computedIt usually lands a little under — the search accepts anything in the top 8% of
the allowance rather than burning two more encodes chasing the last few
kilobytes. A 300 KB limit typically returns 276–300 KB. Tighten tolerance if
you would rather have the passes than the headroom:
await compressImage(file, { mode: MAX.KB(300), tolerance: 0.02 }); // 294–300 KBIf the limit genuinely cannot be met without wrecking the media, you get the
closest safe result plus a LIMIT_EXCEEDED warning — never a silent overrun.
Pass strict: true to turn that into a failure instead.
Shorthands
A bare size is a limit, which is usually all you want to write:
await compressImage(file, { mode: '<=300kb' });
await compressImage(file, { mode: '<300kb' }); // same
await compressImage(file, { mode: 'max 300kb' }); // same
await compressImage(file, { mode: '300kb' }); // same
await compressImage(file, { mode: SIZE.MB[2] });
await compressImage(file, { mode: 1_500_000 }); // bytesSIZE is both indexable and callable, and parseSize accepts '500kb',
'1.5 MB', '900B' and plain bytes. Units are binary (1 MB = 1048576 B), which
is what phone file managers report.
Both are aliases of MAX and still work. They were a distinction without a
difference — identical at runtime, separated only by where the number came
from, which is not something the library can see. And FIXED actively misled:
it named a ceiling as though it were an exact size.
FIXED === MAX; // true
DYNAMIC === MAX; // trueThe result
One shape, always resolved — never thrown.
// success
{
status: true,
file: { uri, name, size, sizeLabel, mime, width, height, blob?, dispose(), … },
warning: null,
warningCode: null,
warnings: [],
source: { uri, name, size, mime, width, height },
meta: { mode, engine, attempts, elapsedMs, originalSize, compressedSize,
ratio, savedBytes, percentSaved, quality, width, height, passthrough }
}
// success, but you should know something
{
status: true,
file: { … },
warning: 'Image had to be compressed hard to fit — pixels can tear and fine detail can smear…',
warningCode: 'HEAVY_COMPRESSION',
…
}
// failure
{
status: false,
file: null,
issue: 'Could not bring this image under 30 KB; the closest safe result was 41 KB.',
issueCode: 'LIMIT_UNREACHABLE',
…
}So the call site is one branch:
const result = await compressImage(asset, { mode: MAX(SIZE.MB[1]) });
if (!result.status) return toast(result.issue);
if (result.warningCode === 'HEAVY_COMPRESSION') toast(result.warning);
await upload(result.file);Warning codes
| code | meaning |
|---|---|
| HEAVY_COMPRESSION | Quality or bitrate went low enough that artefacts are likely. |
| LIMIT_EXCEEDED | The limit could not be met; the closest safe result came back. |
| ALREADY_SMALL | Source was already under the limit — returned untouched, no work done. |
| NO_GAIN | Compressing made it bigger; the original came back instead. |
| BELOW_MIN_SIZE | Source was under minSizeToCompress. |
| FORMAT_FALLBACK | Requested format unsupported here; a supported one was used. |
| SLOW_ENGINE | A real-time engine did the work (browser video). |
| METADATA_DROPPED | EXIF could not be preserved. |
Pass strict: true to turn LIMIT_EXCEEDED and HEAVY_COMPRESSION into a
status: false with issueCode: 'LIMIT_UNREACHABLE' instead.
Issue codes
INVALID_INPUT · UNREADABLE · ENGINE_MISSING · ENCODE_FAILED ·
LIMIT_UNREACHABLE · CANCELLED · TIMEOUT · BAD_OPTIONS · UNKNOWN
API
| function | does |
|---|---|
| compressImage(input, options?) | compress one image |
| compressVideo(input, options?) | compress one video |
| compress(input, options?) | detect which it is, then dispatch |
| compressBatch(inputs, options?) | many, with bounded concurrency |
| probe(input) | metadata only — nothing is written |
| estimate(input, options?) | predict the output size without encoding |
| configure(config) | set defaults once, at startup |
| registerImageEngine / registerVideoEngine | plug in your own backend |
| disposeAll(results) | revoke every object URL in one call |
Inputs
A File/Blob, raw bytes, a uri string (file://, content://, ph://,
data:, http(s)://), or the asset object an image picker hands you —
{ uri, fileName, fileSize, width, height } works as-is.
Options
{
mode, // 'auto' | '<=450kb' | SIZE.MB[1] | MAX.KB(300) | AUTO.MB(2)
output, // 'auto' | 'webp' | 'jpeg' | 'png' | 'avif' | 'heic' | 'mp4' | 'webm'
maxDimension, // cap the long edge, in pixels
maxWidth, maxHeight,
fileName,
quality, // pin it and skip the search (images)
minQuality, maxQuality, warnBelowQuality,
bitrate, minBitrate, maxFps, stripAudio, audioBitrate, // video
skipIfSmaller, // default true
minSizeToCompress, // e.g. '80kb'
tolerance, // default 0.08 → accept 92–100% of the limit
maxAttempts, // images 6, video 1–2
strict, // warnings become failures
signal, // AbortSignal
timeoutMs,
onProgress, // ({ progress, stage, attempt, bytes })
returnBase64,
}Progress and cancellation
const controller = new AbortController();
const result = await compressVideo(uri, {
mode: MAX(SIZE.MB[10]),
signal: controller.signal,
onProgress: ({ progress, stage }) => setBar(progress),
});
// elsewhere
controller.abort(); // → { status: false, issueCode: 'CANCELLED' }Batch
let done = 0;
const batch = await compressBatch(assets, {
mode: AUTO.MB(2),
concurrency: 3,
// Count completions. `onItem` fires in completion order, not input order,
// so `(i + 1) / total` would make the bar jump around and go backwards.
onItem: () => setProgress(++done / assets.length),
});
batch.succeeded; // CompressSuccess[]
batch.failed; // CompressFailure[]
batch.percentSaved; // 87.4Concurrency defaults to 2 on React Native and 4 on the web: each native encode holds a full bitmap in memory, and four at once is how you get an OOM on a mid-range Android device.
Estimate before you commit
const guess = await estimate(file, { mode: MAX(SIZE.KB[100]) });
if (guess.atRisk) {
// "Reaching 100 KB at 4032x3024 needs quality below 0.4…"
confirm(guess.note);
}How the size targeting works
Hitting a byte target usually means bisecting on quality: encode, compare, halve the interval, repeat — six to ten encodes, and on the web each one drags a fresh decode of the source with it.
This does two things differently.
Decode once, encode many. The web engine holds the decoded bitmap for the whole search, so a six-attempt search costs one decode and six encodes rather than six of each.
Solve, don't bisect. Encoded size is roughly pixels × bits-per-pixel(quality),
and the library ships a measured bits-per-pixel curve. The first encode is a
model-based guess; comparing what came back against what the model predicted
gives a contentFactor for this specific image, and the next guess is solved
against the corrected model. A flat graphic and a noisy photograph converge in
the same number of passes even though their sizes differ tenfold.
Across a 72-case simulation the search is run against encoder curves it has deliberately not been told about — the fake encoder uses a different exponent than the model — and it still never exceeds the limit and never drops below the quality floor, at an average of 2.2 encodes. On the real encoders it is faster than that: 1–2 passes for auto, 2–5 for a size limit.
One consequence worth stating, because it cost a real bug to learn: the first measurement is never taken below the quality the search intends to use. A demanding target clamps the model's opening guess to the quality floor, and calibrating at 0.40 to then plan at 0.55 means extrapolating along a curve whose shape varies by encoder. Measuring at the comfort quality instead turns that into "how big is this image at the quality I want?", which needs only the pixel-count relationship — and that one holds everywhere.
Shrink before you smear. When quality alone cannot reach the budget the library reduces the pixel count instead, because a small sharp image beats a full-size mushy one. The scale is solved once, from a real measurement, and then locked — letting it drift between attempts is what makes naive loops oscillate. If the locked size still does not fit, it re-solves for a smaller one rather than dropping below the quality floor. How far quality may fall before any of this kicks in depends on the mode: auto defends its preset, size-driven modes may spend down to 0.55 first.
Filling the budget is not the goal. When several attempts land under the target, the library keeps the best-looking one, not the biggest. 96 KB at quality 0.41 and 90 KB at quality 0.55 both "fit" a 100 KB budget; the last 6 KB of an allowance is worth nothing and the extra quality is worth a lot.
Video is arithmetic, not search. For a clip of known duration, output size is
very close to (video bitrate + audio bitrate) × duration. Solving that for the
bitrate lands inside the limit in one pass. If the required bitrate is too low to hold
the resolution together, the resolution steps down a ladder rather than the
picture falling apart — and on a tight budget the audio track is trimmed first,
since a quieter track is far less noticeable than a blocky picture.
What you actually get, per platform
The same call produces a different format — and so a different size — on each platform, because the encoders differ. This is one real 4352x3264 / 1.54 MB photograph (already a lean 0.91 bpp JPEG) run through the same modes on all three, measured on a booted iPhone simulator, an Android emulator and headless Chrome:
| mode | web (WebP) | Android (WebP) | iOS (JPEG) |
|---|---|---|---|
| auto | 445 KB | 478 KB | 1.14 MB |
| auto small | 143 KB | 144 KB | 441 KB |
| auto tiny | 49 KB | 50 KB | 147 KB |
| '<=450kb' | 425 KB | 448 KB | 421 KB |
| MAX(SIZE.KB[300]) | 280 KB | 291 KB | 288 KB |
| MAX(SIZE.KB[100]) | 99 KB | 98 KB | 97 KB |
And the same 1920x1080 / 20.5s / 10.5 MB clip:
| mode | web (real-time) | Android | iOS |
|---|---|---|---|
| auto | 2.65 MB | 4.17 MB | 3.21 MB |
| MAX(SIZE.MB[5]) | 3.68 MB | 4.97 MB | 4.87 MB |
| MAX(SIZE.MB[2]) | 1.38 MB | — | — |
Video works on all three, limits included. What differs is the cost: the two native engines finished in 3–27 seconds, the browser took 20.7 seconds for a 20.5 second clip, because it transcodes in real time.
Every limit is respected on every platform. Two things worth reading off those
tables. The web and Android columns agree closely despite being completely
separate implementations (canvas versus Bitmap.compress), which is a useful
cross-check on the search. And auto on iOS is more than twice the size of the
other two — not because it is worse, but because iOS cannot write WebP, and at
the same numeric quality WebP is far smaller. At an explicit limit all three
agree within a few percent; it is the quality at that size that differs. If
you want the smaller files on iOS too and you control the server, ask for
output: 'heic'.
estimate() is a prediction from a single metadata read, not a promise. On the
measurements above it lands within ~10% on the WebP platforms and ~45% low on
iOS JPEG, because one content-difficulty number cannot serve two encoders whose
quality scales differ that much. Use it to warn ("this may not fit"), not to
display a number you will be held to.
Platform notes
Web images — canvas. Prefers WebP (25–35% smaller than JPEG at matched quality), falling back to JPEG where it is unavailable. Support is detected by encoding a probe and checking the blob's own type, because browsers that cannot write a format silently hand back a PNG. Downscaling steps down by halves rather than in one jump, which keeps text and fine texture from aliasing.
Web video — MediaRecorder. This is a real-time transcoder: a 30-second clip
takes about 30 seconds, and you get a SLOW_ENGINE warning saying so. That is
the honest cost of transcoding video in a browser without shipping a
multi-megabyte WASM codec. It needs no dependencies, and one pass is normally
enough because bitrate maps almost linearly to output size. Output is MP4 where the browser can record it
(current Chrome, Edge and Safari 17+ all can), WebM otherwise. Trigger it from a
user interaction — browsers block the playback it needs otherwise.
One caveat worth knowing before you rely on it. Recording follows the wall clock, not the source's timeline, so on a machine that cannot decode and re-encode as fast as the clip plays, the output ends up longer than the input — and proportionally larger, which can blow past the target. The library reads the duration back off the finished file rather than assuming it, and tells you plainly when the two disagree:
This engine transcodes in real time and could not keep up: the output runs 6.9s against the source's 4.0s, which also made it 74% larger than planned.
If you hit this, pass maxAttempts: 2. The second pass now has a measured
duration to work from and corrects the bitrate — at the cost of transcoding
twice. Native engines do not have this problem: they encode faster than
playback, so they get the corrective pass by default.
The engine also skips reserving bitrate for audio when the source has none (screen recordings, exported timelapses), which is otherwise ~96 kbps of a tight budget spent on silence.
Android images — BitmapFactory with inSampleSize subsampling, writing
JPEG, PNG or WebP. EXIF rotation is baked into the pixels at decode, so the
picture comes out upright.
iOS camera-roll assets — a picker that hands you ph://<uuid>/L0/001
instead of a file path is supported, but it needs one thing from the host app:
an NSPhotoLibraryUsageDescription key in Info.plist. Without it, any call
into Photos terminates the process — TCC sends SIGABRT, it is not a catchable
error — so the library checks for the key before touching the photo library at
all and returns an explanatory failure instead of killing your app. Apps that
only ever pass file:// uris need nothing.
iOS images — ImageIO. Each attempt is a
CGImageSourceCreateThumbnailAtIndex, which subsamples during decode rather
than decoding full size and scaling afterwards. Writes JPEG, PNG or HEIC
(output: 'heic', roughly 45% smaller than JPEG — opt in, since plenty of
servers still reject it).
Note the format asymmetry: Android can write WebP and iOS cannot; iOS can write
HEIC and Android cannot. Both default to JPEG, which everything accepts. On the
web WebP is the default, so the same auto call legitimately produces a
different format — and a different size — per platform.
Video — Media3 Transformer on Android, AVAssetWriter on iOS. Both encode several times faster than playback, so the planner takes up to three passes to land inside the limit.
One Android quirk worth knowing about, because it cost real debugging: a
fragmented MP4 reports a duration of 0 from both MediaMetadataRetriever
and MediaExtractor — no error, the field simply is not there. Its moov is
only an initialisation segment, and unless the file carries an optional mehd
box the length exists solely as the sum of its moof fragments. Plenty of stock
and streaming-oriented footage is written this way. AVFoundation sums the
fragments and so does Media3's own extractor, but the platform metadata APIs do
not — and every size calculation starts from duration, so believing them means
MAX cannot work at all on those files. This library falls back to asking the
demuxer where its last sample sits, which works whatever the container.
All the mode logic, the search, the warnings and the result shape are the same TypeScript on every platform. Only the encode call differs.
Renames
Everything below still works; the old name is an alias, and the new one is what the docs use.
| was | now | why |
|---|---|---|
| FIXED(...) | MAX(...) | "Fixed" promised an exact size. It was always a ceiling. |
| DYNAMIC(...) | MAX(...) | Identical to FIXED at runtime — one behaviour, one name. |
| MODE.FIXED / MODE.DYNAMIC | MODE.MAX | Same. |
| AUTO.under(...) | AUTO.max(...) | Matches MAX, and is inclusive — 2 MB exactly is allowed. |
| options.maxSize | options.maxDimension | It means pixels. "Size" reads as bytes, which this library also takes. |
| meta.targetBytes | meta.limitBytes | Same vocabulary as the mode. |
| 'TARGET_MISSED' | 'LIMIT_EXCEEDED' | Says what happened: the output went over the limit. |
| 'TARGET_UNREACHABLE' | 'LIMIT_UNREACHABLE' | Same. |
The last two are the only ones without an alias — a string union member cannot
be aliased, so a switch on warningCode needs the new name.
Custom engines
import { registerImageEngine } from 'react-media-compressor';
registerImageEngine({
name: 'my-encoder',
formats: ['webp', 'jpeg'],
async probe(input, ctx) { /* … */ },
async open(input, ctx) {
return {
width, height, size, mime, hasAlpha,
async encode({ width, height, quality, format }) {
return { bytes, mime, width, height, data /* Blob | Uint8Array | uri */ };
},
close(keep) { /* clean up everything except `keep` */ },
};
},
});Higher priority wins; the built-in web engine sits at 0, the shipped adapters
at 15–20.
Full text reference
Everything above, plus every mode, option, warning code, recipe and
troubleshooting entry, is in USAGE.txt — plain text, no
markdown, shipped inside the package so it is available offline:
cat node_modules/react-media-compressor/USAGE.txtIt is written to be copied from: every block is a working snippet, and every number in it was measured on a device rather than estimated.
License
MIT
