8 min readAlvin

Uploading photos from a hundred phones on venue wifi

engineeringrailsreact

The photos from an event end up scattered across a hundred phones and never get collected. SnapCache fixes that by putting a QR code on the table: a guest scans it, picks photos, and they land in one shared gallery the organizer can download later.

Two constraints shape everything underneath. A guest at a wedding will not install an app and will not create an account — you get one tap and about fifteen seconds of patience. And they are standing in a room where two hundred people share one access point, on a phone that shoots a format browsers refuse to display. Every decision below falls out of those two facts.

Identity without a signup

The gallery URL contains a signed token rather than a database id. Rails has this built in:

class Event < ApplicationRecord
  generates_token_for :id
end
 
# in the controller
@event = Event.find_by_token_for(:id, params[:uid])
redirect_to not_found_events_url unless @event

generates_token_for signs the record's id with the application's secret, so a token can't be guessed or incremented into someone else's wedding, and it can be printed on a card weeks in advance. No lookup table, no extra column.

But uploads still need an identity — to credit a photo, to count a gallery view once instead of forty times, to let someone like a picture without liking it again on the next scroll. So the first request that reaches a valid gallery mints a guest:

def create_guest
  Guest.create(name: "guest_#{SecureRandom.hex}", password: 'guest', event: @event).tap do |guest|
    session[:guest_id] = guest.id
    @guest = guest
  end
end

That's a real row scoped to one event, created without the guest doing anything. The display name is separate and optional — if they type one, it goes into a 30-day cookie keyed by event, so a guest who comes back on day two is still credited by name without being asked again. Sessions expire; the cookie is what makes the second visit feel like a continuation.

One request per photo

The obvious upload implementation is one POST with thirty files attached. On venue wifi it's the worst possible choice: the request runs for two minutes, the radio drops once, and all thirty photos are gone with nothing to show for it.

So the client uploads one file per request, sequentially, with per-file state:

const runUploads = async (queue) => {
  for (const item of queue) {
    try {
      lastResponse = await uploadFile(item);
    } catch (e) {
      if (e && e.capExceeded) break;
    }
  }
};

Sequential rather than parallel is deliberate. Ten concurrent uploads over a congested link don't finish faster — they finish at the same time, and until then the progress bars sit at 40% together, which reads as frozen. One at a time means photos land steadily and the screen visibly advances.

XMLHttpRequest is doing real work here, not legacy inertia: xhr.upload.onprogress gives byte-level upload progress, which fetch still doesn't expose for request bodies.

xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) {
    updateQueueItem(item.id, {
      progress: Math.round((e.loaded / e.total) * 100),
      status: 'uploading',
    });
  }
};

Each row in the queue carries its own status — pending, converting, uploading, done, duplicate, error — and a failed row gets its own Retry button. A dropped connection costs one photo, and the guest can see exactly which one.

HEIC, the format the browser won't render

Every recent iPhone shoots HEIC by default. No browser will display it, so a gallery full of HEIC files is a gallery full of broken images.

Converting server-side works but means the phone uploads the original file over the slowest link in the chain, then the server burns ImageMagick time on it. So the client converts first, when it can:

const convertHeicToJpeg = async (file) => {
  const heic2any = window.heic2any;
  if (!heic2any) return { file, conversionMethod: 'server_fallback' };
  try {
    const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 });
    const converted = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
      type: 'image/jpeg',
    });
    return { file: converted, conversionMethod: 'client_side' };
  } catch {
    return { file, conversionMethod: 'server_fallback' };
  }
};

Two failure paths, one outcome. If the library never loaded — a flaky CDN on a flaky network, which is exactly the situation we're in — or if it throws on a particular file, the original HEIC uploads anyway and the server handles it. The guest never sees a conversion error, because a conversion error isn't an error; it's a slower path.

The server decides on the file, not on the client's claim:

def heic_file?(image)
  %w[image/heic image/heif].include?(image.content_type) ||
    image.original_filename.match?(/\.(heic|heif)\z/i)
end

