Unolo is a field force management platform. Companies use it to track the field employees who leave the office every morning — salespeople, on-ground staff — and to follow the work they're sent out to do. The customer base is enterprise: large organisations with distributed teams, including names like Tata and Uber. Publicly, Unolo states it's used by 1200+ companies and 50,000+ field staff.
I was a frontend engineer there from November 2023 to April 2025, inside an engineering team of about 30 people. Four pieces of work from that time are worth writing down properly: an internal usage-tracking platform that changed when the sales team learned about churn, a performance overhaul of the public site, a configuration-driven dashboard I led, and a rewrite of the map system that took it from choking at 4,000 points to comfortably rendering 100,000.
The stack#
Everything below happened on this stack:
| Layer | What we used |
|---|---|
| Application & marketing site | React.js, Next.js |
| State | Redux |
| UI & styling | Material UI, Tailwind CSS |
| Dashboard surface | react-grid-layout (drag & resize) |
| Charts | Recharts |
| Maps | Google Maps, with deck.gl layered on top for custom layers |
Catching churn before the cancellation#
The problem#
The sales team used to find out about a churning account at the worst possible moment: when the customer cancelled the subscription. By then there was nothing left to do. The signal arrived after the decision had already been made.
The premise of the project was simple. A customer about to churn almost always shows it in their usage first. If we could surface that early, someone from our side could reach out while the account was still saveable, ask what was going wrong, and fix it.
So we built an internal platform for the sales team whose only job was to make client usage visible.
What "usage" actually means here#
Usage was never one number. It was a set of signals about how alive an account was:
- how many active users the client has
- how many users are logging in daily
- how many tasks their teams are creating
- how many tasks are being assigned
- volumes across the product's core entities — tasks, sites, companies and visits
And on top of every one of those, comparison over time — because the absolute number tells you far less than the direction of travel: last week vs this week, yesterday vs today, last month vs this month, last year vs this year.
A client with 400 active users is healthy. A client that had 400 last month and has 180 this month is a phone call that needs to happen today. The comparisons were the product.
How it was built#
The frontend was a completely separate project — its own application, not a section bolted onto the customer-facing dashboard. The audience was internal, the access rules were different, and the views had nothing to do with what a customer sees.
The backend was the product's actual backend. We didn't stand up a parallel data store; we added a set of additional APIs on the existing backend that exposed the usage information in the shape this platform needed.
What was hard#
This project was data-heavy, and that's where most of the engineering effort went:
- A lot of filters. Every view needed to be sliceable — by client, by entity, by date range, by comparison window. The filter surface itself became a significant piece of state to manage.
- A lot of conditional logic on the frontend. The branches for handling combinations of filters, comparison modes and data shapes grew fast, and keeping that readable was a constant fight.
- A lot of data manipulation on the client. Reshaping, grouping, deriving the comparison deltas — much of that transformation happened frontend-side.
- Visualisation mattered as much as the data. A raw table of usage numbers does not make a salesperson pick up the phone. A visible drop does. Getting the presentation right was part of the requirement, not polish on top of it.
The lead time changed completely. Instead of learning about a problem at cancellation, the sales team was seeing trouble roughly a month earlier — early enough to call, ask what the customer was struggling with, and in a lot of cases simply get told, and fix it. Churn came down by roughly 10–20%.
Making the landing site fast#
Why we picked this up#
Three signals landed at roughly the same time, and they were all the same problem wearing different clothes:
- PageSpeed scores on the landing site were poor.
- SEO was suffering — the page took so long to load that indexing was being affected.
- Marketing complained about how slowly the page came up for visitors.
For a marketing site that's the whole ballgame. The landing page is the first thing a prospective client sees, and if it takes several seconds to paint, a good number of them never see it at all. An unindexed page might as well not exist.
How I found what was wrong#
I profiled with Lighthouse and Chrome DevTools, deliberately running under different throttling profiles rather than on a fast office connection — because the visitor on a mid-range phone on a mobile network is the visitor whose experience is actually broken.
The audit pointed at two things consistently: images were taking a large share of load time, and fetching CSS and JS was the other large share. Everything below follows from those two findings.
Image optimization#
Images were the single heaviest thing on the page, so this was the first pass:
- Reduced the image sizes outright — the originals were far larger than anything the page actually needed.
- Converted to WebP, with compression on top.
- Introduced
srcsetwith proper dimensions per screen size, so a phone downloads a phone-sized image instead of a desktop-sized one scaled down in the browser. - Moved images onto the CDN, so they were served from close to the visitor.
Chunk size reduction#
The CSS and JS together were bloating the page. Two moves:
- Removed unused CSS that was being shipped in the bundle but never applied.
- Chunked and split the bundle by page and by section, so the browser fetches what the visible section needs first and the rest arrives afterwards.
The browser should not have to download the code for the bottom of the page before it can paint the top of it.
FCP and LCP#
The two problems above were exactly the two things holding these metrics back.
The heavy images were causing significant layout shift and painting problems, and it got noticeably worse across responsive breakpoints, where the wrong-sized image was loaded and then reflowed. The bundle size was the other half — once there was less to paint and less to fetch before painting, the numbers moved.
Fixing images and bundle size was the LCP/FCP work. They weren't separate tasks.
Lazy imports for components#
Every page on the site is made of a lot of sections and components, and very few of them are on screen when the page first loads. So the rule was simple: visible components get brought in first; components outside the visible region load after — via lazy imports, rather than being bundled into the initial payload.
Assets on the CDN#
We had clients across different regions, and the site carried a lot of assets plus large CSS and JS files. Serving all of that from a single origin meant how fast the site felt depended largely on how far away you happened to be.
Putting the assets on a CDN removed that. Alongside it, going through the CSS and JS carefully and tree-shaking out what wasn't used brought file sizes down substantially — and a smaller file served from a nearer edge is a compounding win, not an additive one.
Load time for the images and the first page went from around 2–2.5 seconds down to roughly half a second to one second. That came from three things working together, not one: image sizes came down, whatever is visible is what gets shown first, and assets that aren't required at that moment are not loaded at all.
A dashboard the customer assembles themselves#
A project I led — one frontend (me), two backend, one QA.
The problem with a fixed dashboard#
The dashboard was fixed. Everyone got the same screen.
That works right up until you look at who's actually using it. A single customer has many different admins — different teams, different regions. The admin for one region needs that region's data; the admin for another team cares about something else entirely. The data set behind the product is large, and not all of it is relevant to everyone.
The real insight was that this is not a permissions problem, it's a priority problem. Two admins can both be entitled to see the same numbers and still want completely different screens, because their field of interest differs based on what they're responsible for. A fixed dashboard forces both of them to look past most of the screen to find their one number.
So the dashboard needed to stop being a screen we design, and start being a screen the user assembles.
The design: JSON describes the dashboard#
The whole dashboard is driven by a JSON configuration, which carries everything that makes a dashboard a dashboard:
- what position a component sits at
- what data it's bound to
- how it looks
- how that data should be represented
Nothing about a given dashboard is hardcoded in the UI. The UI is a renderer; the JSON is the dashboard.
How a config entry becomes a component#
Rendering works through component mapping driven by type. The type tells the renderer what it's dealing with at two levels:
- What kind of component is this — a data component, or a visual one?
- If visual, which visual — a graph, and then which graph, or a comparison view.
That two-level split is what keeps the system extensible. Adding a new chart type doesn't mean touching the renderer's logic; it means registering another entry in the map.
Position, layout and the component library#
The complete layout and position is configurable. Components can be arranged, moved and sized, and that arrangement is part of the configuration — which is what makes the dashboard genuinely per admin, rather than a fixed grid with swappable contents.
The component library covered the full range: all chart types, tables, widgets carrying data, and comparison views. And crucially — each component pulls its data from a different API, and which API it calls is configured in that component's JSON. The data binding is part of the config, not part of the component. A chart doesn't know where its numbers come from; the config tells it.
Lazy importing and the component API#
Two things kept this from turning into a bundle problem:
- Lazy importing, so the application only loads the components a given dashboard actually uses — not every chart type in the library on the chance that someone might place one.
- A compound component API — components exposed in a nested form such as
<Graph.Bar>and<Tab.Part>— which kept the surface well balanced: readable and grouped for the developer, while still allowing each variant to be pulled in only when needed.
What was actually hard#
Not the rendering. It was the data flow and the API call flow inside every component.
When every component fetches from its own configured endpoint, you no longer have one page making a coordinated set of requests. You have N independent components, each deciding on its own what to call, when to call it, what to do while it waits, and how to behave when its call fails — all while the user can add, remove, move and resize them. Getting that flow right, consistently, across every component type, was where the real work sat.
A new dashboard stopped being an engineering task. It became clicks. Anyone can choose what they want to see and what they don't — arrange it, bind it, and it's there. No waiting on a build cycle, no timeline, no ticket.
The shift in what the product asks of the user is worth stating plainly. Before, whether you found it useful, whether it mattered to your team, whether it was something you'd want to share with another admin — none of that was considered. The message was this is the information, this is the order, look at it. After, there's real control over what each admin sees, over the visibility of data according to that admin's area of administration, and over which data is most valuable to which administrator so that it appears first. And they can adjust it themselves. What used to be a single fixed arrangement became a screen that reflects the person looking at it.
The map: from 4,000 points to 100,000#
This is the piece I'm most attached to, because the answer wasn't the one I started with — and the map only became fast once I accepted I'd found two bottlenecks, not one.
What was actually broken#
The map had a hard ceiling at roughly 4,000 points, and past it the tab died. Two separate causes, sitting on top of each other:
On the React side, every point rendered its full detail component. Four
thousand points meant roughly four thousand detail divs mounted at once. That
filled memory, and then it crashed.
On the map side, every point was drawn as its own marker. There was no clustering at all, and no separation of points by zoom scale — so a sudden zoom out, with the whole country in frame, still meant every single marker being drawn.
Neither layer had any notion that the user can only actually look at a small part of this at a time.
Why it became urgent#
For a long time this didn't hurt, because most customers weren't using the feature at scale. Then Uber and Tata wanted to use it — and the 4,000-point ceiling went from a known rough edge to a blocker in front of two of our largest accounts. It had to be fixed, quickly. That's when I picked it up.
The wrong turn#
First attempt: virtualization. I started with the React side, because that was the crash. Only the components inside the visual window get rendered; as the user moves across the map, components outside the visible window are never created.
It worked — for the crash. The page stopped crashing and got faster.
But the map still felt wrong. Zooming in and out was still janky. Move the mouse and you could physically feel the drag. The page existed now, but it wasn't usable.
The dig#
This is the part worth writing down. It would have been easy to conclude that virtualization was the answer and that what remained was just "the map being slow." Instead I went deeper — and found the two problems were sitting on top of each other. Virtualization was a bottleneck, not the bottleneck. Neither fix alone was going to make this fast, which is exactly why the first improvement felt like progress and still felt broken.
The test that proved it: when I stopped passing points to the map, the drag disappeared. That located the second bottleneck precisely. It wasn't React any more — it was the sheer number of markers I was handing to the map itself.
The fix: clustering by zoom level#
If the map can't take 100,000 markers, then the map should never be given 100,000 markers. It should be given whatever is meaningful at the current zoom level — because at most zoom levels, individual points aren't even legible.
So I built a zoom-based cluster hierarchy:
| Zoom level | What the map is handed |
|---|---|
| All the way out (world in frame) | A single roll-up — India, 2,000 points |
| One level in | State-level clusters |
| Further in | District-level clusters |
| Close in | Individual points per city — how many, and exactly where |
At every zoom level, the map is handed a small number of things to draw. The 100,000 points still exist in the data — they're just never all on screen at once, because they never needed to be.
Alongside this came layering: separating individual points, clusters and sites into their own layers rather than throwing everything into one undifferentiated pile of markers. That separation is what makes the zoom rules above expressible at all — each layer can appear, disappear and update on its own terms.
Debouncing the cluster computation#
Building clusters is real computation, and zooming is a continuous gesture. The naive version recalculates on every frame of a zoom — which just moves the cost from the map's rendering into my own JavaScript.
So I debounced it: the cluster computation runs based on how much zooming has actually happened in a given interval, after the gesture settles rather than during every step of it. The user is mid-zoom; they don't need a recomputed cluster set at each intermediate level, they need one for where they land.
Why clustering happened on the frontend#
The backend was sending all the data as-is, and the backend team was fully packed at that point — there was no room to change what was being sent. So the clustering was done entirely on the frontend. That was a constraint, not a preference, but it shaped the solution: everything above happens client-side, on data that arrives raw.
Before: the page crashed outright past a few thousand points, and even when it survived, the drag was constant. After: it became a completely ordinary page — fast, and smooth to zoom exactly like any other map service. The ceiling went from 4,000 points to 100,000, and the experience at 100,000 is the one you'd expect from a map, not from a stress test.
The lesson wasn't clustering or virtualization. It was that the first fix working is not evidence that the first fix was the whole answer. Virtualization stopped the crash and improved the page, and that success is exactly what would have made most people stop. The map only got fast because I kept going after the numbers had already improved.
What I take away from this#
The through-line#
If I had to name the one thing I'm good at, it's finding the actual problem.
Someone comes to me and says "this page isn't working." That sentence contains no information. My job starts there — working out why it isn't working, whether that's the performance of a page or the performance of a map. Virtualization, layering, clustering: those were the answers, but the value wasn't in knowing those techniques. It was in isolating the exact problem each one was the answer to — and, in the map's case, in not stopping when the first answer already looked like a win.
The other half is that I can build a project from the base — given a clear requirement and a clear picture of how far it has to go — and build it to be scalable. On the frontend, scalable means something specific to me:
- performance is good
- good practices are followed
- state management is correct
- components are structured in a correct, deliberate way
Those four aren't a checklist you apply at the end. They're the decisions that determine whether the thing survives its second year.
What a year and a half taught me#
Most of what I learned came from working inside a large codebase with a lot of dependencies, where different teams work on different products and your change is never only your change.
What I learned was a way of approaching work that arrives badly defined. A weak, loosely-specified problem lands on your desk, and the job is to give it structure:
- Do a thorough architecture analysis of the current code — understand what exists before proposing to change it.
- Propose a solution on the basis of that analysis, not on the basis of what you'd do on a blank page.
- Implement it in phases.
- Test it, and find the bugs.
- Fix them.
- If something is wrong, turn around and make the right decision — rather than defending the first one because it's yours.
- Debug the existing problems, so the same class of problem stops recurring.
Point six took the longest to internalise, and the map project is a monument to it. Being willing to say this isn't it, go back further is worth more than being right the first time.
The technical ground#
Set out plainly, across these four projects:
- Virtualization — rendering only what's in the visual window, and not constructing what isn't
- Zoom-based clustering and layering on a map, with debounced recomputation
- Configuration-driven UI — JSON describing position, data, appearance and representation
- Type-based component mapping — turning a config entry into the right rendered component
- Compound / sub-component systems —
<Graph.Bar>,<Tab.Part>-style APIs - Lazy importing and code splitting — at component, section and route level
- Web performance — image optimization, bundle and chunk reduction, tree-shaking, CDN delivery, LCP/FCP work
- State management at application scale
- Debugging — the part that made all of the above possible