The problem: a business running on spreadsheets and WhatsApp

The client I built PanelControl for is an official reseller of myPOS payment terminals. Every day they handle dozens of orders, hundreds of sales leads, contract activations, shipments, staff shifts, time off, payroll, and a steady stream of leads coming in from an external CRM. Before PanelControl, all of this lived scattered across spreadsheets, WhatsApp chats, emails, and phone calls — no single view, no reliable history, no way to know in real time what a colleague in the next room was doing.

I designed and built from scratch a web-based business app that centralizes all of it — sales, onboarding, support, shipping, HR, administration — into a single multi-role Progressive Web App, installable on desktop and mobile, used daily on tablets and phones by the team. Like the other projects on roversia.it, no framework: just JavaScript loaded straight by the browser, for zero friction in deploy and in debugging directly in production.

Tech stack

Not a single line shared with the rest of the site, but the same philosophy: zero bundler on the frontend, dependencies picked one by one.

LayerTechnology
FrontendVanilla HTML/CSS/JS, no bundler
Realtime dataFirebase Realtime Database
Query/analyticsFirebase Firestore
AuthenticationFirebase Auth with server-minted Custom Tokens
File storageFirebase Storage
Push notificationsFirebase Cloud Messaging (v1, service account OAuth2)
Serverless backendNetlify Functions, ESM, zero npm dependencies
Hosting/CI-CDNetlify, automatic deploy from GitHub
ChartsChart.js
Data exportSheetJS (Excel), jsPDF (PDF)
Built-in AIGoogle Gemini API
External CRMDelera / GoHighLevel, via webhook
TelephonyVoxloud, Telephony Events API
EmailGmail API, OAuth2

A hand-written rendering engine, no React or Vue

The whole UI is rendered by a home-grown engine: a global state object holds every piece of application data — orders, activations, leads, operators, shifts, time off, permissions. Every state change triggers render(), debounced at 80ms to avoid cascading re-renders during closely spaced writes, plus a renderNow() for cases that need an immediate synchronous update, like switching months. The internal router is a switch on the current view, picking which render*() function to call — each defined in its own file, loaded as a separate module.

The part I'm most proud of is the 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 it never wipes out the DOM under someone's fingers while they're typing — with explicit exceptions for month-switch selects and checkboxes, which have to stay reactive regardless. Without a framework handling diffing for me, this is the rule I had to write by hand so a realtime update from Firebase would never erase a form an operator is filling in.

💡

Not every module loads on first launch. The heaviest files load on demand, only when the user navigates to that section, with a loader that waits and retries if the module hasn't arrived over the network yet — useful on slow mobile connections, since the app is also used off-site on tablets and phones.

From on('value') to granular listeners: the real bottleneck

Every critical data node syncs in real time across all connected clients through Firebase's native listeners. The single most impactful optimization in the project was migrating from on('value') listeners to granular child_added / child_changed / child_removed listeners:

  • Before: every single write to a node — say, a new activation — caused every connected client to re-download the entire history.
  • After: only the delta, the new or changed record, gets transmitted to each client.
  • Measured result: an estimated 40-60 MB/day saved in Firebase bandwidth, with a direct impact on pay-as-you-go plan costs.

The same pattern, applied to the activity log, cut the initial load from 200 to 50 records via once('value'), followed by a child_added listener for new events only — roughly 75% bandwidth saved on that section's initial load. It's the kind of optimization that's invisible in the UI but very visible on the bill.

Authentication: no more shared Firebase credentials

The login system was redesigned from scratch 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 the token for an authenticated Firebase Auth session, with granular permissions mapped via custom claims, verified in the database's security rules.

On top of the base role, each operator can have specific permissions individually enabled from a dedicated Admin panel. The architectural pattern chosen for every access check is always the same:

pseudocode
access = legacy_hardcoded_list.includes(operator)
      OR hasPermission(operator, feature)

Never the dynamic check alone. This is to avoid breaking access for legacy operators not yet explicitly migrated to the new granular permission system, keeping continuity during the transition. It's a principle I applied across every permission migration in the project: every new rule ORs with the old one, never replaces it outright.

The modules that raised the most interesting problems

Internal chat: the "portal" pattern for position:fixed

Messaging between operators supports photos, voice messages, documents, and GIFs, with a floating bubble UI. The classic problem: position:fixed stops working correctly once an ancestor has a CSS transform applied, common when nesting modals and panels. I fixed it by making the chat a direct sibling of the main container instead of a descendant — a DOM structure choice, not a CSS trick.

Mail: polling instead of push, denormalized schema

The email module queries the Gmail API with periodic polling instead of push, and uses a denormalized data schema split across two separate nodes to balance list speed against detail speed. On this module, Firebase listeners are always granular, never on('value'): the nodes hold potentially heavy email bodies, and an on('value') would download the entire mailbox on any tiny change.

Telephony: a hybrid architecture for high volume

