
Express Soft
A fully upgraded and rewritten taxi dispatch platform, doing much more than before, much readier to integrate with other services and much easier to use
Express Soft is the taxi dispatch platform of Mobilauto, sold as a package to the taxi companies. It covers passenger transport, cargo transport and tow trucks, and it's the current, multi-language brand of the Mobilauto platform. A taxi company gets a dispatcher workstation, a driver app and a passenger app it can publish under its own name, all on one backend. The site speaks six languages, and offices are listed in Kyiv and Baku.
What a taxi company really lives on is the few minutes between an order coming in and a driver taking it, and pretty much everything under this platform is there to keep that gap short. It replaced a system from 2008 that needed an actual Windows machine to run. Express Soft runs in the browser and on a Kubernetes cluster. I worked on it from 2021 as the architect, the planner and one of the developers.
The plan
We started in 2021. By then I had already worked at Mobilauto for about a year, on Cabinet24, and I'd proved to be a trusted person there. So the roles on the new project landed on me: developer, head planner, the one who owns the general architecture. Ukrainian businesses do save money by not hiring a separate person for each of those seats, and any business wants that kind of job in hands it already trusts. Both were true here. I was really grateful to be that person, and it was also tough, for sure. Drawing the general architecture while speaking with new recruits and screening them, and then planning how the whole thing should be deployed in the cloud, all wanted my attention in the same weeks.
By the middle of 2021 we had a solid plan. The architecture was drawn, the libraries for the frontend side and the backend side were picked, and we knew how the parts connect before the first service got written. The project split into the mobile apps for Android and iPhone, the taxi backend, the frontends, and a general backend for the Company itself.
In early 2022, after the full-scale invasion, money flows at Mobilauto were cut and working contracts were paused. Work stopped for most of that year and then resumed.
The driver app
ES Driver is React Native with TypeScript, on Android and iOS, and I treated it as a safety product. The app is literally the only thing that connects a driver and a passenger, and it's the line between a great trip and a life-threatening one. Danger can come from either side. A driver can take the passenger into the forest instead of to the address. A passenger can be aggressive or just have a really bad day. In this sort of business bad things do happen, so you think about them before you build anything.
That's why the app tracks the driver's position in the background. A position goes out every few seconds, at an interval the server hands down. Connection in some areas is really bad. Every position fix that fails to send goes into a queue on the phone, and the next send that gets through sends the whole backlog plus the current position. With that queue and one persistent connection, the app handled 3G and even 2G.
flowchart LR
P[Driver phone] --> F[Position fix]
F --> N[Network check]
N -->|no link| Q[On-device queue]
N -->|link| C[Persistent connection]
Q --> C
C --> M[Mobile entry point]
M --> I[Live position index]The app has an SOS control. The driver holds it to raise an alarm, a mechanism prevents an accidental press, so a phone loose in a pocket can't call for help on its own, and the phone vibrates to say the alarm went out. The server turns it into an emergency-priority message and a modal on the dispatcher's screen with the driver's name, the car call sign and the phone number. The dispatcher has to confirm reading it, and the platform records who did. A road accident is the common case. Aggressive passengers are the rarer one.
Around that sits the everyday work of a shift. A driver registers in a sector and sees the queue, gets offers automatically or picks up a free order, looks at the order map and filters by sector, radius, cost and distance. There's a software GPS taximeter, a handoff to Google Maps, Waze or City Guide for navigation, balance top-up by card, a shift journal, parking-rank queues tied to sectors, and a chat with the dispatcher with read receipts on both sides.
The passenger app
For passengers the tracking problems mostly fade, and a different set of problems shows up at the same level of complexity. ES Client finds the pickup by GPS, searches addresses, shows the car live on the map, runs several orders at once, keeps trip history with reorder, lets the passenger pick a city and pay by cash or card. A taxi company can publish it under its own name on Google Play and the App Store.
Car classes are highly configurable, and a class is really a routing decision. Each one carries a priority, its own switches for automatic distribution, the broadcast wave and online ordering, and the rules that decide which drivers an order of that class can reach. A fresh install of the full taxi solution seeds a starting set, and after that a company adds, renames and removes classes as it likes. A passenger can also raise the fare to be picked up sooner, up to a ceiling the company sets as well.
There are four ways to pay: cash, which a new install starts on, card, a payment link, and a corporate account. Card payment is a switch per company and per branch, and a company enters its own merchant account and attaches it to the whole company or to single branches. The driver commission can differ between cash and non-cash orders. Corporate customers are counterparties with contracts. The taxi company writes a contract for the client, issues cards to the client's employees, and contracts and cards both carry spending caps by period, per trip, by trip count and by distance.
Creating an order touches nobody's card. The hold comes when a driver accepts: the order moves into card validation, and the platform holds the trip cost when there's a route distance, or a per-operator default when there isn't. If the hold fails, the order closes. From there a row of payment states tracks the order, and a cancellation gives the held money back.
Passengers can keep preference and block lists over drivers, and there are loyalty plans, promotions and ride points. A small web app lets a passenger delete an account, behind a captcha, with a one-time code.
The backend
A taxi backend is a notorious thing to build. It has to be fast, and it has to run somewhere you can actually operate. For me that meant Kubernetes only. By that point I'd decided everything I build goes to Kubernetes: if you can put something in Docker, you can put it in Kubernetes, and it gives you so much control. It also gives you a pretty big headache when something breaks, of course. But if you do the wiring properly the first time, you can debug everything and keep working on the product instead of chasing the next networking issue.
Each taxi company gets its own isolated install on a Kubernetes cluster, packaged as one release. One settings file carries every service, dependency and secret, and a seeding job fills a fresh install with defaults before anyone signs in.
Clients come in through two entry points built from one gateway, one for browsers and one for phones. Behind it there's a service for each part of the job, and they talk only over a message bus. I selected KubeMQ. The bus client handles a lot on its own. Every send opens a tracing span and carries the trace context inside the message, so one order's path across the services reads as a single trace in OpenTelemetry. A fire-and-forget send that times out lands in a durable error queue, where it can be pulled back. The call timeout and the reconnect interval are set once, in the shared client, for every service.
The services carry a lot more than dispatch: orders, driver offers, the live map, telephony, payments, the fleet, chat, shift journals, weekly schedules, corporate accounts and passenger block lists. Under them sit four data services. PostgreSQL with PostGIS holds the model, and it answers the real spatial questions, since parking ranks, price zones and sectors are polygons: which zone a point is in, which sectors a whole route crosses, how far things are. Elasticsearch keeps the live positions as geo points, one index for drivers and one for passengers, both queried by radius and searchable right after a write. MinIO holds the files. Redis is the cache and the shared store.
flowchart TB
DB[Dispatcher browser] --> WE[Web entry point]
AP[Driver and passenger applications] --> ME[Mobile entry point]
TE[Telephony entry point] --> BUS[KubeMQ message bus]
WE --> BUS
ME --> BUS
BUS --> SVC[Orders, driver offers, live map, telephony, payments, fleet and the rest]
SVC --> PG[PostgreSQL with PostGIS, the ledger and the records]
SVC --> RD[Redis, snapshots, balances and live offers]
SVC --> EL[Elasticsearch, driver positions]
SVC --> MO[MinIO, files]The transport
For the transport I used prpcow, my own package. Cabinet24 opened one WebSocket per table, and when we counted how many that would make for the new project at the same pace, it was clearly no way to go. GraphQL was really popular in 2021, but its subscriptions back then sent the full object with no deltas, and with a lot of tables, deltas are the only thing that keeps frontend memory usage sane. We needed something easy to add to any device, easy to maintain, quick to grow APIs on, and easy to reach from the deepest part of an app.
So the gateway runs a prpcow WebSocket server, and the dispatcher app and the driver app run its client. On a thin link that means paying for one connection instead of a handshake per call. On connect the gateway checks the session token and hands the client a live model of server functions it can call directly, and the routes come over lazily. Calls go both ways, so the server can push without being asked, and a session survives a reconnect. A big reply gets split: the first message carries the total size, the rest arrives in parallel and gets put back together before anything above sees it. On the dispatcher side, @node-elion/syncron, another package of mine, delivers subscription updates as deltas.
The offer loop
The distribution service runs on a timer that wakes up every 3 seconds. Each round it expires the offers nobody answered, revives preliminary orders, recreates expired manual offers, pings the drivers on offers still open, collects the open orders, then ranks drivers and sends offers per branch in parallel. That loop worked through thousands of orders a day across the installs. Before the service went to production we load-tested it with 100 drivers signing on at once, and it held.
There are four distribution modes: a parking queue, GPS, parking then GPS, and GPS then parking, so a company that runs its fleet by sectors distributes by sectors. Order chains carry their own weights. In GPS mode, by default, it looks within 5 km, gives a driver 45 seconds to take the offer, ranks by class priority and driver rating, adds a capped bonus for idle hours, takes a capped penalty for a negative balance, and re-checks open offers every few seconds. A fresh install has to distribute orders on the day it's switched on, before anybody opens the settings, and after that each of those values is a company setting a taxi company tunes for itself.
flowchart LR
T[3 second tick] --> E[Expire stale offers]
E --> O[Collect open orders]
O --> D[Available drivers with coordinates]
D --> R[Rust distance filter, 5 kilometer default radius]
R --> K[Rank by priority, rating, idle time and debt]
K --> W[Offer to one driver, 45 second default window]
W --> A[Accepted]
W --> TThe loop itself is TypeScript. The math under it, distance, route length, whether a point sits inside a polygon, is Rust compiled to WebAssembly, and it runs the driver radius filter, the parking-rank distance and the nearest-sector distance. When we planned the project we ran some samples, and it was clear basic geometry wouldn't go great on a common JavaScript engine. This is mission-critical math, so it went to the fastest and safest language we had. We picked WebAssembly over a native C++ addon, and a teammate wrote the geometry in Rust.
To let TypeScript import Rust like any other module I wrote rust-wasmpack-loader. Without it, every build meant running wasm-pack by hand, parking the output where the bundler can see it and wiring the bindings. The loader does that at build time: it finds the crate on its own, writes the types beside the output, and hands back JavaScript that exposes the Rust exports. It started as a simple script, and as the amount of Rust in the project grew it became its own library, first published in 2022 under MIT. It supported Node.js first, then the browser, and grew to cover 8 build tools.
The money ledger
The amount of money control a taxi platform needs is just insane. We ended up with a ledger that works like a bank's, where a movement of money is a stored operation.
Three things carry it. An account comes in a main form and a bonus form for every owner in the system: customers, drivers, agents, counterparties, branches and the company itself. An operation groups one movement of money and carries its kind, and there are nine: a commission fee, a discounted commission fee, a subscription fee, an agent reward, a compensation, a balance correction, a top-up, a withdrawal and a transfer. A transaction is one signed step inside the operation, written once. It carries a direction and a status, so a cancelled movement stays on the record as a cancelled row.
A movement is a chain of those steps inside one database transaction, so either all rows land or none do. Before the chain opens, a lock takes the two accounts in a fixed order, so two movements over the same pair can never deadlock each other, and the lock has its own timeout, so a stuck pair can't hold everything behind it. With a lot of users, three changes to one number in a row can break the final result, and this structure means we always know the exact amount.
flowchart TB
E[Money event] --> L[Lock the account pair]
L --> T[Open one database transaction]
T --> O[Write the operation]
O --> R[Write each signed transaction, with the amount before and after]
R --> C[Commit, then write the new total to the cache]
C --> B[A balance read returns the cached total]Every transaction row stores what the account held before it and after it, so the whole history proves the total, and it's an audit trail nobody has to rebuild. A balance comes from redoing the math over the operations, a bit like a blockchain does. Period reports don't take any cached number on trust: they replay the history by aggregation over the date range. A ranked list of accounts reads the newest transaction of each one and sorts on what it left behind.
Calculating the exact amount every time is a separate problem, and the cache takes it. After commit, each touched account's new total goes to the cache, together with a snapshot and a change event for anything watching, and a live balance read comes from there, since a dispatcher screen asks for it constantly.
The cache
At this project size a cache is a necessity. A balance is computed, so without a cache every screen that shows one would ask the ledger to add up history first. An order isn't one row either. One order read pulls 49 related records, from the customer and the passengers down to a vehicle chain nine levels deep: the car, its color, its class, its body type, its model, its brand. A dispatcher screen is a table full of those, refreshing while the dispatcher watches, and doing that in the database every second is too much.
So reads go to Redis first. A hit returns the whole assembled order. A miss runs the big query once, writes the snapshot back, and publishes the change to every open session, so every dispatcher looking at that order sees it move at the same moment.
The cache holds a snapshot per entity with a version on it, the recent change history per entity, the current offer for each order, the broadcast state, and the balances with the time of their last write, read back in bulk in one call. Driver coordinates, live and historic, sit in Elasticsearch instead. The cache ran with plenty of memory room, an append-only file for durability and no eviction, so when it's full it refuses a write instead of quietly dropping a key something still depends on.
The dispatcher workstation
ES Cabinet, the dispatcher app, is a dockable operator workstation, a bit like a trading terminal. A dispatcher drags the panels where they want them, and the layout stays that way. The widgets dock anywhere: the order table, order details, a live map and a second map beside it, drivers, parking ranks, chat, a softphone, a file previewer and a generic table. Around it sit an orders board, accounting, archives, customers, a blacklist, counterparties, reports and a settings tree deep enough to hold every default in this story.
It does a lot for the dispatcher on its own: caller ID with frequent-address detection, fare calculation on licensed maps, automatic order distribution by sector or GPS, automatic customer notification by call or SMS, built-in telephony, a live driver map with GPS track recording, reports, loyalty programs, the order exchange between taxi companies, and driver payments by terminal or card.
The browser calls server functions directly over prpcow and holds a live model of what the server can do. Subscriptions arrive as deltas through syncron, so the server pushes only what changed, and they re-subscribe when their arguments change, dropping any subscription a newer one has replaced. The map is Leaflet, driven by a controller class, since a screen full of cars moving at once isn't something you fix with re-renders. It has car markers, heading arrows, route lines, a history player with transport controls, and a focus mode that locks the view on one driver. The softphone widget is a real phone inside the app, with a dial pad, a call bar, a caller list and a call journal grouped by day.
The team ran performance campaigns on it: virtual lists where the tables ran long, re-render work in the order dialog, and a dense optimization drive across the order page, chat, navigation, tables, routes and panel layout. All of it sits on a shared in-house React design system, with primitives at the bottom, a map and a map polygon editor at the top, hooks in between and a Storybook page for every piece.
The Company's service
The Company runs its own service over all of this, and it answers which operators run the platform, which of them paid, and on what terms. It lists the taxi companies with search and filters, an activation date, a last-payment date and a payment mode, prepayment or postpayment. There's one install per operator, and each account record carries its own database connection settings. That's also where the Company sees who needs to be switched off.
The rates the Company charges can differ per taxi company, and there are three models. A periodic charge runs by year, month, week or day, with month by default. A per-driver cap counts a driver as active by trips over a period the plan picks and adds a price per driver above the cap. A per-plan split puts services into included ones and paid add-ons, each add-on with its own price and interval. The commission and subscription model got its own written explanation inside the project.
The interchange server
The last large system we built into the platform was the interchange server. The case for it is pretty ordinary: the order is here, nobody local will take it, and a company across town has a free car. Some taxi companies have agreements with each other, and the interchange server lets them exchange orders. Each taxi service has one opaque global pointer, and the interchange server owns the only map from a pointer to the install behind it. About a dozen operator installs were registered in that map.
An order that finds no driver in its own free wave gets promoted a stage at a time by an escalation timer. The first stage is the other taxi services inside the same install, the second is the other operators. By default that's a minute to the first stage and two minutes to the second, and both are company settings. At the second stage the order takes a global identifier, and the interchange server resolves each destination and sends the whole order to that install on its private channel.
The receiving install creates a local order of its own with the same global identifier, marks it as not its own, and copies the origin's price into an external field and the passenger in as an external customer. Car classes and services translate through a mapping table per pair of companies, and if no class maps, the platform refuses the order instead of guessing one. After that it's an ordinary order: the partner's dispatchers see it on their board, the partner's drivers get the offer, and one of them takes it.
flowchart TB
A[Order taken at company A] --> NF[No driver found in the free wave]
NF --> ES[Escalation timer promotes the order to the partner stage]
ES --> SB[Company A's install calls the shared exchange bus]
SB --> DIR[Interchange directory resolves the partner route]
DIR --> CP[Company B's install creates a linked copy, mapping car class and service]
CP --> DR[A driver at company B accepts it]
DR --> CL[The origin closes its own order and every remaining copy is hidden]When one side closes its order, the interchange server hides every remaining copy. The trip runs on the partner's platform, with the partner's own driver and car, and the live status and the final cost stay there.
The platform sells at express-soft.org.
Stack
- TypeScript
- Node.js
- React
- Redux Toolkit
- Leaflet
- Storybook
- React Native
- Rust to WebAssembly
- rust-wasmpack-loader
- PostgreSQL
- PostGIS
- Elasticsearch
- Redis
- MinIO
- KubeMQ
- Asterisk
- Docker
- Kubernetes
- Helm
- OpenTelemetry
- prpcow
- @node-elion/syncron
- Android
- iOS
Credits
- Express Soft
- ES monogram
- Screens of the three Express Soft applications
Related work
- MobilautoUkrainian taxi dispatch software, sold since 2008. I worked across the platform from 2020 to 2022
- Cabinet24A cloud PBX with a light CRM in one browser cabinet, rewritten and optimized into a platform that's ready to go
- PprpcowAn MIT WebSocket RPC library in TypeScript that I put into production inside a commercial taxi dispatch platform
- Rrust-wasmpack-loaderCompiles a .rs file to WebAssembly at build time and returns JavaScript, so Rust imports like any other module across 8 build tools