Hey there, mobile dev π Ever noticed how some apps load images instantly, while others leave you staring at a spinner or some other loading animation or worse just a blank screen? Yep image compression is often the unsung hero.
Big, unoptimised images can slow down your mobile app, eat up user data, and even make your app feel clunky. But don't worry, in this guide, we'll learn how to shrink those images down to size using Expo's powerful tools, making your app smoother and more professional.
Let's dive in!
Why Bother With Compression in the First Place?
Imagine your app is a fancy restaurant. If every dish is huge and takes ages to serve, customers will get impatient and leave! Similarly, if your app tries to load massive image files:
Slower Loading: Your users will stare at blank screens longer.
More Data Usage: Especially bad for users on limited data plans.
Increased Storage: Bigger app bundles.
Poor SEO (this one is for web apps): Search engines prefer fast-loading websites.
By compressing images, we make them "lighter," so they load faster and provide a better experience for everyone.
Our Goal: The 100KB Sweet Spot
For many mobile and web images, you really want to hit that sweet spot of 100 -200kb. This is often small enough to load quickly without sacrificing too much visual quality. In this guide we'll also be converting our images to WebP format, which is like a magic trick for better compression and itβs supported on most modern devices (Android, and iOS from version 14 and up).
The Tool: expo-image-manipulator
Since we did put expo in our title, it should be no surprise that our tool of choice is expo-image-manipulator, this tool lets us resize, crop, rotate, and compress images right on the user's device. No need to send images to a server just to shrink them!
Step 1: Install the Manipulator
First things first, let's get the tool:
npx expo install expo-image-manipulator
Step 2: The Core Idea - Iterative Compression
Instead of just trying to compress once and hoping for the best, we'll use an "iterative" approach. Think of it like a sculptor: they don't just whack off a huge chunk of marble; they chip away gradually until they get the perfect shape.
Our compression will:
- Try to compress the image.
- Check its size.
- If it's still too big, try again with slightly lower quality or smaller dimensions.
- Repeat until it's small enough or we decide it's "good enough."
Hereβs a look at the logic we'll use:
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator';
import * as FileSystem from 'expo-file-system';
/**
* Compress image for SEO
* Target: 100KB max size, iterative compression with quality and dimension reduction
*/
export const compressImageWithExpo = async (
imageUri: string, // The local URI of the image (e.g., from ImagePicker)
fileName: string // The original file name (e.g., "my-photo.jpg")
): Promise<string | null> => {
try {
const targetMaxSizeKB = 100 * 1024; // 100KB in bytes
let currentQuality = 0.85; // Starting compression quality
const reductionFactor = 0.9; // How much to reduce dimensions each time
const maxIterations = 7; // Safety limit to prevent endless loops
// 1. Get original image dimensions
const imageInfo = await ImageManipulator.manipulate(imageUri, [], {
compress: 1, // We just want info, not to compress yet
format: SaveFormat.PNG // Format doesn't matter for info
});
// Default to common sizes if info is missing
let currentWidth = imageInfo.width || 1920;
let currentHeight = imageInfo.height || 1080;
// Note: ImageManipulator.manipulate() automatically
// closes the file handle after running so we dont have to worry
// about memory leaks
// 2. Check original file size to decide if we need aggressive reduction
let originalSize = 0;
try {
const info = await FileSystem.getInfoAsync(imageUri);
if (info.exists) {
originalSize = info.size || 0;
}
} catch (e) {
console.warn("Could not get original file info:", e);
}
const needsAggressiveReduction = originalSize > 2 * 1024 * 1024; // > 2MB
// This is our recursive function that keeps compressing
const compressImageIteratively = async (
width: number,
height: number,
quality: number,
iteration: number = 0
): Promise<string> => {
// Safety check: stop if we've tried too many times
if (iteration >= maxIterations) {
console.warn('Max compression iterations reached. Returning last best attempt.');
// Even if over limit, return the last generated URI
const finalResult = await ImageManipulator.manipulate(imageUri, [
{ resize: { width: width, height: height } }
], {
compress: quality,
format: SaveFormat.WEBP,
});
return finalResult.uri;
}
// Try compressing with current settings
const compressedResult = await ImageManipulator.manipulate(imageUri, [
{ resize: { width: width, height: height } } // Resize first
], {
compress: quality, // Then compress
format: SaveFormat.WEBP, // And convert to WebP
});
// Get the size of the newly compressed image
const fileInfo = await FileSystem.getInfoAsync(compressedResult.uri);
const fileSize = fileInfo.exists ? (fileInfo.size || 0) : 0;
// Check if we hit our target size
if (fileSize <= targetMaxSizeKB) {
console.log(`Image compressed to ${Math.round(fileSize / 1024)}KB`);
return compressedResult.uri; // Success!
} else if (width > 300 && height > 300 && quality > 0.4) {
// Still too big, AND we have room to shrink further
const widthReduction = needsAggressiveReduction && iteration === 0 ? 0.5 : reductionFactor;
const heightReduction = needsAggressiveReduction && iteration === 0 ? 0.5 : reductionFactor;
console.log(`Still too big (${Math.round(fileSize / 1024)}KB). Reducing dimensions and quality...`);
// Call ourselves again with smaller dimensions and lower quality
return compressImageIteratively(
Math.floor(width * widthReduction),
Math.floor(height * heightReduction),
quality - 0.05, // Reduce quality slightly
iteration + 1
);
} else {
// We can't shrink it any further without making it tiny or ugly
console.warn('Could not reach target size, returning best possible compression.');
return compressedResult.uri;
}
};
// Start the iterative compression process
return await compressImageIteratively(currentWidth, currentHeight, currentQuality, 0);
} catch (error) {
console.error("Unable to compress image : ", error);
return null; // Something went wrong
}
};
Code Breakdown for Beginners:
-
targetMaxSizeKB: Our desired limit (100KB). -
currentQuality: Starts high (0.85 means 85% quality) and goes down. -
reductionFactor: Each time we retry, we multiply the width/height by 0.9 (making it 90% of its previous size). -
maxIterations: If our loop runs 7 times and the image is still too big, we just stop and use the best result we got. This prevents endless loops! -
FileSystem.getInfoAsync(uri): This is how we check the size of a file on the device. Super important! -
ImageManipulator.manipulate(imageUri, actions, options): This is the core function.-
imageUri: The path to the image on the device, this will come from the image picker you're using (egexpo-image-picker) -
actions: An array of things to do (like { resize: { width: ..., height: ... } }). -
options: How to save it (like { compress: ..., format: SaveFormat.WEBP }).
-
SaveFormat.WEBP: This tells expo-image-manipulator to save the image as a WebP file, which is usually smaller than JPG or PNG for the same quality.
recursive function (compressImageIteratively): This function calls itself if the image is still too big, but with slightly smaller dimensions and lower quality. It's like saying, "Okay, that didn't work. Let's try again, but be a bit more aggressive this time!"
How to Use It in Your App
You'd typically use compressImageWithExpo after a user picks an image using expo-image-picker.
Here's a simplified example:
import React, { useState } from 'react';
import { View, Button, Image, ActivityIndicator, Text, Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { compressImageWithExpo } from './image-utils'; // Assuming your compression code is in image-utils.ts
export default function App() {
const [pickedImageUri, setPickedImageUri] = useState<string | null>(null);
const [compressedImageUri, setCompressedImageUri] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const pickImage = async () => {
// Request camera roll permissions
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Sorry, we need camera roll permissions to make this work!');
return;
}
let result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, // You can allow editing if needed
aspect: [4, 3],
quality: 1, // Get the highest quality original
});
if (!result.canceled) {
const uri = result.assets[0].uri;
setPickedImageUri(uri);
setCompressedImageUri(null); // Clear previous compressed image
await handleCompressImage(uri);
}
};
const handleCompressImage = async (uri: string) => {
setLoading(true);
try {
// Extract a simple filename (e.g., "photo.jpg")
const filename = uri.split('/').pop() || 'image.jpg';
const compressedUri = await compressImageWithExpo(uri, filename);
if (compressedUri) {
setCompressedImageUri(compressedUri);
Alert.alert('Success!', 'Image compressed to WebP and saved locally.');
} else {
Alert.alert('Error', 'Image compression failed.');
}
} catch (error) {
console.error('Compression process failed:', error);
Alert.alert('Error', 'An error occurred during compression.');
} finally {
setLoading(false);
}
};
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }}>
<Button title="Pick an image from camera roll" onPress={pickImage} />
{pickedImageUri && (
<View style={{ marginTop: 20 }}>
<Text style={{ fontWeight: 'bold' }}>Original Image:</Text>
<Image source={{ uri: pickedImageUri }} style={{ width: 200, height: 150, marginTop: 10, borderWidth: 1, borderColor: 'gray' }} />
<Text style={{ fontSize: 12, color: 'gray' }}>{pickedImageUri.split('/').pop()}</Text>
</View>
)}
{loading && (
<View style={{ marginTop: 20 }}>
<ActivityIndicator size="large" color="#0000ff" />
<Text>Compressing image...</Text>
</View>
)}
{compressedImageUri && (
<View style={{ marginTop: 20 }}>
<Text style={{ fontWeight: 'bold' }}>Compressed WebP Image:</Text>
<Image source={{ uri: compressedImageUri }} style={{ width: 200, height: 150, marginTop: 10, borderWidth: 1, borderColor: 'green' }} />
{/* You can add a button here to upload this compressed image */}
<Text style={{ fontSize: 12, color: 'green' }}>{compressedImageUri.split('/').pop()}</Text>
</View>
)}
</View>
);
}
Beyond Compression: Uploading to the Cloud
Once your image is perfectly compressed, you'll usually want to upload it to a cloud storage service like Amazon S3, Google Cloud Storage, or similar.
Wrapping Up
And thats it! You've taken your first steps into the world of image optimisation in Expo. By using expo-image-manipulator and an iterative compression strategy, you can dramatically improve your app's performance and give your users a much snappier experience.
Keep experimenting, and happy coding!
Top comments (0)