A Python-on-Firebase backend for an iOS app
I write iOS apps for a living, and most of them never touch a server. PartyCam was the one that had to. The moment two phones at the same party need to see the same shared album, the photos can't live on one device anymore — and that's the moment a client-only app grows a backend. Here's the smallest one I could get away with.
PartyCam is a shared-camera app: you spin up an event, friends join with a code, and every photo anyone takes lands in one live album everyone can scroll. It sounds like a client app, and the camera and the UI are. But three requirements drag it onto a server, and they're the same three that drag most "client-only" apps onto one eventually: shared state that has to stay in sync across devices, logic you can't trust a client to run, and push notifications. I built the whole thing on Firebase, with the server logic in Python. This is what that looked like and where it bit back.
Why a camera app needs a server at all
For a long time PartyCam was a prototype that synced photos peer-to-peer over the local network. It demoed beautifully and fell apart the instant someone walked out of Wi-Fi range or joined ten minutes late. Shared, real-time data is the thing local sync is worst at. Once you accept that the album is a piece of state several devices read and write concurrently, you want one authoritative copy of it somewhere, and "somewhere" is a server.
The other two reasons showed up close behind. Some logic simply can't live on the client: when a guest joins with a 6-character code, something trusted has to check that code, confirm the event isn't full or expired, and add them — you can't ship that decision to a phone the user controls. And the headline feature, a buzz on everyone's lock screen when a new photo drops, is a push notification, which by definition originates from a server. Three requirements, one conclusion. The interesting question was how little backend I could write to satisfy them.
Firestore: thinking in documents, not tables
I reached for Firestore as the database, and the first thing to unlearn coming from SQL is the schema. There are no tables, rows, or joins. There are collections (think: folders) holding documents (think: JSON blobs with an ID), and documents can themselves hold subcollections. You model around how you'll read the data, not how you'd normalize it.
For PartyCam the shape fell out naturally. An events collection, one document per party, and each event document owns a photos subcollection:
events/{eventId}— name, owner UID, join code, created/expiry timestamps, amembersmap of UID → role.events/{eventId}/photos/{photoId}— the uploader's UID, a Cloud Storage path to the full image, a thumbnail URL, a caption, a timestamp.
The win is that "show me this event's photos, newest first" is one query against one subcollection, and the iOS client can subscribe to it. Firestore's real-time listeners push every change down the socket, so a new photo on one phone appears on every other phone in well under a second without anyone hitting refresh. That live-listener behavior is most of why I picked Firestore over rolling my own API on top of Postgres — it hands you the real-time sync that was the whole point, for free.
Cloud Functions in Python
Server logic runs in Cloud Functions. Firebase added first-class Python support to its firebase-functions SDK, and since I write backends in Python by preference when I get the choice, that decided the language. You write plain functions, decorate them, and deploy with the Firebase CLI — no servers to provision, no Dockerfiles, scale-to-zero by default.
Two kinds of function carry the app. HTTPS callable functions are request/response endpoints the iOS app invokes directly — "join this event with this code" is one. Firestore triggers fire automatically when a document is created, updated, or deleted — "a photo was just written, go notify everyone" is one. The triggers are the part that feels like magic coming from a request/response world: you don't call them, the database calls them.
Here's the join endpoint — an HTTPS callable that validates the code server-side and adds the caller to the event. Note it never trusts the client for identity; Firebase hands it a verified UID:
from firebase_functions import https_fn
from firebase_admin import firestore, initialize_app
initialize_app()
@https_fn.on_call()
def join_event(req: https_fn.CallableRequest) -> dict:
# req.auth is populated by Firebase from the verified ID token.
# No auth == no entry. We never read a UID from the request body.
if req.auth is None:
raise https_fn.HttpsError(
https_fn.FunctionsErrorCode.UNAUTHENTICATED,
"You must be signed in to join an event.",
)
uid = req.auth.uid
code = (req.data.get("code") or "").strip().upper()
if len(code) != 6:
raise https_fn.HttpsError(
https_fn.FunctionsErrorCode.INVALID_ARGUMENT,
"A join code is six characters.",
)
db = firestore.client()
matches = (
db.collection("events")
.where("joinCode", "==", code)
.limit(1)
.get()
)
if not matches:
raise https_fn.HttpsError(
https_fn.FunctionsErrorCode.NOT_FOUND,
"No event with that code.",
)
event = matches[0]
data = event.to_dict()
if data["expiresAt"] < firestore.SERVER_TIMESTAMP:
raise https_fn.HttpsError(
https_fn.FunctionsErrorCode.FAILED_PRECONDITION,
"That event has ended.",
)
if len(data.get("members", {})) >= 50:
raise https_fn.HttpsError(
https_fn.FunctionsErrorCode.RESOURCE_EXHAUSTED,
"This event is full.",
)
# Add the caller as a member. Dotted keys update one map field in place.
event.reference.update({f"members.{uid}": "guest"})
return {"eventId": event.id, "name": data["name"]}
That is the entire "join" feature. Everything that must be trusted — code lookup, expiry, capacity — happens here, where the user can't reach in and lie. The client just shows a text field and renders the result.
Auth: Firebase Auth and verified ID tokens
I didn't write a login system. Firebase Auth handles sign-in — Sign in with Apple, mostly, since this is an iOS app — and hands the client a signed ID token, a JWT that encodes who the user is. The iOS SDK attaches that token to every callable-function request automatically. On the server, the SDK verifies the signature and expiry for you and drops the result into req.auth. That's the req.auth.uid the join function leans on.
The rule I hold to, and the one I've watched people get wrong: the UID always comes from the verified token, never from the request body. The moment a function reads a userId field the client sent, any user can impersonate any other. Treat the request body as untrusted input — because it is — and the token as the only source of identity.
Security rules are the real access control
Here's the part that catches iOS engineers, and caught me: the iOS app talks to Firestore directly for reads and writes. It doesn't route every operation through a function. Listening to the photos subcollection is a direct connection from the phone to the database. So what stops a malicious client from just reading every event in the project, or writing photos into someone else's album?
Firestore security rules. They are a small declarative language that runs on Google's servers and decides whether each read and write is allowed, and they — not your client code — are the actual access-control layer. Client checks are UX. Rules are security. The two are not interchangeable, and any access rule you only enforce in Swift is not enforced at all.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /events/{eventId} {
// Only members of an event can read it.
allow read: if request.auth != null
&& request.auth.uid in resource.data.members;
match /photos/{photoId} {
// Members can read the album.
allow read: if request.auth != null
&& request.auth.uid in
get(/databases/$(database)/documents/events/$(eventId)).data.members;
// You can only create a photo as yourself, in an event you're in.
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.uploaderUid
&& request.auth.uid in
get(/databases/$(database)/documents/events/$(eventId)).data.members;
}
}
}
}
Write the rules first, and test them in the Firebase Emulator before any client touches production. A wide-open default rule shipped to the App Store is a data breach with a version number on it. I keep the rules in the repo next to the functions and treat a rules change like any other code change — reviewed, tested, deployed together.
Push via FCM from a Firestore trigger
Now the satisfying part, where the pieces click together. When a photo document is created, a Firestore trigger fires, looks up the event's members, and pushes a notification to each of them through Firebase Cloud Messaging. No client asked for this; the write itself caused it.
from firebase_functions import firestore_fn
from firebase_admin import firestore, messaging
@firestore_fn.on_document_created(
document="events/{eventId}/photos/{photoId}"
)
def notify_on_photo(event: firestore_fn.Event) -> None:
photo = event.data.to_dict()
event_id = event.params["eventId"]
db = firestore.client()
parent = db.collection("events").document(event_id).get().to_dict()
# Don't buzz the person who just took the photo.
recipients = [
uid for uid in parent.get("members", {})
if uid != photo["uploaderUid"]
]
# Fan out device tokens. (Stored per user when they grant notifications.)
tokens: list[str] = []
for uid in recipients:
user = db.collection("users").document(uid).get()
tokens.extend(user.to_dict().get("fcmTokens", []) if user.exists else [])
if not tokens:
return
messaging.send_each_for_multicast(
messaging.MulticastMessage(
tokens=tokens,
notification=messaging.Notification(
title=parent["name"],
body="New photo just dropped 📸",
),
)
)
A photo lands in Firestore, every other phone's live listener updates the album instantly, and a beat later a push nudges anyone who isn't looking. Three Firebase products — Firestore, Functions, Cloud Messaging — doing one job each.
Cost and cold starts, honestly
Two things will surprise you on a serverless backend. The first is cold starts: a function that hasn't run recently has to spin up a fresh Python runtime, and the first request can take a second or two. For a fire-and-forget trigger like the push, nobody notices. For an interactive callable like join_event, a cold second is a visibly sluggish tap. If a path is latency-sensitive you can set min_instances=1 to keep one warm — but that instance bills around the clock, so you're trading money for milliseconds. I keep warm instances only on the endpoints a user waits on.
The second is cost shape. Firestore bills per document read, write, and delete, and the bill is driven by your access patterns, not your data size. The classic trap is a real-time listener on a big collection that re-reads documents on every change — it's invisible in development with five test photos and a genuine line item at a real party with hundreds. Design queries to read only what's on screen, paginate, and watch the usage dashboard in the first week of real traffic like it's a production metric. It is one.
When a client-only app should stay client-only
I want to be honest here, because the reflex to add a backend is strong and often wrong. A backend is permanent operational weight: rules to maintain, functions to keep deployed, a bill, an attack surface, a thing that can be down. If your app doesn't have the three triggers — shared state across devices, logic that must be trusted, or server-originated push — you probably shouldn't build one.
Plenty of apps that feel like they need a server don't. If the data is one user's and only their devices need it, CloudKit or even iCloud key-value sync gives you cross-device sync with zero backend code and no bill. A single-player app with local persistence needs nothing at all. I've shipped apps where the right move was deleting a backend someone had added out of habit — the feature it powered could live entirely on-device, and removing it made the app faster, cheaper, and more private in one commit. Adding a server is a real decision with a real recurring cost, not a default.
PartyCam earned its backend, and the lesson I took from it is to build the smallest one that clears the bar. Lean on managed pieces — Firestore for synced state, Auth for identity, rules for access, one trigger for push — so the code you actually own and maintain is a handful of small Python functions, each doing the one thing a client genuinely cannot. When someone asks how to build the backend for their app, my first question isn't which database. It's whether they need one yet.