The brief
Earlier this year I led the build of a personnel management system for a large institutional client — the kind of organization that still tracks ranks, appointments, postings, and base assignments through paper files passed between desks. The brief was straightforward: replace the paperwork. The implementation, less so.
I'm writing this for two reasons. One, future-me will forget half of what we figured out. Two, if you're about to build something similar, a few of these notes will save you a week.
What we actually shipped
A web app for managing personnel records end-to-end:
- Multi-step entry forms for new personnel — personal info, contact, professional history, rank, appointment, posting.
- A search + filter layer over the full directory (by name, service number, rank, posting, date ranges).
- Approval workflows for changes that need a second pair of eyes.
- Master data management for the things that change rarely but break everything when they do — ranks, appointments, bases, area commands, trades, offices.
- Role-based access (admin / officer / viewer) with audit trails on every meaningful change.
Stack: Django + Django REST Framework on the backend, PostgreSQL for storage, JWT for auth, Tailwind CSS + jQuery on the frontend. No SPA framework. That choice ended up being interesting on its own — more on that below.
The bug that cost the most: Django sessions vs. JWT
We picked JWT for auth so the same backend could serve the web app today and a mobile app later without rewriting the session layer. The problem: Django ships with session middleware turned on, and a lot of Django patterns silently assume it's there. We had server-rendered pages protected by @login_required, AJAX endpoints protected by JWT — and inconsistent behavior depending on which path the request took.
The first symptom was a logout that didn't actually log anyone out: the JWT was cleared from localStorage, but the Django session cookie was still valid, so the next page load went straight back into the dashboard. We patched it. Then a different symptom showed up. Then another.
Eventually we stopped patching and made one decision: the frontend treats the API as the only source of truth for auth, and the backend has no session layer at all. Every page is served as static HTML; every protected element is hydrated by an authenticated API call. @login_required got deleted; a small middleware verifies the JWT on every API request.
This is obvious in retrospect. It was not obvious while we were trying to make a half-session, half-token model work.
The dashboard-flash bug
After fixing auth, we hit a smaller but more visible problem: when a user logged out, the dashboard would render for a fraction of a second before the redirect to /login kicked in. It looked sloppy, and on slower connections it leaked information — for a moment, you could see numbers from someone else's session.
The cause was that our auth check ran inside DOMContentLoaded, which fires after the browser has already painted the page. The fix was to move the check into a tiny inline <script> in the <head>, before the body parses:
<head>
<script>
if (!localStorage.getItem('access_token')) {
window.location.replace('/login');
}
</script>
<!-- rest of head -->
</head>The fix is fewer steps and a smaller blast radius: the head script runs synchronously before the parser reaches the body.
Two small details that mattered:
window.location.replace(), notwindow.location.href =— we didn't want the protected page sitting in the browser's back-button history.- The script is intentionally synchronous and tiny. Anything async runs too late.
For the more thorough check (token still valid, not expired), we still rely on the API call after page load — but the cheap localStorage gate is enough to prevent the flash.
Pagination, but actually usable
Default pagination patterns in most templates show every page number until you reach the second page, then dump the rest into a …. That breaks down once you have a few thousand records. Our directory has over six thousand personnel; "page 1 2 … 247" is not navigation, it's a guess.
The behavior we ended up with:
- Always show first and last page.
- Always show current page and one or two neighbors on each side.
- Insert
…only when there's an actual gap. - On mobile, collapse to "Page 47 of 247" with prev/next arrows — the dots-and-numbers UI doesn't fit on a phone.
The implementation is maybe forty lines of JavaScript. The hard part wasn't writing it; it was noticing that the default pattern was bad and that nobody had complained because they were just using search instead.
I didn't realize you could browse by page. I just searched for everything.
That quote is also a lesson — see the last section.
Multi-step forms without a framework
The personnel entry form has five steps and around sixty fields. Without React or Vue, "wizard state across steps" becomes a real design question. Our options were:
- One big form, hidden sections. Simplest; one submit at the end. Bad for validation feedback and bad if the user closes the tab.
- Multiple forms, server-side state. Each step posts to the backend, which holds a partial record. Resilient but adds a lot of backend complexity.
- Multiple steps, client-side state in
localStorage, single final submit. Resilient to tab close, no partial-record state on the server, easy to validate per step.
We picked option 3. Each step's data is serialized into localStorage under a draft key (personnel-draft-{userId}), validated on "Next," and only sent to the server on the final step. If the user closes the tab and comes back tomorrow, the draft is still there.
The thing I wish we'd done from day one: include a schema version in the draft. We changed the form once, deployed, and existing drafts deserialized into a broken state. Now every draft carries a version field and we discard drafts that don't match the current schema.
const DRAFT_SCHEMA_VERSION = 3;
function loadDraft(userId) {
const raw = localStorage.getItem(`personnel-draft-${userId}`);
if (!raw) return null;
const draft = JSON.parse(raw);
if (draft.version !== DRAFT_SCHEMA_VERSION) return null;
return draft;
}Forty lines of work the first time. About a day of cleanup the time we forgot.
Time savings: the honest version
The proposal estimated 60–80% reduction in administrative time, and we hit that on the easy stuff. But the breakdown by task was uneven:
| Task | Before | After | Notes |
|---|---|---|---|
| Finding a personnel record | 5–10 minutes | Under 30 seconds | The biggest immediate win. |
| Generating a roster report | 2–4 hours | 2–5 minutes | This is the one users mentioned. |
| Entering a new record | 30–45 minutes | 10–15 minutes | Faster, but more fields than they were used to. |
| Approval routing | 1–3 days | Hours | Limited by humans, not the system. |
The time savings on data entry were smaller than expected because we asked for more structured data than the paper form did. That's a real cost — and worth flagging to anyone planning a similar migration. Digital is not automatically faster if you're also expanding the data model.
What I'd do differently
A few things, in roughly the order I'd change them:
- Pick one auth model on day one. Don't ship session and token side-by-side and hope to converge later. You will not converge. (See above.)
- Version the client-side state. Anything in
localStorageis a schema you ship to users. Treat it like a migration target. - Build the search before the browse. Users find records by typing, not by paginating. Our pagination work was important but not as important as the search.
- Talk to the actual users earlier. The dashboard layout we built for "executives" was not what executives wanted. The dashboard for officers was wrong too. Three rounds of iteration on the dashboard would have been one round if we'd shown wireframes in week two instead of week six.
- Reconsider the no-framework call honestly. jQuery shipped fine, and the maintenance team is happy with it. But every single one of the bugs above would have been less painful with a framework that has a real component lifecycle and a real router. If I were starting over, I'd at least try Alpine or htmx before defaulting to vanilla.
What's next
Next post in this series: how we wired the approval workflow, including the part where we accidentally let approvers approve their own changes for two weeks. (Nobody noticed. We noticed in code review. We fixed it.)
If you're building something similar and have questions, send them our way — the answers usually become the next post.
