PanelControl is an internal business app I designed and maintain solo for an official myPOS reseller: sales, onboarding, shipping, staff shifts, HR, and administration, all in one multi-role Progressive Web App. It started as a single HTML file. It’s now 65+ source files and about thirty serverless functions — and it’s still framework-free, with no build step.
Here’s what kept it standing as it grew.
The problem
Before PanelControl, the business ran on spreadsheets, WhatsApp, email, and phone calls. Dozens of orders a day, hundreds of sales leads, contract activations, shipments, shifts, time off, payroll, plus a steady stream of leads from an external CRM — no single view, no reliable history, no way to know in real time what a colleague was doing.
I built a single PWA that centralizes all of it, installable on desktop and mobile, used daily on tablets and phones by the team.
A hand-written rendering engine
No React, no Vue. A global state object holds every piece of application data. Every state change triggers a debounced render() (80ms, to avoid cascading re-renders during closely spaced writes), plus a renderNow() for cases that need an immediate synchronous update.
The part I’m proudest of: a user-interaction guard. Before every re-render, the engine checks whether focus is on an input field, and if so, postpones the render by a few seconds so a realtime update from Firebase never wipes out a form someone is filling in — with explicit exceptions for controls that must stay reactive (month selectors, checkboxes).
Not every module loads on first launch either. Heavy modules load on-demand when the user navigates to that section, with a retry loop if the module hasn’t arrived over the network yet — important since the app also runs on tablets over spotty mobile connections.
From on('value') to granular listeners
The single most impactful optimization in the project: migrating Firebase Realtime Database listeners from on('value') to granular child_added / child_changed / child_removed.
- Before: every single write to a growing node caused every connected client to re-download the entire history.
- After: only the changed delta gets transmitted.
- Measured result: an estimated 40–60 MB/day saved in Firebase bandwidth, with a direct impact on pay-as-you-go costs.
The same pattern applied to the activity log cut the initial page load from 200 to 50 records via once('value'), followed by a child_added listener for new events only — about 75% bandwidth saved on that section alone.
Authentication without shared credentials
The login system was rebuilt to eliminate shared Firebase credentials on the client. A Netlify Function verifies username and password with PBKDF2-SHA256 hashing, applies server-side rate limiting against brute force, and returns a server-minted Firebase Custom Token. The client exchanges it for an authenticated session, with permissions mapped via custom claims and checked in the database’s security rules.
For granular, per-operator permissions on top of the base role, I settled on one non-negotiable pattern for every access check:
access = legacy_hardcoded_list.includes(operator)
OR hasPermission(operator, feature)
Enter fullscreen mode Exit fullscreen mode
Never the dynamic check alone. This avoids breaking access for operators not yet explicitly migrated to the new permission system — a rule I’ve applied across every permission change in the project since: new rules OR with the old ones, they never replace them outright.
A few modules that raised real problems
Internal chat. A floating bubble UI hit the classic position:fixed bug: it stops working correctly once an ancestor has a CSS transform applied, which is common when nesting modals. Fixed by making the chat a direct sibling of the main container instead of a descendant.
Mail. Polls the Gmail API instead of using push, with a denormalized schema split across two nodes to balance list speed against detail speed. Listeners here are always granular — the nodes hold potentially heavy email bodies, and on('value') would redownload the whole mailbox on any tiny change.
Call history. A two-speed architecture: the current month stays on the Realtime Database with a capped listener, past months move to Firestore with block pagination. Searches always route to Firestore to avoid saturating the realtime database.
CRM reconciliation. A scheduled function checks each lead against the external CRM one call at a time (not a bulk dump) to stay within rate limits, rechecking recent leads every 24 hours and permanently skipping older ones once they’ve settled.
Fail-soft serverless functions
Backend functions are pure ESM, deliberately dependency-free. A few shared patterns across every webhook integration:
- Deduplication via a business key (order number), not a CRM-generated ID
- Every webhook logs to both RTDB and Firestore, for realtime visibility and historical queries
- Critical alerts go out via Telegram with per-category throttling
- Webhooks always respond HTTP 200, even on an internally handled error — so the external CRM doesn’t retry the same request forever
One operational constraint worth knowing: Netlify environment variables cap out at 4KB per function. Large credentials like private keys end up hardcoded in the function file instead, with a note for manual rotation, rather than blowing the limit and breaking the deploy.
Takeaway
“No framework” doesn’t mean “no discipline.” It means writing by hand the rules a framework would otherwise give you for free — a sensible debounce, a user-interaction guard, a non-destructive permission pattern — and sticking to them as the codebase grows from one file to sixty-five.
I wrote a longer, more detailed version of this case study, plus related deep-dives on the Custom Token migration and a cross-app HMAC token, on my site: roversia.it/blog-21.
답글 남기기