Call history integrates the Voxloud API with a two-speed architecture: the current month stays on the Realtime Database, with a capped listener and "load more" pagination; past months move to Firestore, with block pagination. Searches always route to Firestore, to avoid saturating the realtime database with heavy queries over thousands of records.

Reconciling with an external CRM under tight rate limits

A scheduled function checks each lead against its matching opportunity in the external CRM, one call at a time rather than bulk-dumping the pipeline, to stay within API limits. A two-speed strategy: recent leads rechecked every 24 hours, older leads permanently excluded from rechecking so they don't burn quota on data that's already settled.

Backend: idempotent, fail-soft serverless functions

Backend functions are written in pure ESM, deliberately with no npm dependencies, to stay lightweight with no supply chain to maintain. Some shared patterns across every integration with the external CRM:

  • A shared secret validates incoming webhook requests.
  • Deduplication via a business key — the order number — instead of technical IDs generated by the CRM.
  • Every webhook logs to both the Realtime Database and Firestore, for both realtime visibility and historical queries.
  • Critical alerts sent via Telegram with per-category throttling, to avoid a flood of notifications during a burst of errors.
  • Webhooks always respond HTTP 200, even when an error is handled internally — a deliberate choice to stop the external CRM from retrying the same request forever.

One operational constraint I had to learn to work around: Netlify environment variables have a 4KB limit per function. Large credentials like private keys aren't stored as environment variables, then, but kept directly in the function file, with an explicit note for manual rotation — it avoids hitting the limit and breaking the deploy, at the cost of a less automated rotation process.

When a custom vanilla JS management panel makes sense (and when it doesn't)

When to use it

  • A business still running on spreadsheets and chat with no real centralized system: even a minimal custom panel is a huge step up from scattered manual processes.
  • Very business-specific requirements that a generic management tool (ERP, standard CRM) doesn't cover without costly customization.
  • Technical team available for maintenance: a hand-written rendering engine requires in-house skills to maintain over time, unlike a vendor-supported SaaS product.
  • Limited budget for software licenses: building on Firebase Spark/Blaze costs far less than a commercial ERP with per-user licensing.

When not to use it

  • Standard business management needs (accounting, inventory, basic CRM): an existing ERP or CRM (even open source) covers these cases without reinventing the wheel.
  • No internal or external technical team available for future maintenance: a custom panel without ongoing support accumulates technical debt quickly.
  • Integration with many established external systems needed (e-invoicing, shipping carriers, marketplaces): mature platforms already have these connectors ready.

Alternatives

For a case with more specific catalog and pricing logic (e-commerce instead of a generic management panel), see the more extensive approach in custom e-commerce on Firebase. For standard management needs with no particular requirements, an open-source ERP (Odoo) or a SaaS CRM is often more cost-effective than custom development.

Common mistakes

  • Sharing the same Firebase credentials across all operators: eliminates any individual audit trail and increases risk in case of compromise — per-user authentication should be planned from the start.
  • Writing a rendering engine without planning for scalability: a "works for now" approach without clear componentization patterns makes adding new modules increasingly costly as the panel grows.
  • Non-idempotent serverless functions: without explicit handling of duplicate requests (e.g. double-click, network retry), operations like creating an order can silently duplicate.

What I take away from this project

PanelControl is the project that taught me, more than any other, that "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 then sticking to them as the code grows from one file to sixty-five. I used the same installable, zero-dependency PWA approach for the games on this site: the nature of the problem changes, but the underlying philosophy stays the same.

If the authentication side interests you, I wrote in more detail about both the migration to per-operator Custom Tokens and the cross-app HMAC token used to let PanelControl talk securely with other business apps for the same client.

Frequently asked questions

Is it worth building a business app with no framework like React or Vue?

For a single developer who knows their codebase deeply, vanilla JavaScript with a small hand-built rendering engine removes build steps and dependencies to keep updated. The cost is discipline: without a framework enforcing patterns, you have to maintain them by hand over time.

How do you cut Firebase Realtime Database bandwidth on nodes that grow a lot?

By switching from on('value') listeners to granular child_added/child_changed/child_removed listeners, which transmit only the changed delta instead of redownloading the whole node on every write. Savings can reach 70-75% on initial load.

Why use a Firebase Custom Token instead of direct client-side credentials?

With a Custom Token, a serverless function verifies credentials with hashing and rate limiting, then mints a server-signed token. No shared credential stays visible on the client, and permissions become custom claims checkable in security rules.

How do you handle granular permissions without breaking existing users' access?

With a non-destructive OR: access is granted if the user is on the old hardcoded list or has the explicit granular permission. You introduce a finer system without migrating everyone at once.

Why do iOS push notifications need the Firebase SDK instead of the native push event?

The Service Worker's raw push event doesn't reliably wake a fully closed PWA on iOS. The Firebase Messaging SDK with onBackgroundMessage correctly handles payload decryption and APNs integration.