DEV Community

Robin J
Robin J

Posted on

What I Learned Building a Simple Photo Downloader

I started this project because the idea looked almost too simple.

Paste a post URL, find the photo, download it.

The first version worked pretty quickly.

Then I started testing it with more real links.

That was when the project stopped being a small frontend exercise and turned into something much more about input handling, upstream failures, media parsing, and keeping the whole thing from falling apart when the source platform behaves differently than expected.

The download button turned out to be the easy part.

Real URLs are messy

The clean version looks like this:

paste URL
   ↓
resolve post
   ↓
find media
   ↓
download
Enter fullscreen mode Exit fullscreen mode

That works fine in a demo.

Real users paste URLs with tracking parameters.

Some have spaces around them.

Some come from redirects.

Sometimes somebody pastes a whole sentence with the link buried inside it.

Sometimes the URL is perfectly valid but the post itself is unavailable.

So I ended up moving quite a bit of logic in front of the resolver.

More like:

raw input
   ↓
trim
   ↓
parse
   ↓
validate host
   ↓
detect URL type
   ↓
extract identifier
   ↓
resolver
Enter fullscreen mode Exit fullscreen mode

None of this is clever.

It is just the kind of boring work that prevents much more annoying problems later.

I stopped treating every error the same

My early version had the usual error handling:

Something went wrong.
Enter fullscreen mode Exit fullscreen mode

That is fine until something actually goes wrong.

A bad URL is not the same problem as a private post.

A network failure is not the same as an upstream rate limit.

A resolver that suddenly stops finding media is not the same as a user typo.

Internally, I started separating failures into things like:

INVALID_URL
PRIVATE_POST
POST_NOT_FOUND
UPSTREAM_RATE_LIMIT
UPSTREAM_BLOCKED
MEDIA_NOT_FOUND
NETWORK_ERROR
Enter fullscreen mode Exit fullscreen mode

The user never needs to see those exact labels.

But I do.

If one error type suddenly spikes, it gives me a much better starting point than a pile of generic failures.

That turned error handling into a debugging tool instead of just a UI message.

Multi-image posts changed the way I modeled data

The first version assumed one post meant one image.

That was a mistake.

Once multi-image posts showed up, I had to stop thinking in terms of:

{
  image: "https://..."
}
Enter fullscreen mode Exit fullscreen mode

and move toward:

{
  media: [
    {
      type: "IMAGE",
      url: "https://..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

It is a small difference, but it matters.

The moment one post can contain several items, the whole one-image assumption starts leaking into the rest of the code.

I would rather fix that in the data model than keep adding special cases everywhere else.

A media URL is not automatically a good result

One thing I did not think much about at first was verification.

If the resolver gives you a media URL, it is tempting to assume the job is done.

Not always.

The resource may already be unavailable.

It may expire.

It may return something unexpected.

So I now treat media verification as a separate step.

resolve post
   ↓
get media URL
   ↓
check resource
   ↓
return result
Enter fullscreen mode Exit fullscreen mode

I would rather fail before showing the user a result than give them a button that points nowhere.

I split the website from the resolver

This was probably the most useful architecture change.

At first, everything lived together.

The site handled the page, the request, and the upstream resolution.

That worked, but it also meant the most unstable part of the system was tightly connected to everything else.

I eventually separated them.

Conceptually:

browser
   ↓
web / edge layer
   ↓
resolver service
   ↓
upstream platform
Enter fullscreen mode Exit fullscreen mode

The frontend does not need to know how media resolution works.

It just needs a result.

That means the resolver can change independently.

It also means rate limiting can happen before the expensive part.

And if the upstream platform changes something tomorrow, I do not want that change spreading through the rest of the application.

The unstable part should stay isolated.

I stopped thinking of the resolver as permanent

At first, I thought I was building “the parser.”

Now I think of it as one implementation of a simple interface.

Something like:

resolve(postUrl)
Enter fullscreen mode Exit fullscreen mode

The rest of the application should care about the returned media, not about how it was found.

That gives me room to change the internals later without rewriting everything around it.

Maybe one resolver path is enough.

Maybe later there is a fallback.

primary resolver
      ↓
    fails
      ↓
fallback
      ↓
structured error
Enter fullscreen mode Exit fullscreen mode

I am not building a complicated fallback system just for the sake of having one.

But I do want the architecture to leave the door open.

Keeping a few test URLs helped more than I expected

I keep a small set of representative test cases.

Nothing fancy.

A normal photo.

A portrait photo.

A landscape photo.

A small multi-image post.

A larger one.

An unavailable post.

A malformed URL.

Whenever I touch the resolver, I run the same cases again.

I want to know:

did it resolve?
how many media items came back?
was the media type correct?
does the resource still respond?
Enter fullscreen mode Exit fullscreen mode

This has been much more useful than waiting for somebody to say the site is broken.

If several known-good cases suddenly fail, I know the problem is probably upstream.

That narrows things down quickly.

Rate limiting belongs before the expensive work

Any public endpoint eventually attracts automated traffic.

That is just part of putting something on the internet.

I prefer to deal with that near the edge.

internet
   ↓
edge / rate limit
   ↓
application
   ↓
resolver
   ↓
upstream
Enter fullscreen mode Exit fullscreen mode

There is no point letting obviously abusive traffic travel all the way to the most fragile part of the system.

Especially when that part depends on somebody else's infrastructure.

The frontend got simpler

The backend became more complicated over time.

The frontend went the other direction.

For this kind of tool, I keep coming back to the same flow:

  1. Paste a URL.
  2. Click.
  3. See the result.
  4. Download.

That is enough.

No account unless there is a real reason for one.

No browser extension.

No setup wizard.

No extra steps just because the backend has a lot going on.

If the system is complicated, that is my problem.

The user should not have to care.

Where the project is now

The project eventually became IGPuller:

https://igpuller.com

The goal is still simple: take an accessible public post URL, find the available photo media, and make it easy to save from the browser.

There is plenty left to improve.

The resolver can always be more resilient.

Monitoring can get better.

There are more edge cases to cover.

But the project has already changed the way I think about small web tools.

What looks like a one-button utility from the outside can hide a lot of moving parts underneath.

And when your tool depends on an upstream platform you do not control, the real work is not getting it to work once.

It is keeping the rest of your system sane when that upstream behavior changes.

Top comments (0)