A freight procurement platform for a large logistics business in Eastern Europe. In 2022-2023 we worked on it as a team of four. One platform connects transport procurement, real-time auctions, long-term tenders, trip allocation, transport documents, claims, carrier KPI, notifications, corporate integrations, and analytics.
The difficulty was not the number of screens. Different parts of the product needed different architecture: auctions needed real time, core operations needed transactional consistency, notifications and integrations needed asynchronous processing, and analytics needed a separate store with a different query profile. The system ended up as about 10 independently developed and deployed application components on top of PostgreSQL, Redis, RabbitMQ, ClickHouse, and S3-compatible storage.
- Industry: road freight and procurement of transport services.
- Region: Eastern Europe.
- Our engagement: 2022-2023; the product was already in production when we joined.
- Team: 4 people.
- Our scope: the real-time auction service, background workers and queues, and analytics and KPI in ClickHouse.
- Handover: a stable system handed over to the client’s in-house engineering team.
- Stack: PHP, Go (real-time auction service), Vue.js, PostgreSQL, Redis, RabbitMQ, ClickHouse, S3, WebSockets, Kubernetes.
Under the NDA, the client’s name, exact location, production interfaces, and some operating figures are not disclosed.
Scale at a Glance
- 3 user applications: shipper portal, carrier portal, and admin application.
- About 10 application components, each with its own responsibility and life cycle.
- 5 procurement scenarios: from a one-off auction to a year-long tender.
- 4+ integration classes: accounting systems, electronic documents, tracking, and notification channels.
- About 500 concurrent real-time connections at peak, after rebuilding an auction engine that used to stop responding with hundreds of participants.
- 10,000+ carriers and tens of thousands of trips a month - the platform’s current scale, reached after handover.
One Trip in Seven Places at Once
Before a single process existed, procurement was a chain of separate steps: define the transport need, find suitable carriers, communicate the terms, collect bids, compare offers, pick a winner, fix the terms, and hand the result over to operations. Most of it ran through calls, email, spreadsheets, and separate corporate systems.
The problem was not one particular spreadsheet. It started when the same trip existed at once in the email thread with the carrier, in the procurement procedure, in the accounting system, in transport documents, in a logistics coordinator’s head, in reporting, and later in a claim.
Procurement was only the start. Once a carrier was chosen, the result had to flow into trip allocation, documents, the accounting system, notifications, execution control, KPI, and, when needed, a claim.
Five Procurement Scenarios
One procurement mechanism does not fit every shipment. An urgent trip has to be covered today, a regular lane is better priced for a quarter, and some cargo can only go to an accredited pool of carriers.
1. One-off trip: real-time reverse auction
The shipper publishes a need, qualifying carriers join the auction and bid, and the market sets the price in real time. Used for one-off and urgent shipments and whenever the shipper needs the current price of a lane.
2. Fixed price: first carrier to accept takes the trip
The shipper already knows the price. Instead of an auction, an offer with a fixed rate and terms goes to a pool of carriers, and the trip goes to the first carrier who accepts it and meets the requirements. In effect, it is a quick sale of the trip at the shipper’s price.
3. Closed procurement
Procurement only among accredited or already vetted carriers. Used where working history, vehicle requirements, insurance, SLA, or special terms matter.
4. Open procurement
The opposite goal: widen competition by adding new carriers to the pool and comparing their terms with regular partners, so the list of vetted carriers does not turn into a closed club with historical rates.
5. Long-term tender
Regular lanes do not need an auction for every trip. The shipper creates a tender with lanes, expected volumes, and requirements, carriers submit offers, and the selected rates are fixed for a quarter or a year. Price alone does not decide the winner: rating, trip history, SLA, vehicle requirements, and insurance count too.
50 Users in Testing, Hundreds in Production
In testing, the auctions worked without issues: 50 users logged in, placed bids, and completed deals. The client approved the release and the platform went live. When hundreds of real participants joined the same auction at once, the system slowed down, bids stopped updating in real time, and then the platform stopped responding. That is where our work on the project began.
There were several causes, and each was invisible at small scale: the bidding engine was not designed for high concurrency, too much work ran synchronously inside user requests, database queries were not tuned for the real volume, caching was minimal, and the infrastructure could not scale horizontally.
We rebuilt the bidding engine on persistent socket connections as a separate Go service, moved non-critical operations to asynchronous processing, added Redis caching, optimized database access, and prepared the application and infrastructure for horizontal scaling.
Real-Time Auctions: One State for Every Participant
A regular page can respond a few hundred milliseconds late and the user will barely notice. In an auction, several participants watch the same state, place bids, and must see changes almost simultaneously. If one carrier sees the new state noticeably earlier than another, that is a question of whether the procedure is fair, not of interface speed.
So the auction layer ran on persistent socket connections, about 500 concurrent ones in the peak scenario. The number does not describe the load on its own: 500 users on 500 different pages and 500 participants in one auction are very different profiles. A single new bid means:
- checking permissions and auction rules;
- changing the shared state;
- recording the operation;
- broadcasting the event to connected participants;
- writing the audit record;
- triggering notifications;
- passing data to other processes;
- sending the event on to analytics.
What the Bidder Waits For, and What Not
A carrier has to wait until the bid is validated and recorded. They do not have to wait for an email to go out, a document to be generated, an analytics aggregate to update, an integration call to finish, or a secondary event to be processed. All of that moved off the hot path into RabbitMQ and background workers. Components became less coupled, and the most sensitive ones could be scaled on their own.
About 10 Application Components
Technically the platform is not one backend and one frontend. The working architecture has about ten large, independently developed components:
- Core backend / API in PHP - core business logic and transactional operations.
- Shipper portal.
- Carrier workspace.
- Admin application - system settings, users, reference data, and procedures.
- Real-time auction service in Go - socket connections, auction state, and event broadcasting.
- Background workers - asynchronous jobs and queue processing.
- Integration layer - exchange with corporate systems.
- Document processing - templates, versions, generation, and related files.
- Analytics processing - preparing events and data for ClickHouse.
- Notifications and operational events.
The goal was not to split the system into as many microservices as possible. A service boundary appeared where a component had its own responsibility, load profile, or life cycle. We do not count PostgreSQL, Redis, RabbitMQ, ClickHouse, and S3 as services: they are infrastructure under the application architecture.
PostgreSQL for Operations, ClickHouse for History
PostgreSQL held the current state: requests, auctions, bids, carriers, trips, documents, claims, users, and permissions. KPI and management analytics need different queries: long history ranges, aggregations, period comparisons, and grouping by carrier and lane. That data and those events went to ClickHouse, so dashboards did not run heavy analytical queries against the main database during live auctions.
Redis held hot data that is read far more often than it changes during auctions and in the portals. It took repeated reads off PostgreSQL but did not replace it: the main database stayed the source of truth.
Documents, KPI, and Claims in One Process
A transport document is not a file attached to a trip. Documents had templates and versions: a document appeared at a certain stage, changed, got a new version, and triggered the next actions. Files lived in S3-compatible storage, while the application managed their metadata, versions, and access.
The lowest bid does not mean the best carrier. Each carrier built up a history: trips completed, deadlines met, past results, SLA, incidents, and claims. That data fed carrier KPI, and selection weighed a carrier’s past work together with the price.
A claim is always tied to a trip, a carrier, documents, and a sequence of actions, so the claims process lived in the shared data model. Next to it sat an audit of significant operations: in a dispute you can reconstruct who did what, to which object, when, what the state was before, and which processes the action triggered.
Four Classes of Integrations
- The client’s ERP and accounting systems, including SAP: requests, reference data, lanes, rates, trip data, and documents are not entered twice by hand.
- Electronic document exchange, including the national electronic transport document system.
- Tracking: trip execution status is tied to GPS data from the road.
- Notification channels: in-app notifications, email, SMS, and corporate channels. We count this as one class of integrations, not one integration per channel.
Observability and Operations
In a distributed system, trouble rarely starts with “service unavailable”. First a queue grows, a query slows down, a downstream service takes longer to respond, lag builds up, or socket connections climb. Prometheus, Grafana, centralized logging, Kubernetes, and GitLab CI/CD let the team see degradation as it started rather than after an outage.
What We Would Do Differently
We would build the auction load model earlier. “How many concurrent users?” is not enough for a system like this. You need to know how many participants sit in one auction, how many bids arrive per minute, how many events one bid produces, how many auctions run in parallel, how many recipients each event has, which operations are synchronous, and which can move to a queue without harm. The real cost of real-time load comes from event fan-out and concurrent work on one shared state.
We would also add hot-path metrics from the start: bid processing time, real-time event delivery time, queue depth, processing lag, active socket connections, and latency of the main PostgreSQL and Redis queries.
Data and Access Control
The platform handles commercially sensitive data: carrier bids, procurement terms, lanes, organizations, transport documents, trip history, claims, and analytics.
- access separated by roles and working areas;
- user actions recorded for audit;
- documents and files stored apart from application logic;
- separate permissions for the admin application.
The project held no formal certification such as SOC 2 or ISO 27001: it was not part of the client’s requirements.
Outcome and Handover
Over 2022-2023 the platform connected the whole transport procurement cycle: the transport need, the choice of procurement mechanism, the auction or tender, carrier selection, trip assignment, documents, execution, KPI, analytics, and claims. Once the product was stable, we handed further development to the client’s in-house engineering team.
The product kept growing without us: today the network has more than 10,000 carriers and tens of thousands of trips a month go through the platform. These figures describe its current scale, not the period of our work.
A different logistics problem, a freight marketplace with a yard queue, is covered in our separate case study, and how systems are maintained after launch is described under technical support.