Skip to content
Home About Services Work Notes Contact Start a project
Healthcare systems

Designing a Hospital Registration Counter That Survives the Queue

Hospital software is judged at the counter. If registration takes ninety seconds instead of fifteen, the queue reaches the door and the staff go back to the register book.

DjangoPostgreSQLHealthcarePerformance

Hospital software is judged in one place: the registration counter. Everything else — the reporting, the dashboards, the module list in the tender document — is invisible to the person deciding whether your system is any good. What they see is the queue behind them.

A government medical college OPD registers patients in bursts. The desk opens, and a few hundred people who have been waiting since before dawn form a line. If each registration takes fifteen seconds, the line moves. If it takes ninety, the line reaches the door, the staff start writing in a register to catch up, and by the end of the week your system is a data-entry backlog rather than a system of record.

So the design constraint is not "handle N requests per second". It is: one clerk, one keyboard, one patient in front of them, and the screen must never make them wait. That single constraint drives most of the architecture.

The lookup is the hard part, not the insert

Registering a new patient is a trivial write. The expensive operation is the one that happens first, on every single visit: is this person already in the system?

Getting that wrong creates duplicate patient records, and duplicate patient records are the defect that poisons everything downstream — a patient's history splits in two, their old reports are unreachable, and billing reconciliation stops adding up.

The clerk has whatever the patient can produce: a mobile number, a registration slip from two years ago, or a name they are pronouncing for someone who cannot read it back. So lookup has to work on all three, and it has to return in the time it takes to finish typing.

# A registration number that humans can read back over a counter.
# Not a UUID: the patient has to be able to say it out loud.
class Patient(models.Model):
    reg_no      = models.CharField(max_length=16, unique=True)
    name        = models.CharField(max_length=120)
    mobile      = models.CharField(max_length=15, db_index=True, blank=True)
    guardian    = models.CharField(max_length=120, blank=True)
    dob         = models.DateField(null=True, blank=True)
    created_at  = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            # Mobile is the primary handle: patients remember it,
            # and it is the one field that is nearly unique in practice.
            models.Index(fields=["mobile", "-created_at"]),
            models.Index(fields=["-created_at"]),
        ]

Name search is where people reach for icontains and then wonder why the page hangs at 200,000 rows. LIKE '%raj%' cannot use a B-tree index — Postgres has to read every row. The fix is a trigram index, which can serve a leading-wildcard match:

-- once, as a migration
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX patient_name_trgm ON patient USING gin (name gin_trgm_ops);
from django.contrib.postgres.search import TrigramSimilarity

def find_patient(term):
    term = term.strip()
    if term.isdigit() and len(term) >= 6:
        # mobile or reg_no: exact, index-backed, no fuzziness wanted
        return Patient.objects.filter(
            Q(mobile=term) | Q(reg_no=term)
        ).order_by("-created_at")[:20]

    return (Patient.objects
            .annotate(score=TrigramSimilarity("name", term))
            .filter(score__gt=0.3)
            .order_by("-score", "-created_at")[:20])

Two details matter more than the similarity threshold. First, the numeric branch is checked before the fuzzy one — a mobile number should never go through a similarity search. Second, results are capped at twenty. A clerk will never scroll past the first few, and an uncapped query on a common name is how you turn a 40ms page into a four-second one.

Token numbers and the race you will eventually lose

Every OPD registration gets a token number, per department, per day. The obvious implementation is the wrong one:

# Two clerks registering at the same instant both read 47.
# Both write 48. Two patients now hold token 48.
last = Visit.objects.filter(dept=dept, date=today).aggregate(Max("token"))
token = (last["token__max"] or 0) + 1

This works perfectly in testing, because in testing there is one of you. It fails on the first busy morning, and it fails silently — nobody notices until two patients are standing at the same door.

The fix is to make the database do the arithmetic, inside a transaction, holding a lock on a row that represents the counter itself:

from django.db import transaction

@transaction.atomic
def issue_token(dept, day):
    counter, _ = TokenCounter.objects.select_for_update().get_or_create(
        dept=dept, date=day, defaults={"value": 0},
    )
    counter.value += 1
    counter.save(update_fields=["value"])
    return counter.value

select_for_update() makes concurrent callers queue on that one row. The lock is held for microseconds, and it is scoped to one department's counter for one day, so it never becomes a global bottleneck. A Postgres sequence is faster still, but sequences do not reset per day per department without extra machinery, and this is not the code path that needs saving.

Keep everything else out of the request

The temptation, once registration works, is to do more things during it: send an SMS, generate a PDF slip, push a row into the analytics table, notify the department screen. Each of those adds latency to the moment the clerk is waiting, and each of them can fail in a way that should not stop a patient being registered.

The rule I hold to: the registration transaction writes the patient, the visit and the bill, and nothing else. Everything downstream is a task.

@transaction.atomic
def register(patient_data, dept, user):
    patient = get_or_create_patient(patient_data)
    visit = Visit.objects.create(
        patient=patient, dept=dept,
        token=issue_token(dept, timezone.localdate()),
        created_by=user,
    )
    Bill.objects.create(visit=visit, amount=dept.opd_fee)
    # after the commit, never inside it — an SMS outage
    # must not roll back a registration
    transaction.on_commit(lambda: send_registration_sms.delay(visit.id))
    return visit

transaction.on_commit is the part people miss. Queue the task inside the transaction and the worker can pick it up before the commit lands, then fail because the row it needs does not exist yet. It is a genuinely confusing bug the first time you hit it, and it only shows up under load.

Printing is a first-class requirement

Western SaaS assumptions do not survive here. The patient needs a paper slip; the department needs a paper register; the accounts office wants the day's collection in a format that matches the book they already keep. A hospital system that cannot print exactly what the office printed before is a system the office will route around.

Design the print layout against the existing paper form, not against your screen layout, and render it server-side. Browser print dialogs on shared counter machines are a support burden you do not want.

What to measure

Not requests per second. Measure the two numbers the clerk feels:

  • Time from keystroke to search results. If this exceeds roughly 200ms the interface feels laggy even though nothing is broken.
  • Time from pressing Register to the slip printing. This is the number the queue length is a function of.

Log both with timings, per counter machine. When someone reports "the system is slow today", you want to be able to answer whether it is the network at that desk, the database, or one clerk searching for a name spelled four ways — and those three have completely different fixes.

The part that has nothing to do with code

Go live one department at a time. Registration and billing first, because that is where trust is won or lost. Do not attempt a whole hospital in one weekend; every horror story in this sector starts with someone trying exactly that.

Next step

Need this built properly?

Tell me what you are trying to ship. You get an approach, a timeline and a realistic estimate — usually within a working day.