I Refused to Pay $10/mo for an Upscaler, So I Built One Into the Browser
The full write-up: multi-step Canvas interpolation, the per-tile pixel math for Instagram grids, three real mobile crashes I had to fix, and an honest account of what interpolation can't do.
The photo that started this feature was a 1600×1600 crop of a much larger shot. I wanted to split it into a 3×3 grid for Instagram, and the math was brutal: 1600 divided by 3 is 533 pixels per tile. Instagram displays square posts at 1080 pixels. Every tile would have been upscaled by Instagram's own compressor — the exact "why does my grid look soft?" effect this whole site exists to prevent.
I searched for an upscaler. Every tool I tried was either behind a $9.99/month paywall, put me in a queue behind other users, or stamped a watermark across the result. For what is, at its core, a resampling operation my laptop can do in about a second.
So I built the upscaler into 9grid's grid splitter itself. This post is the honest write-up: how it works, the three ways it crashed phones before I got it right, and — just as important — what interpolation genuinely cannot do.
The pixel math for Instagram grids
Before upscaling anything, know your target. 9grid exports each grid tile at 1080 pixels or larger, so the question is whether your source image has enough pixels to feed every tile:
| Layout | Source needed for 1080px tiles | A 1600px-wide photo gives you |
|---|---|---|
| 1×2 carousel | 2160px wide | 800px per slide — needs ~1.4× |
| 1×3 carousel | 3240px wide | 533px per slide — needs ~2× |
| 3×3 profile grid | 3240×3240 | 533px per tile — needs ~2× |
| 3×4 puzzle feed | 3240×4320 | 533px per tile — needs ~2× or more |
Notice that almost every realistic rescue case lands at 1.5–2×. That constraint shaped the whole design: I optimized for clean 2× upscales, not for heroic 8× reconstructions.
How it actually works: many small steps, not one big jump
When you click Upscalein 9grid, the image never leaves the page. The resize runs on the browser's Canvas API — but not as a single drawImage call to the final size. A single big jump with bilinear filtering produces visibly mushy edges. Instead, 9grid scales in steps of at most 1.5× each, repeatedly, until it reaches the target.
Two details make the output noticeably better than a naive resize. First, every step runs with the browser's imageSmoothingQuality: "high"hint, which on Chromium engages a better resampling kernel. Second, stacking several small interpolations approximates the result of a proper Lanczos filter far more closely than one large step — the same trick Photoshop users applied for years with "resize in 10% increments." A 4× upscale becomes five 1.32× hops rather than one leap.
Between hops, the work yields back to the browser with requestAnimationFrame, so the page stays responsive instead of freezing while a 20-megapixel canvas redraws. And if the requested upscale would enlarge the image by less than 5%, 9grid just returns the original — resampling for nothing only degrades pixels.
The three ways it crashed phones first
The desktop version worked on day one. Mobile Safari then spent a week teaching me humility. All three fixes are still visible in the code today:
1. The iOS canvas ceiling
iOS Safari silently fails on canvases above roughly 16.7 megapixels — you get a black image, no error. A 4× upscale of a 12MP phone photo blows way past that. 9grid now clamps output to 12MP on mobile (25MP on desktop) and tells you the actual size it produced.
2. The toDataURL freeze
My first export used canvas.toDataURL() — a synchronous call that builds a base64 string. On a large canvas that string is tens of megabytes on the main thread; mid-range phones simply froze. Switching to the async toBlob() fixed it completely.
3. Death by garbage collection
Allocating a fresh canvas for every 1.5× step piled up hundreds of megabytes for the garbage collector. Now two canvases alternate as source and destination across all steps — memory stays flat no matter how many hops the upscale takes.
What interpolation honestly can't do
This is the part most upscaler landing pages skip. Canvas interpolation redistributes the pixels you have; it does not invent detail you never captured. A 500px face upscaled 4× will be a smooth, artifact-free 2000px face — not a sharp one. If you need hallucinated detail (real "enhance"), that requires a machine-learning model, which means either multi-megabyte model downloads that crash the same mid-range phones I just rescued, or server-side processing with the privacy and cost trade-offs that come with it.
For the Instagram grid use case, that trade-off almost always favors interpolation:
- Your gap is small. As the table above shows, real grid workflows need 1.4–2×, the range where high-quality interpolation is nearly indistinguishable from ML output on photos.
- Instagram recompresses everything anyway. Fine hallucinated detail is the first thing its compressor destroys. What survives is clean edges and the absence of upscaling blur — exactly what interpolation provides.
- It runs everywhere, instantly.No model download, no queue, no upload. The photo stays in your browser's memory for the entire operation.
My rule of thumb: if your source is at least half the target size, upscale locally and post. If you are trying to turn a 300px thumbnail into a poster, no free tool — mine included — will genuinely get you there.
The workflow, start to finish
Upload your photo to the grid splitter, pick your layout, and check the size warning: if any tile would fall below 1080px, 9grid recommends an upscale factor (2× when the gap is small, 4× when it is not). Click Upscale, watch the progress counter, then split. The whole detour adds about five seconds on a typical laptop.
Try the Browser Upscaler
No credit card, no sign-up, no watermarks — and your photo never leaves the page.
Open 9grid App