DEV Community

Nabeel Hassan
Nabeel Hassan

Posted on

My Habit App's Icons Were 6860 Pixels Wide. They Render at 40.

On May 12, 2025 I made a commit called "added tasks iange". Typo included. It swapped three placeholder icons in Lifemaxxing AI, the habit app I co-founded with Benjamin Siegel, for the real artwork: fourteen task images for things like pushups, journaling, meditation and bedtime.

The placeholders were about 35 to 46 KB each. The new pushups.png was 22.6 MB. workout.png was 28.6 MB. Across the fourteen, every single file landed somewhere between about 15 and 29 MB.

In the app, the task card draws that image like this:

Image.asset(
  'assets/images/tasks/${taskConfig.imagePath}',
  width: 40,
  height: 40,
  color: isCompleted ? Colors.grey : null,
),
Enter fullscreen mode Exit fullscreen mode

Forty by forty. The schedule screen is more generous: a 50 by 50 circle. I was shipping poster-sized files to fill a space the size of a fingertip.

This is the story of how I fixed that, then made the exact same mistake two months later, and what I would put in place so there is no third time.

The first fix only fixed half of it

A month later, on June 11, I was getting the app ready for a TestFlight build and the size finally got my attention. The assets/images folder was about 363 MB. Almost all of it, around 325 MB, was the task icons.

The first thing I reached for was compression, which is the instinct most of us have. A script ran every PNG through pngquant with a quality range of 65 to 85, then through optipng for a lossless pass on top. For the onboarding screens this was perfect: Onboarding Screen 1.png went from 2.4 MB to 647 KB and looked identical.

For the task icons, the log told a different story:

Optimized PNG: assets/images/tasks/study.png
  Original: 25873.49KB, Optimized: 7401.47KB, Reduction: 71.4%
Optimized PNG: assets/images/tasks/journal.png
  Original: 19760.72KB, Optimized: 9248.02KB, Reduction: 53.2%
Enter fullscreen mode Exit fullscreen mode

Seventy percent sounds like a win until you look at the result. A 7.4 MB icon is still a 7.4 MB icon. Compression was squeezing the bytes that described each pixel, and there were simply far too many pixels.

So a second, task-specific script did the thing that actually mattered. It capped the longest side at 1000 pixels before saving:

width, height = img.size
if width > 1000 or height > 1000:
    if width > height:
        new_width = 1000
        new_height = int(height * (1000 / width))
    else:
        new_height = 1000
        new_width = int(width * (1000 / height))
    img = img.resize((new_width, new_height), Image.LANCZOS)
Enter fullscreen mode Exit fullscreen mode

And the log changed character completely:

Optimized task image: assets/images/tasks/pushups.png
  Original: 21.59MB, Optimized: 0.57MB, Reduction: 97.4%
Enter fullscreen mode Exit fullscreen mode

Across all assets, that June pass took roughly 363 MB down to roughly 17 MB.

The lesson I thought I had learned: when an image is too big, ask how many pixels it has before you ask how well it is compressed. Compression works on the size of each pixel. Resizing works on the count. When the count is off by a factor of fifty, no codec saves you.

Then I did it again

On July 14 we added a new category of tasks, the "quit" habits: no alcohol, no gaming, no vaping and two more, plus a redrawn skill.png. The five new ones came in at 24 to 30 MB each, and the resize log later recorded their true dimensions: 6860 by 5000 pixels.

Here is the part I find genuinely funny in hindsight. At 20:44 that evening I committed "decrease size". It was the compression pass again. NoAlcohol.png went from 24.2 MB to 8.2 MB, NoGaming.png from 30.2 MB to 12.2 MB. The same kind of result I had already seen in June and already knew was not enough.

Twenty minutes later, at 21:04, came the commit called "done": each of those six images resized to 1000 by 728 and saved at 143 to 370 KB. The resize run took 1.07 seconds.

So I had the right tool sitting in the repo the whole time. I just reached for the familiar one first, again. A lesson that lives only in your head does not survive contact with a new batch of assets and a deadline. It has to live somewhere that runs.

Git remembers every byte

Shrinking the files fixed the app bundle. It did nothing for the repository.

Every version of every one of those images is still in history: the 20+ MB originals from May, the 8 to 12 MB half-measures from July, and the final small ones. The optimization workflow also copied the originals into an assets_backup folder, and that folder got committed too. Today the .git directory for this project is 706 MB, for an app whose shipped image assets are around 25 MB.

Nobody downloading the app pays for that. Anyone cloning the repo does, every time, along with any CI job that does a full checkout. If I were starting again, giant source art would never enter the app repo at all. It would live in a design folder or object storage, and only the export at the size the app needs would be committed. Rewriting history to purge it now is possible, but it is the kind of chore that only gets more annoying the longer you wait.

1000 pixels is still too many

I want to be honest that the "fixed" state is not optimal either.

A 40 point icon on a 3x iPhone screen needs 120 physical pixels. The 50 point circle needs 150. The images are 1000 pixels wide, so they are still roughly seven or eight times larger than the biggest place they are drawn.

That matters for more than file size. Flutter's Image.asset decodes the full image unless you pass cacheWidth or cacheHeight, and I checked: nothing in the app does. A 1000 by 728 PNG decodes to about 2.9 MB of RGBA in memory. The original 6860 by 5000 would have decoded to roughly 137 MB, for one icon. Download size is the cost you see in App Store Connect. Decoded size is the cost your users' phones pay quietly.

The properly boring fix is the one Flutter already supports: resolution-aware assets. Export a 1x, 2x and 3x variant sized for the actual widget, put them in 2.0x/ and 3.0x/ folders, and let the framework pick. For a 40 point icon that is files of 40, 80 and 120 pixels. Where a single image is reused at very different sizes, cacheWidth on the widget gets you most of the memory win without new exports.

What I would put in place so there is no third time

The real failure was not one bad export. It was that nothing in the workflow could notice. A designer hands over a 28 MB PNG, it looks great, it renders fine on a fast phone, and it goes in. The cheapest protection is a check that runs before the commit lands. Something like this, which I did not have and should have:

# illustrative: fail the build if any bundled image is oversized
import os, sys, glob
from PIL import Image

MAX_SIDE, MAX_BYTES = 512, 500 * 1024
bad = []
for path in glob.glob("assets/images/**/*.png", recursive=True):
    with Image.open(path) as img:
        w, h = img.size
    size = os.path.getsize(path)
    if max(w, h) > MAX_SIDE or size > MAX_BYTES:
        bad.append(f"{path}: {w}x{h}, {size // 1024} KB")
if bad:
    print("Oversized assets:\n" + "\n".join(bad))
    sys.exit(1)
Enter fullscreen mode Exit fullscreen mode

Pick limits that match your largest real usage, and allow an explicit exception list for the onboarding hero images that genuinely fill the screen. The point is not the numbers. The point is that the next time a 6860 pixel icon shows up, a machine says no before I have to remember anything.

The broader takeaways, for any mobile app with designer-supplied art:

  • Compare the asset to the widget, not to the old asset. "It went from 25 MB to 7 MB" felt like progress. "It is 7 MB for a 40 point square" is the sentence that should have stopped me.
  • Resize first, compress second. Pixel count is where the order-of-magnitude wins are. Compression is the polish.
  • Keep source art out of the app repo. History is forever, and every clone pays for it.
  • Make the rule executable. I learned this lesson in June and forgot it in July. Scripts do not forget.

Lifemaxxing AI is at lifemaxxingai.com if you want to see where those icons ended up. And I am curious: what is the biggest asset you have found shipping inside an app bundle that had no business being there?

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.