Backend & Systems
NOWI: Real-Time Event & Social Discovery Platform
An app for meeting people at the event you are already at. A radar shows who nearby is open to being approached and what they are up to, so nobody has to guess whether they are intruding.
Radar presence expires on a 90 second TTL, so everyone still on your screen is still in the room and still open, rather than someone who left twenty minutes ago.
- Swift
- Vapor
- PostgreSQL
- Redis
- WebSockets
- APNs
- Docker
- SwiftUI
- MapKit
- NearbyInteraction
- CoreBluetooth

Problem
Cities are full of people who would get on and never meet. Two things stop it. Some people will talk to a stranger but hold back, because they might be interrupting someone who wanted to be left alone, or because they expect to be turned down. Others would start something and cannot, because they need a reason to open with first, some shared thing to point at. Either way the barrier sits before the conversation rather than inside it.
Solution
The event does the first filter, so everyone in the room already shares something. Then a radar lets people signal that they are open to being approached and say what they are up to. Anyone visible has chosen to be there, which takes away the question of whether you are intruding, and their interests and status hand you the opening you needed.
Architecture
Durable records live in PostgreSQL and anything about who is present right now lives in Redis, because presence has to decay on its own for the radar to stay honest. WebSockets carry live changes to connected clients and push notifications cover everyone else.
- Radar presence is a Redis key under a 90 second TTL alongside a member set, refreshed on every client heartbeat and lazily pruned when a key has already expired, which is what keeps the radar honest about who is still there.
- Two WebSocket channels, one for the event radar and one for chat rooms, authenticated by a JWT passed on the query string.
- Ping cooldowns use a single Redis SET with NX and EX, so a burst of Back Taps cannot write duplicate rows for the same pair.
- APNs payloads carry a typed kind beside the standard alert, letting the client deep link on tap without parsing the display text.
- Twenty Fluent migrations cover users, events, bookmarks, interests, rooms, chats, ping logs, and a waiting list.
Key features
- Event radar showing who at the same event has signalled they are open to being approached, with their interests and a short status saying what they are up to
- Ultra-wideband ranging between phones, with discovery tokens exchanged device to device over MultipeerConnectivity instead of through the server
- A Back Tap App Intent that pings the person you are focused on without opening the app
- Real-time chat over WebSockets, falling back to push when the recipient is offline
- Map and search discovery over scraped event data, with bookmarking and interest matching
Direct contributions
- Designed the API surface, the DTOs, and the PostgreSQL schema across twenty Fluent migrations.
- Built the Redis presence layer behind the radar, and the cooldown that keeps repeat pings out of the log.
- Wrote the client application layer, covering app state, the API client, shared DTOs, and the event, map, profile, room, and onboarding screens.
- Refined the event radar service on the client, then handled cross-stack debugging and the VPS deployment in Docker.
Implementation
Presence that expires on its own
Radar membership is a Redis set plus a per-user key under a 90 second TTL. Because the key expires rather than being deleted on disconnect, someone who walks out of the venue or loses signal falls off the radar without the server being told. Stale members are pruned the next time the roster is read.
/// Ephemeral "is radar on for this event" state, backed by Redis.
/// Deliberately not persisted in Postgres. It has to expire when the user walks
/// out of the radius or stops sending heartbeats, not when they close the app.
enum EventRadarStore {
private static let ttl: TimeAmount = .seconds(90)
/// Marks a user's radar active and refreshes the TTL.
/// Called on join and on every heartbeat while inside the radius.
static func activate(client: any RedisClient, eventID: UUID, userID: UUID) async throws {
try await client.set(aliveKey(eventID: eventID, userID: userID), to: "1").get()
_ = try await client.expire(aliveKey(eventID: eventID, userID: userID), after: ttl).get()
_ = try await client.sadd([userID.uuidString], to: memberKey(eventID: eventID)).get()
}
/// User IDs whose radar is currently live. Members whose TTL key has
/// already expired are pruned from the set on the way past.
static func activeUserIDs(client: any RedisClient, eventID: UUID) async throws -> [UUID] {
let members = try await client.smembers(of: memberKey(eventID: eventID), as: String.self).get()
var active: [UUID] = []
for member in members.compactMap({ $0 }) {
guard let userID = UUID(uuidString: member) else { continue }
let alive = try await client.get(aliveKey(eventID: eventID, userID: userID), as: String.self).get()
if alive != nil {
active.append(userID)
} else {
_ = try await client.srem([member], from: memberKey(eventID: eventID)).get()
}
}
return active
}
}One Redis call instead of a read-then-write race
Back Tap can fire a ping faster than a person can think. SET with NX and EX sets the cooldown and reports whether it already existed in a single atomic call, so two pings arriving together cannot both pass the check.
/// Redis-backed cooldown so a rapid string of Back Taps does not spam
/// ping_log with duplicate rows for the same (from, to, event) triple.
enum PingRateLimiter {
private static let cooldownSeconds = 15
private static func key(eventID: UUID, fromUserID: UUID, toUserID: UUID) -> RedisKey {
RedisKey("ratelimit:ping:\(eventID.uuidString):\(fromUserID.uuidString):\(toUserID.uuidString)")
}
/// Returns true if this ping is allowed to proceed, and marks the cooldown.
static func tryConsume(client: any RedisClient, eventID: UUID,
fromUserID: UUID, toUserID: UUID) async throws -> Bool {
let redisKey = key(eventID: eventID, fromUserID: fromUserID, toUserID: toUserID)
let result = try await client.set(
redisKey, to: "1",
onCondition: .keyDoesNotExist,
expiration: .seconds(cooldownSeconds)
).get()
return result == .ok
}
}Interface




