Storyie lets users attach photos and screenshots to diary entries — on the web and on iOS and Android. That means one image upload pipeline has to work across two runtimes with different file APIs, different compression tools, and different image pickers.
This post covers how we built that pipeline on Cloudflare R2, why we chose not to share the implementation code across platforms, and what we learned running it.
TL;DR
- R2 has no egress fees and speaks the S3 API — a straightforward choice for image storage in an app where reads far outnumber writes.
- Web and mobile share the same architecture and naming conventions, but not the implementation: file selection and compression differ enough that a shared package would hurt more than it helps.
- Images are compressed in stages — pass through, resize, then reduce quality only if necessary — so most photos never get re-encoded at low quality.
XMLHttpRequest, notfetch, because upload progress is still not somethingfetchcan report on either platform.- Errors carry a typed code plus a retryable flag, which turns out to be the single most useful thing in the service layer — even though the two platforms drifted on what to name it.
Aspect | Web (Next.js) | Mobile (Expo) |
|---|---|---|
File selection |
|
|
Compression | Canvas API ( |
|
File reading |
|
|
Upload progress |
|
|
Why Cloudflare R2
Two things drove the decision: cost and API compatibility.
R2 has no egress charge. S3 does. For a diary app, images are viewed far more often than they are written — users scroll through months of entries, each with photos. With S3, that traffic translates directly into a bill that scales with engagement. With R2, it does not. For a small team running a consumer app, that predictability matters.
The S3-compatible API means @aws-sdk/client-s3 works without modification. No new SDK to learn, no bespoke client to maintain. Public delivery runs through images.storyie.com, a CNAME in front of the bucket.
The upload path
Both platforms follow the same four steps:
- Validate the file type against an allowlist (
image/jpeg,image/png,image/webp). - Compress and resize locally if the image exceeds the target size or dimensions.
PUTthe result to R2 using a short-lived presigned URL, with progress reported to the UI.- Embed the resulting public URL in the Lexical editor JSON that gets stored with the entry.
Deletion is a separate flow. When a diary entry is removed, the images embedded in it need to go too, so a server endpoint extracts the image URLs from the Lexical JSON and calls R2's DeleteObjects API. That endpoint checks that the caller actually owns each key before deleting it, and DeleteObjects accepts up to 1,000 keys per request, so even entries with many images are a single call.
Same architecture, different implementations
When we started, the temptation was to put the upload logic in a shared package and import it from both apps. We decided against it.
What is genuinely shared
The two upload services align on everything that doesn't touch platform APIs:
- The same R2 bucket and the same key naming scheme.
- The same upload mechanism: a presigned
PUT, sent viaXMLHttpRequestwith aprogressevent listener. - The same error model: a custom error class carrying a machine-readable code and a retryable boolean.
- The same staged compression behavior, tuned to the same thresholds.
What differs
File selection and compression differ at the platform level. On web, a File from an <input type="file"> goes through createImageBitmap and canvas.toBlob. On mobile, an asset from expo-image-picker goes through expo-image-manipulator. Trying to abstract over that produces a leaky interface that is harder to navigate than two parallel, readable implementations.
What we did instead: align the method signatures and the shape of the error types between the two services. When you need to change the upload logic, the web implementation is a clear template for what to do on mobile, and vice versa.
The honest caveat is that symmetry maintained by convention does drift. The two error classes ended up as ImageUploadError with an isRetryable field on web and ImageError with a retryable field on mobile — same design, different names, because nothing forced them to match. It costs a second of re-orientation every time you switch sides. That is the price of not sharing the module, and so far we consider it cheaper than the abstraction would have been.
Compression strategy
A photo from a modern phone is typically 3–10 MB. Storing that uncompressed inflates storage costs and slows down the feed. Our compression runs in four stages, stopping as soon as the result meets the target:
- Pass through — if the image is already under 2 MB and within 2048×2048 px, use it as-is.
- Resize — if either dimension exceeds 2048 px, scale down while preserving aspect ratio.
- Quality reduction — if the file still exceeds 2 MB after resizing, reduce JPEG quality starting at 0.8 and stepping down by 0.1 until it fits.
- Resolution fallback — if quality hits the floor and the file is still too large, scale the resolution down further and try again.
// apps/web/services/editor/imageProcessor.ts (excerpt)
let quality = imageConfig.compressionQuality;
let blob = await canvasToBlob(canvas, outputFormat, quality);
while (blob.size > maxSizeBytes && quality > 0.1) {
quality -= 0.1;
blob = await canvasToBlob(canvas, outputFormat, quality);
}
// If still too large after minimum quality, reduce dimensions further
if (blob.size > maxSizeBytes) {
const reductionRatio = Math.sqrt(maxSizeBytes / blob.size);
// ...
}Most photos land in stage 1 or 2. Stage 3 is uncommon. Stage 4 is rare.
One detail worth calling out: PNGs stay PNGs and everything else becomes JPEG. The rule is "JPEG for photos, PNG for graphics" — a one-line branch on the input MIME type, but it keeps the pipeline from re-encoding line art and screenshots into a format that handles them badly.
Per-plan image limits
Storyie's free plan allows one image per diary entry; the Pro plan allows ten. The limits are constants in the shared @storyie/subscription package:
// packages/subscription/src/constants/limits.ts (excerpt)
maxImagesPerEntry: 1, // FREE_PLAN_LIMITS
maxImagesPerEntry: 10, // PRO_PLAN_LIMITSBoth platforms enforce the limit before the image picker opens. If the entry is already at the limit, the action is blocked immediately — the user sees "you cannot add more" rather than "your upload was rejected." The server checks the same limit as a backstop, but the client check is what shapes the experience.
On web, the editor component resolves the limit from the current plan and passes it down. On mobile, imageLimitService runs the same logic before calling the image picker. Both read from the same constants, so the two numbers cannot drift apart.
What we noticed running it
Key design is hard to change later. Image keys carry a year/month prefix. Because image URLs are embedded in Lexical editor JSON stored in the database, changing the key format would break every image in every existing entry — there is no cheap migration. The date prefix was a deliberate choice: it makes it easy to configure lifecycle policies that expire old objects later without having to touch the data. Pick this before you ship, because you only get to pick once.
fetch still cannot track upload progress. Request-side ReadableStream support is limited enough that it doesn't work reliably across browsers, and it isn't available in the React Native runtime at all. We use XMLHttpRequest on both platforms. The progress event is straightforward and the behavior is consistent. As of mid-2026, nothing has changed to make fetch viable here.
The retryable flag on errors pays for itself. Tagging errors with whether a retry makes sense felt like over-engineering at first. In practice, it makes the UI branching trivial: network errors get a retry button, format or size errors do not. The classification lives in the service layer, not scattered across UI components — which means the web and mobile UIs make the same decision without coordinating.
Related Posts
- Cross-platform Lexical with
use dom: monorepo gains and the bridges you still own — how the image node fits into the broader editor architecture - Building a Monorepo with pnpm and TypeScript — how we structure shared and platform-specific code across the workspace
- Building a Cross-Platform Mobile App with Expo — the broader Expo context that the upload flow runs inside
Try Storyie
Attach a photo to a diary on storyie.com and it shows up immediately on the iOS app. The pipeline described here is what makes that work.