When you are running a car brokerage in Azerbaijan and importing salvage vehicles from the United States, your software problems look different from those of a Silicon Valley startup. You are not optimizing for a million concurrent users. You are optimizing for clarity, uptime, and the ability to fix things yourself at midnight while coordinating with an auction house twelve time zones away. That is exactly the situation I found myself in when I built AutoMakler. The platform handles everything from live auction scraping and Carfax lookups to delivery estimates and payment processing. It is a real production system serving real customers, and it runs on what most developers would call an aggressively boring stack.
The Stack That Nobody Wants to Pitch
There is no React. No Vue. No Redis, no Celery, and no WebSocket server. The backend is FastAPI with plain Python. The database is PostgreSQL. The frontend is server-rendered HTML using Jinja2 templates, Bootstrap, and a pinch of vanilla JavaScript. For scraping, I use Playwright. Everything runs as a single Python process that serves HTML directly.
There is no build step. There are no node_modules folders to audit, no transpilers to configure, and no frontend framework churn to keep up with. When I deploy, I am moving Python files and templates, not orchestrating a pipeline of bundlers. That simplicity is not a compromise. It is the entire point.
How to Queue Jobs Without a Message Broker
Scraping a live car auction cannot happen synchronously. A single scrape can take several seconds as Playwright loads the page, executes JavaScript, and extracts the data. Blocking the user while this happens is not an option. The standard playbook says to install Redis, configure Celery, and spin up a worker pool. I skipped all of it.
Instead, AutoMakler uses Postgres as its own job queue. When a user triggers a scrape, the application writes a new row into a tasks table with a status of pending. An asyncio background task picks up that row and launches the browser scrape. Meanwhile, the browser polls a lightweight endpoint every three seconds to check the status. When the row updates to completed, the page refreshes and displays the results.
This pattern works because the polling interval is short enough to feel responsive but long enough to avoid crushing the server. Three seconds is an eternity for a computer and barely noticeable for a human waiting on an external auction site. The database handles concurrency natively, and because the jobs are just rows in Postgres, I can inspect the queue with a simple SQL query instead of digging through Celery logs or Redis keys.
Keeping the Server Alive Without a Worker Pool
Browser automation is memory-hungry. Launch too many Playwright instances at once and your server will collapse. The conventional fix is a managed worker pool with concurrency limits, often backed by that same Redis and Celery combination. I use one line of Python: an asyncio.Semaphore.
The semaphore caps how many simultaneous browser instances can run. When a new scrape request comes in, it either grabs a slot immediately or waits until one opens up. All of this happens inside the same process. There is no external orchestrator to fail, no worker process to silently die, and no additional infrastructure to monitor. My memory stays predictable, and the code that protects the server is right next to the code that uses it, not hidden in a deployment manifest.
Routing Money with One Callback URL
Payment processing introduced a constraint I could not change. My payment gateway allows exactly one callback URL per merchant account, but I needed to process transactions for two separate projects through that single account. Building a second merchant profile would have meant extra fees, extra compliance, and extra paperwork that a small brokerage does not have time for.
The fix was to encode the project name directly into the order ID string before sending the customer to the gateway. When the callback hits my server, AutoMakler decodes that ID, identifies which project the payment belongs to, and routes the notification to the correct internal handler. The existing logic stayed untouched. This is additive design: I did not rewrite the payment flow, I just made the identifier carry a little more context. It is the kind of hack that looks obvious in hindsight but saves hours of architectural gymnastics.
Chat That Works Without WebSockets
Customer support chat is usually where engineers cave and add WebSockets. I needed in-app messaging, but I also needed to keep the infrastructure footprint tiny. So I reused the same polling strategy that powers the auction scrapes.
Messages are stored in Postgres. When a user sends a message, it writes to the table. The client polls for updates, and the UI reflects new messages and read receipts in near real-time. To keep this fast even as the conversation table grows, I added a Postgres partial index that only covers unread messages for active conversations. The database does not waste cycles scanning old history, and the query planner can satisfy most chat lookups with a tight index range scan.
For a support chat where a few seconds of latency is acceptable, this is perfectly adequate. The users get the feedback they need, and I never had to debug a stale WebSocket connection or manage a separate socket server.
The Honest Downsides
This architecture makes real trade-offs, and pretending otherwise would be dishonest. Polling is chatty. Every three seconds, every active client hits the server. The bandwidth and query load are higher than a persistent socket connection would demand. If the Python process restarts, any in-flight background task dies immediately because there is no external worker to pick it back up. I accept this because the tasks are small and the cost of a retry is low. A failed browser scrape can simply be re-triggered by the user.
There is also a ceiling to this approach. If AutoMakler ever needs to serve thousands of simultaneous scrapes, the single-process model with polling will strain. But that is not the business I am in. I need reliability for dozens of concurrent users, not
