I Built a Matchmaking App for Bihar Government Teachers (Tinder With a Haversine Formula)
Teachers in Bihar wait years for a transfer posting. I built a platform that finds them someone who wants to swap jobs, using geohash grids, Haversine distance, and a scoring formula that treats reciprocity like gold.
I Built a Matchmaking App for Bihar Government Teachers (Tinder With a Haversine Formula)
Here's a situation every government school teacher in India knows intimately. You clear the exam, you get your posting, and the department, in its infinite wisdom, puts you 300 km from home in a village where the internet is a rumor and the chai costs two rupees. You want to go back. You want it badly. You write applications, you wait in line, you wait some more.
There is a system for this. It's called a mutual transfer, and it's simple on paper: Teacher A sits in Gaya and wants Patna. Teacher B sits in Patna and wants Gaya. They swap. Two signatures, one happy ending.
The department officially supports this. It's arguably the only romantic thing the government has ever organized. The catch is finding the other half. There is no Tinder for transfers, no directory of "wants to be in Patna" teachers. So I built one, called TeacherTransfer. This post is about the matching engine, which turned out to be way more interesting than I expected.
The Core Problem Is a Two Way Search
Finding a mutual match is not a search, it's a double search. It's not enough to find teachers who want to be where you are. They have to actually be where you want to be, teach the same subject, work in the same school type, and have their own radius that covers your location. Every leg of the swap needs to close, or there's no swap.
The naive implementation is brutal and simple. For every teacher, compute the distance to every other teacher. At 10,000 teachers, that's 50 million distance calculations for a single search, and the database starts humming funeral music. The entire trick of this project is avoiding that.
┌──────────────────────────────────┐
│ Teacher registers │
│ current block + preferred block │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Geo index row written │
│ geohash6/5, subject, school type│
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Search: center + neighbor cells │
│ from preferred location │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Haversine filter within radius │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Mutual check on the reverse leg │
└───────────────┬──────────────────┘
│
▼
┌──────────────────────────────────┐
│ Score, sort, cache the top 50 │
└──────────────────────────────────┘
Geohash: Turning Distance Into a String Comparison
Geohash turns the problem sideways. Instead of asking "who is within 30 km?", you ask "who lives in these grid cells?". The earth gets chopped into 32 base32 cells, each cell into 32 more, and at six characters deep you get cells roughly 1.2 km by 0.6 km. Distance becomes a prefix match. Same hash, same neighborhood.
The clever part is sizing the cells to the search radius:
public static int precisionForRadius(int radiusKm) {
if (radiusKm <= 2) return 6;
if (radiusKm <= 50) return 5;
return 4;
}
public static int ringsForRadius(int radiusKm) {
if (radiusKm <= 3) return 1;
if (radiusKm <= 12) return 2;
if (radiusKm <= 25) return 3;
if (radiusKm <= 50) return 4;
return 1;
}
Small radius, small cells. Two kilometers or less means you're practically transferring across the road, so you search precision 6 cells. Anything up to 50 km runs at precision 5, cells of about 5 km by 5 km, and the search expands outward in rings. Each ring drags in the neighboring cells, so the search area is never one lonely cell. It's the center plus everything around it:
┌─────────┬─────────┬─────────┐
│ tuvz4 │ tuvz5 │ tuvz6 │
├─────────┼─────────┼─────────┤
│ tuvz0 │ tuvz1 │ tuvz2 │ center cell, you are here
├─────────┼─────────┼─────────┤
│ tuvy │ tuvyb │ tuvyc │
└─────────┴─────────┴─────────┘
Early on I only searched the single center cell, until I realized what that meant: a teacher living exactly on a boundary between two cells would miss everyone just across the line, even if they were literally a few meters away. Their entire match list would come back empty while someone 2 km away in the same cell got plenty. Geohash cells have arbitrary edges and people live on both sides of them, so you always search the center plus the 8 neighbors, minimum.
The Candidate Query
The geo index is a denormalized table. One row per teacher, holding the hashes for their preferred location and their current location, plus subject, school type, and a composite index covering all of it:
@Query("SELECT DISTINCT t FROM TeacherGeoIndex t WHERE " +
"t.geohash6 IN :geohashes AND t.subject = :subject " +
"AND t.schoolType = :schoolType AND t.teacherId != :excludeTeacherId")
List<TeacherGeoIndex> findByGeohash6(Set<String> geohashes, Integer subject, Integer schoolType, Long excludeTeacherId);
I run this query twice, once against preferred location hashes and once against current location hashes, then de-duplicate by teacher id. Why twice? Edge case: Teacher B currently sits right next to the block you want, but his preferred location is somewhere completely different because he's given up hope. If you only searched preferred locations, you'd never see him. He's exactly the person you want to talk to.
Haversine: Measuring the Actual Floor
Geohash gets you into the right building. Haversine measures the actual floor. It computes the great circle distance between two coordinates, because the earth is round and your requirements are not:
private double haversineDistance(double lat1, double lng1, double lat2, double lng2) {
final double R = 6371.0;
double dLat = Math.toRadians(lat2 - lat1);
double dLng = Math.toRadians(lng2 - lng1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
Every candidate that survives the geohash filter gets measured for real. If the distance fits inside your radius, they stay. And this is where the two way nature kicks in again: your radius governs your side of the journey, and the candidate's own radius governs theirs. You might accept 30 km, they might accept 50. The circles don't have to match for the swap to be fair, because each side gets to define its own version of "worth it".
The Mutual Moment
Then comes the moment the whole app exists for, the mutual check. The reverse trip, from their preferred location back to your current one, gets measured against their radius. If it fits, you two are a genuine swap:
private boolean isMutualMatch(Teacher teacher, TeacherGeoIndex candidate, Teacher candidateTeacher) {
double reverseDistance = haversineDistance(
candidate.getPreferredLat(), candidate.getPreferredLng(),
teacher.getCurrentLat(), teacher.getCurrentLng());
Integer candidateRadius = candidate.getRadiusKm() != null ? candidate.getRadiusKm() : 30;
return reverseDistance <= candidateRadius;
}
Every result gets classified. MUTUAL when both legs close. INTEREST_SENT when you've already made a move. POTENTIAL when this person clearly wants to be where you are and you should probably introduce yourself. The frontend tabs map exactly to these three buckets.
The best moment in the whole codebase is automatic mutualization. When you send an interest, the system checks whether they've already sent you one. If they have, both rows atomically upgrade to MUTUAL, both statuses flip to ACCEPTED, and both teachers get a notification at the same instant. No "they liked you, keep guessing" nonsense. It's the swipe that matches on both phones simultaneously.
Scoring: Everything Is a Number
private double calculateScore(double distanceKm, boolean isMutual, Teacher candidateTeacher) {
double score = 100.0 - (distanceKm * 2.0);
if (isMutual) score += 20.0;
if (candidateTeacher.isPaidActive()) score += 10.0;
return Math.max(0, score);
}
You start at 100 and bleed 2 points per kilometer. A mutual match is worth a 20 point bonus, because reciprocity is the entire business. Paid users get 10 points, because somebody has to pay for the VPS. The score clamps at zero, so a teacher 55 km away still appears in your list, just at the very bottom, sorted by distance as a tiebreaker. Sort is score descending, distance ascending, limit 50. The results get stored in a match table so the next page load is instant.
Privacy by Default
The privacy model is probably the most important feature I never got asked for. Before a mutual match, you see a subject, a school type, and "Under Block". No name, no school, no phone number. Identities only unlock when the match turns mutual. Teachers told me this directly: if their transfer plans leaked before they signed anything, the whole thing would collapse. So the app treats identity like a prize, not a default.
Two filters keep the list honest. Ghost detection: if a teacher hasn't interacted for 14 days, they're out, and every API call refreshes the timestamp so active people stay visible. And the match cache: generating matches is expensive, so results live in a match_result table and only get regenerated when your profile changes. The check is brutally simple, a timestamp comparison. If your profile was updated after your matches were generated, everything is recomputed. Change your subject, your school type, or your district, and your existing interests get wiped too. Fresh start, reshuffled deck. Harsh, but correct.
The Map Nobody Thinks About
Here's a human problem nobody talks about: teachers don't know their coordinates. Ask a teacher in Bihar "what's your latitude?" and they'll smile politely and wonder about you. But ask them which block they teach in, and you'll get an instant answer. So onboarding is a Leaflet map over OpenStreetMap tiles, with Bihar's 38 districts and 551 blocks seeded from JSON files. You pick your current block, you pick your dream block, the marker snaps to the block centroid, and the coordinates flow into the geohash without the teacher ever seeing a number.
Keeping the Lights On
Then there's all the unglamorous stuff that keeps a side project alive. OTP based login where the OTP is SHA-256 hashed before it ever touches the database, max three attempts, a 60 second resend cooldown, a five minute expiry. JWTs signed with HMAC for sessions. The deployment is a Docker compose stack with four services: Spring Boot API, Next.js frontend, PostgreSQL, and Nginx in front with Let's Encrypt SSL. Nginx also does the rate limiting, 10 requests per second on the API and a very stern 5 per minute on auth endpoints, because OTP endpoints are exactly where bots queue up.
What I Skipped (and Regret)
Now the honest part. The original design doc describes multi hop chains, the 2 and 3 hop rotations where A wants B's spot, B wants C's spot, and C wants A's spot. Three way swaps exist in real government life and they're strictly better than two way swaps, because they unlock way more combinations. The plan is beautiful: a batch job every 12 hours, PostgreSQL advisory locks to stop duplicate runs, a 100 millisecond timeout per teacher. The schema has the tables. The code does not have the feature. Direct swaps only. Which means a teacher in Bhagalpur who wants Patna only wins if someone in Patna wants Bhagalpur exactly. That's the next version.
Also, honest footnote: the SMS provider config exists, but the SMS service never got written, so OTP rides on email. The payment integration is a stub with a TODO that I glance at every couple of months. Shipping beats perfect.
Lessons
-
Index the search, not the distance. Geohash turned a hopelessly slow spatial problem into a few indexed string lookups. I should have done this weeks earlier than I did.
-
Two way problems are twice as annoying and twice as rewarding. Every feature, from the query to the radius check to the notification logic, has a reverse leg. Miss one, and you've built a dating app where only one side ever says yes.
-
Reciprocity is a better signal than distance. The mutual bonus in the scoring is small, 20 points, but it changes behavior completely. Teachers hunt for mutuals first, because that's a transfer they can actually sign.
-
Caching is a feature, not an optimization. Match generation is expensive and teachers don't change their dream block daily. The timestamp based invalidation means nobody ever sees stale data and the database never gets hammered.
-
Real users find edge cases you never imagined. The geohash boundary problem taught me more about spatial indexing than any blog post ever did. If a data structure lets someone slip through the cracks, someone will end up in those cracks.
TeacherTransfer is live at teachertransfer.vercel.app, and the code is on GitHub. If you're a teacher in Bihar, make a profile, you might just find your swap. If you're a developer, read the matching service, it's the best part.
And if you're a government official reading this: the mutual transfer system works, it just needed better marketing.
Related Modules
LockedIn Is an Attention Operating System and I'm Here to Sell It to You
You've downloaded twelve productivity apps and still ended up on your phone at 2 PM. LockedIn treats your attention like a bank account, logs every escape and urge as an event, and only lets you spend real-world rewards when you've earned them. This is the pitch.
Building AdminForge: A Zero-Config Admin Panel and AI Orchestration Layer for Next.js
The story of building AdminForge — an open-source framework that auto-generates admin dashboards, REST APIs, and AI agent interfaces from a single TypeScript config file.
Building Scalable REST APIs with Spring Boot
Learn how to design and implement scalable REST APIs using Spring Boot with best practices for performance, security, and maintainability.