Full-stack engineer who ships production software end to end.
Work spans internal platforms at a state transportation agency, a subscription AI product with paying users, and a config-driven template system serving a client roster. Currently building network and data tooling at the New Jersey Turnpike Authority.
Open to full-stack, frontend and AI product rolesJersey City, NJ
A subscription product that matches a resume against a nightly-scraped corpus of startup jobs and emails the top ten every morning. Built solo, live, with paying subscribers.
The obvious architecture for resume-to-job matching is embeddings and cosine similarity. I did not use them. At roughly 5,000 jobs in a narrow, highly structured domain, a keyword pre-filter followed by an LLM re-rank gave better precision than semantic similarity would have, because the domain vocabulary is standardized enough that lexical overlap is a strong signal. It also meant zero embedding infrastructure to run, version, and re-index every night.
What I traded away: this approach will not survive a corpus an order of magnitude larger or a domain with looser vocabulary. The pre-filter is the bottleneck, and at 50K jobs I would be re-ranking a candidate set too noisy to be useful. That is the point at which I would add embeddings, and I would rather add them when the data tells me to than carry the infrastructure from day one.
The rest of the build
Billing
Stripe Checkout with a trial, signature-verified webhooks keeping Stripe and Postgres in sync, and the Customer Portal for self-serve billing. The edge case that cost me an evening: checkout.session.completed can land before the subscription row exists, so the handler has to be idempotent and upsert rather than update.
Funnel
Clerk for auth, and a free instant-match preview at the top of the funnel: upload a resume, see three matches with reasoning, then hit the paywall for the daily digest.
The 8am run
Vercel Cron triggers the digest job, which fans out per subscriber and sends through Resend. A failed run is logged per user rather than per batch, so one bad resume parse does not cost everyone else their morning email.
The result
Live
jobcannon.app
3
Paying subscribers
~5K
Jobs scraped nightly
What I would do differently
I built the ranking before I built any way to measure it. For the first month my only signal that matching worked was reading digests myself, which is the worst possible evaluator. I would start with a labelled set of fifty resume-to-job pairs and a script that scores a run against it, so that every prompt change has a number attached. I would also send the first digest to myself an hour early, because a scheduled job whose failure mode is silence is a job you find out about from a customer.
I build internal tooling for the agency's network and data teams. The main system is a network visualization platform mapping 500+ enterprise devices and IP addresses, used daily by technical and non-technical staff across three departments. Before it, troubleshooting meant cross-referencing spreadsheets.
Next.jsTypeScriptPostgreSQLDrizzleNodePython
State agency system. Device names, addresses, and topology below are illustrative. Screenshots of the live app are not published.
Internal, no public link
Shape of the system
Illustrative
ingestion
scheduled ETLmonitoring agentmanual CSV import
core
deviceinterfacedevice_linkssite
surface
topology viewdevice detailsearchREST API
The performance work
2.4s to under 500ms
The topology page took 2.4 seconds to load. Two causes. First, the device-relationship tables had no composite indexes, so relationship lookups were scanning. Second, two Drizzle queries were N+1: one query for the device list, then one per device for its connections. I added composite indexes on the relationship tables and rewrote both patterns as joins. Load time dropped to under 500ms.
Before
Seq Scan on device_links
rows=48212 cost=0.00..9821.4
+ 1 query per device (N+1)
──────────────────────────
total 2,412 ms
After
Index Scan using
device_links_src_dst_idx
rows=512 cost=0.29..184.6
single join, one round trip
──────────────────────────
total 418 ms
Sanitized plan summary. Figures rounded, table names generalized.
Also on this build
The API layer
REST endpoints in Node and Express: full CRUD over devices and relationships, auth middleware enforcing department-level access, and pagination on every list route because the device table only grows.
Script management service
A Python service orchestrating monitoring, ingestion, and scheduled ETL on a common runner with logging and retries, replacing 10+ hours of weekly manual work that previously lived in someone's head.
Mentoring
I onboarded two summer interns onto the codebase, paired on their features, and reviewed their PRs through to deployment. Writing the onboarding doc for them was the fastest way I found to notice which parts of the system I had never actually justified.
The result
500+
Devices mapped
−40%
Troubleshooting time
418ms
Topology load, was 2.4s
What I would do differently
I found the 2.4 second load because someone complained, not because anything told me. There was no query timing in the logs and no budget on the page, so a regression could sit there for weeks. I would put slow-query logging and a load-time assertion in CI on day one, since it is cheap to add early and awkward to retrofit into a system three departments already depend on. I would also have pushed harder for read-only demo data from the start, because the thing I most want to show about this project is exactly the thing I cannot publish.
A productized web service for local businesses. The engineering problem was not building one site, it was building the tenth site without rewriting the first nine.
Every client site is generated from a single typed site.config.ts holding colors, copy, section order, and integrations. Twelve reusable section components render from it. Per-client build time went from days to hours, and every site stays on one upgrade path: a fix to the contact section ships to all of them.
Trimmed and anonymized. The section union type is what keeps a typo from shipping.
The tooling evaluation
I evaluated Framer as a faster delivery path for the simplest builds. It wins on setup speed and loses on custom control, SEO, and bespoke integrations. I settled on using it only where speed was the whole requirement, and kept the Next.js template for anything where those three mattered.
What I would do differently
I generalized on the second client instead of the fourth. Two of the twelve section components exist because one client asked once, and they have been dead weight in every review since. The rule I would apply now is to copy and paste until a third site needs the same thing, then extract. I would also have versioned the config schema early, because shipping a shared fix is only easy while every site is on the same shape.