DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

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)
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.

Top comments (3)

Collapse
 
nazar-boyko profile image
Nazar Boyko

That OR between the legacy hardcoded list and the new permission check looks like a one-way door: you can grant access through the new system, but you can't revoke it for anyone still on the old list without editing code. Has that mattered in practice, or does the operator list change slowly enough that it never comes up?

Collapse
 
frank_signorini profile image
Frank

This is really impressive without a framework! How do you handle state management across different 'panels' or modules

Collapse
 
androve2k profile image
Andrea Roversi

Thanks! State is centralized in a single global state object that holds all application data (orders, activations, leads, staff, shifts, permissions, etc.). There's no per-module local state — every module reads from and writes to that same object.

Any mutation triggers a render() call, debounced by 80ms to avoid cascading re-renders when multiple writes land close together (which happens a lot with realtime Firebase listeners). There's also a renderNow() escape hatch for cases that need an immediate synchronous update, like switching the selected month.

Routing between panels is just a switch on the "current view" — it picks which render*() function to call, and each view's render function lives in its own module file, loaded separately.

The trickiest part was the user-interaction guard: before any re-render, the engine checks whether focus is currently on an input field. If so, it delays the render by a couple seconds so a realtime update from Firebase doesn't blow away the DOM while someone's mid-keystroke in a form. Selects for month-switching and checkboxes are explicitly exempted since those need to stay reactive regardless. Without a framework doing diffing for you, that guard is something you have to hand-roll — otherwise realtime writes and live user input step on each other constantly.