DEV Community

PanelControl: a 65-file business app, in vanilla JavaScript with no framework

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)

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.

Comments

No comments yet. Start the discussion.