conversion_method rides along in the form data purely so the server can log which path ran. It's a metric, not a control — when client-side conversion succeeded the uploaded file is already a JPEG and heic_file? is false on its own. Being able to grep for heic_conversion ... method=server_side and watch that number is how you find out a browser update broke the client path, weeks before anyone reports a slow upload.

Guests re-upload their camera roll

People select photos from a grid. They do it twice, on day one and day two, and the second selection overlaps the first almost entirely. Treating that as an error trains guests to stop uploading.

Dedup is a hash of the file, scoped to the event:

def compute_hash(image)
  Digest::MD5.file(image.tempfile.path).hexdigest
end
 
def duplicate_image(hash)
  event.images.kept.find_by(file_hash: hash)
end

MD5 is the right tool here — this is collision-avoidance among a few thousand photos, not a security boundary, and it's fast on a multi-megabyte file. The column is indexed on [event_id, file_hash] so the lookup stays cheap as the gallery grows.

What matters more is the response. A duplicate returns 409 Conflict, and the client renders it as a resolved state, not a failure:

} else if (xhr.status === 409) {
  updateQueueItem(item.id, { progress: 100, status: 'duplicate' });
  resolve(data);
}

Progress goes to 100. The row reads Already uploaded ✓. The promise resolves, so the queue keeps moving. The guest's intent — "this photo should be in the gallery" — was satisfied, and the UI says so.

The cap is a race condition

The free tier caps a gallery at 100 photos. That cap is meaningless if you check it with a bare count, because the situation it exists for is twenty guests uploading at the same moment during the toast. Twenty requests each read 98, each decide there's room, and the gallery ends up at 118.

def save_with_cap(image, hash, cap)
  event.with_lock do
    dup = duplicate_image(hash)
    next duplicate_response(dup) if dup
 
    count = event.images.kept.count
    next cap_blocked_response(cap, count) if count >= cap
 
    create_image(image, hash)
    nil
  end
end

with_lock wraps the whole check-and-create in a transaction holding a row lock on the event, so concurrent uploads to the same gallery serialize at exactly the point where it matters and nowhere else.

The status code is the other half. A blocked upload returns 402 Payment Required, and the client treats it differently from every other failure:

} else if (xhr.status === 402) {
  setUpgradeModal(data);
  reject({ capExceeded: true });
}

That capExceeded flag is what breaks the queue loop. Without it, a guest who selected thirty photos against a full gallery would sit through twenty-nine more doomed round trips before being told. One failure, one modal, queue stopped.

There's a matching warning path: crossing 80% of the cap fires a one-time email to the organizer and returns a nudge in the upload response, so the person who can actually fix it hears about it before the room hits the wall.

Where this stops working

The ZIP download. When an organizer asks for the whole album, the request downloads every blob to a temp folder on the app host and adds them to an archive one at a time:

def create_zip(tmp_folder, filename)
  Zip::File.open("#{tmp_folder}.zip", Zip::File::CREATE) do |zf|
    zf.remove(filename) if zf.find_entry(filename)
    zf.add(filename, "#{tmp_folder}/#{filename}")
  end
end

That opens and rewrites the archive once per photo, inside the web request, and send_file only runs once all of it finishes. For a 300-photo birthday party it's a few seconds and nobody notices. For a 2,000-photo wedding it's a request that will hit a proxy timeout long before it produces a file, on a worker that's been blocked the whole time. The archive_complete? check — comparing file count in the temp folder against the gallery — makes a retry cheap, which is a decent mitigation and not a fix.

The honest version of this is a background job that streams entries into an archive in object storage and emails a link when it's ready. Same for the dedup index, which isn't unique in the database: on uncapped plans there's no lock around the check, so two byte-identical uploads racing each other can both get through. The index makes it fast; only a constraint would make it true.

Both are the kind of thing you leave until the first gallery large enough to prove it matters. What we didn't leave until later is everything a guest touches — the per-file retry, the duplicate that reads as success, the conversion that silently falls back. Those had to be right on day one, because a guest who hits a broken upload at a wedding does not try again.

Building something and want a team that takes it from empty repo to shipped? We'd love to hear about it.