I’m working on a side API and phone app built on Django Rest Framework (DRF) and React Native. The core feature involves analyzing full-resolution images with an AI pipeline, meaning I absolutely must keep the high-definition originals untouched.
But over in the React Native mobile app’s photo management section? Loading those massive raw files on a cellular network is a massive performance sin.
Why I Need Copies of the Same Image with Different Sizes
My solution was simple: I actually want 2 new images created every single time a user uploads 1.
- Uploaded Original Image: Safely tucked away. This will only be processed by the AI workers later and is never actually served directly to the mobile client.
- Small Thumbnail: Built specifically for the list view grid in the app where we render dozens of these uploaded photos simultaneously.
- Medium Thumbnail: Built for the “full details” view. Itโs optimized so the user can see crisp details and zoom in seamlessly inside the app, without pulling down the raw multi-megabyte original.
Taking a page out of the classic WordPress playbook, my plan was to hook directly into the database lifecycle right after DRF catches the upload. A lot of which was already done.
- โ
Client uploads image via POST
- Caught beautifully by a DRF
ViewSet@action
- Caught beautifully by a DRF
- โ
MyModel.objects.create()- Intercepted directly by the model’s
save()method
- Intercepted directly by the model’s
- โ
Open with Pillow
- Extracts the EXIF photo metadata (lat/long, date, camera type, etc.) and saves the pristine high-res file
- ๐
build_thumbnail(400, 400)- Generates and saves
image_small
- Generates and saves
- ๐
build_thumbnail(1000, 1000)- Generates and saves
image_medium
- Generates and saves
- ๐ Second
super().save()- Safely updates the thumbnail file fields in the database
Saving the Data
Originally, my Django data model was pretty vanilla, tracking just the raw upload path:
class ProductImage(BaseModel):
...
image = models.ImageField(upload_to='product_images/')
To support the architecture, I decided on some conservative, mobile-friendly dimensions for my thumbnails and added two new properties: image_small and image_medium. These explicitly define where our downscaled assets live.
class ProductImage(BaseModel):
SMALL_SIZE = (400, 400)
MEDIUM_SIZE = (1000, 1000)
...
image = models.ImageField(upload_to='product_images/')
image_small = models.ImageField(upload_to='product_images/small/', blank=True, null=True)
image_medium = models.ImageField(upload_to='product_images/medium/', blank=True, null=True)
Once the schema updates were ready, I ran the migrations locally and fired off my customized AI deploy agent to handle the production rollout. Production upgraded successfully without a hitch. Now, letโs fill in the meat of this logic!
Creating Thumbnails on Save
Over in my Django view, the upload handling was already executing a standard creation signature like this:
image = ProductImage.objects.create(
image=request.FILES['image'],
)
To keep things DRY (Don’t Repeat Yourself), I wrote an internal helper function on the data model class utilizing Pillow to process the image stream efficiently in memory using a binary buffer:
def _build_thumbnail(self, pil_img, size):
logger.info('_build_thumbnail size=%s, pil_img=%s', size, pil_img)
thumb = pil_img.copy()
thumb.thumbnail(size, PilImage.LANCZOS)
logger.info('_build_thumbnail after resize: %s', thumb)
buffer = BytesIO()
thumb.save(buffer, format='JPEG', quality=85)
content = ContentFile(buffer.getvalue())
logger.info('_build_thumbnail content size=%d bytes', content.size)
return content
Now for the final magic trick: overriding Django’s native save() function. We check if the instance is brand new (self._state.adding), handle our EXIF extras, commit the original to generate a primary key (pk), spin up our two variants, and then selectively update the database fields so we don’t trigger infinite save loops.
def save(self, *args, **kwargs):
is_new = self._state.adding
if is_new and self.image:
pil_img = PilImage.open(self.image).convert('RGB')
self.metadata = extract_exif_metadata(pil_img)
self.image.seek(0)
super().save(*args, **kwargs) # save original first
small = self._build_thumbnail(pil_img, self.SMALL_SIZE)
self.image_small.save(f'{self.pk}_small.jpg', small, save=False)
medium = self._build_thumbnail(pil_img, self.MEDIUM_SIZE)
self.image_medium.save(f'{self.pk}_medium.jpg', medium, save=False)
super().save(update_fields=['image_small', 'image_medium'])
else:
super().save(*args, **kwargs)
The Result

Original = 1.1 MB
Medium = 119 KB
Small = 29 KB
Voila! The mobile app’s list view went from chugging through heavy raw image arrays to parsing tiny, optimized web assets. We are talking about loading times crashing down from painful minutes down to less than a single second.
If you are building any modern client-server app that deals with heavy user-generated content, optimizing your asset delivery pipeline isn’t a luxury itemโitโs absolutely necessary.