Virtual Internships · 2025–26
Making a long application finishable
Was 28 minnow 12 min
Two releases, not one. The redesigned flow shipped over five weeks in Aug–Sep 2025; the persistence layer that stopped it losing drafts landed six months later, and that layer is mine end to end.
By the numbers
Outcome
- Time to apply
- 28 → 12 min
- 57% faster
- Completion rate
- +24%
- Drop-off
- −31%
System
- Steps
- 5–8
- 4 orders pre-generated
- Write paths
- 3 → 1
- collapsed by one debounce
- API debounce
- 5s
- plus a 1s idle poll
- Draft keys
- 15
- per step · 24h TTL
- Persistence layer
- 573 lines
- 6 files · 72 test cases
- Whole flow
- 21k lines
- 533 test cases · 68 events
Scope
- My commits
- 123 / 286
- a plurality, not a majority
- Engineers
- 9
- across the flow
- Persistence layer
- all mine
- and its 3 post-launch fixes
The outcome figures were measured on the design side. The flow’s own instrumentation cannot break them down by step — the failure events for steps two through four are declared and never fired.
Step count is configuration
Five steps are always present; three splice in from batch config. So the flow is five to eight steps long and four orders are pre-generated rather than derived per render.
- 01Personal info
- 02Career field
- 03Internship preference
- 04Additional questions
- 05Diversity
- 06Résumé profile
- —Video profile
- 07Review
7 steps · 2 optional spliced in
Each step is its own route and its own lazily-loaded page, so position is computed against the visible order rather than stored. That is what makes the back button, a refresh and a shared link all land on the step you were actually on — and it is a precondition for drafts being recoverable at all, since a restored draft is useless if reaching it means walking forward through four screens.
Three writers, one debounce
Drafts are keyed per step, not per application — app_autosave_draft_{uuid}_{stepKey} — because each step is a separate route with its own form instance. There is no moment when the whole application is in memory at once.
The draft fills blanks, it never corrects them
Hydration runs once per mount and writes a draft value only into fields that are currently empty, so the server always wins. Safe by default — a stale draft cannot clobber saved data — but narrower than it looks.
A mergeWithServerData function exists that compares timestamps and prefers the newer side. It is dead code, and it would not have worked: the merge spreads server keys last, so server values overwrite local ones on the exact branch meant to prefer local. Two tabs on one step also share a key with no versioning and no storage listener — last writer wins, silently.
Credit
The designer owned structure, research and visuals. The résumé parser and its field pre-fill were started before my first commit here and built by other developers — I integrated and hardened its output, and added custom-skill entry. The persistence layer, the step shell and the validation integration are mine.
The profile this feeds is separate work: fe-intern-profile.