Uploading photos from a hundred phones on venue wifi
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 @eventgenerates_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
endThat'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)
endconversion_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)
endMD5 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
endwith_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
endThat 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.