Skip to content
Prajwal Bhatia
← Work

Virtual Internships · 2025–26

Making a long application finishable

Was 28 minnow 12 min

Time to apply

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.

Batch configuration
  1. 01Personal info
  2. 02Career field
  3. 03Internship preference
  4. 04Additional questions
  5. 05Diversity
  6. 06Résumé profile
  7. —Video profile
  8. 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

blurfield loses focus1s pollerwhile form is dirtysubmitstep advanceslocalStorageimmediate · per step · 24h TTLdebounce 5scollapses the threegatevalid & dirtyAPIdraftinvalid → stays local only
Blur alone is not enough — someone who types and closes the tab never blurs the field.

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.