Reservation State Can't Live in a UI Countdown
Mobishare's vehicle-reservation timer was a singleton service holding one connection ID for the entire app, with expiry driven by client-side JavaScript. Here's why that's a concurrency bug, not a UI detail.
Reserve a vehicle in MobiShare and you get 60 seconds to start the ride before it’s supposed to free up again. Watch a demo of it working and it looks entirely correct: reserve, watch the countdown, either start the ride or let it expire. What that demo can’t show you is that the countdown only exists for one user at a time, for the entire running application.
A resource with two different owners of “is it still mine”
Split the reservation feature into its two halves and they were built to two different standards. The write is a real, per-vehicle database transition — Free -> Reserved — and that part is correct: any number of vehicles can be reserved by any number of users concurrently, each row independent of the others. The expiry of that reservation, the part that’s supposed to undo an abandoned one, is a different piece of code entirely, and it wasn’t built with the same care:
public class TimerService
{
private System.Timers.Timer? _timer;
private string? _ownerConnectionId;
private readonly TimeSpan _duration = TimeSpan.FromSeconds(60);
public async Task Start(string connectionId)
{
// Se il timer è già in uso da un altro utente, ignora la richiesta
if (_ownerConnectionId != null && _ownerConnectionId != connectionId)
return;
_ownerConnectionId = connectionId;
_startTime = DateTime.UtcNow;
// ... ticks every second, sends "ReceiveTime" to that one connection
}
}
Registered with builder.Services.AddSingleton<TimerService>() — one instance of this class exists for the whole server process, and its one _ownerConnectionId field can hold exactly one value. The comment in the code says the quiet part out loud: if the timer’s already claimed by someone else’s connection, a second Start() call is just ignored. No queue, no per-vehicle instance, no error surfaced to the second caller — the request silently does nothing.
What actually happens with two people in the demo at once
Reserve a vehicle from two browser tabs — two different users, two different vehicles, both legitimately reserved in the database — and only the first tab to call StartTimer() gets a working countdown. The second reservation is a completely valid row in the Vehicles table with Status = Reserved, and there is no countdown for it anywhere: not silently running server-side, not queued, nothing. The UI for the second user either shows nothing changing or shows the first user’s countdown, depending on how the SignalR group happens to be wired — either way, the thing that’s supposed to eventually free that vehicle again was never started for it.
That’s the first gap. The second is worse, and it’s not really about concurrency at all: even for the one reservation that does get a working countdown, hitting zero doesn’t free the vehicle. It sends a number to the browser. The browser’s JavaScript is what actually acts on it:
public override Task OnDisconnectedAsync(Exception? exception)
{
_timerService.Disconnect(Context.ConnectionId); // stops the *timer object*
return base.OnDisconnectedAsync(exception); // never touches the vehicle's status
}
Close the tab before the countdown reaches zero — dead battery, flaky wifi, someone just navigates away — and OnDisconnectedAsync fires, stops the Timer object, and does nothing else. There is no background sweep re-checking “has any reservation been sitting past its expiry regardless of whether a client is still watching it.” The countdown was never a lock; it was a number being pushed to a screen, and the moment nobody’s watching that screen, nothing enforces anything at all. A reserved vehicle can sit Reserved forever, permanently unbookable by anyone else, with no path back to Free except someone noticing and fixing it by hand.
Naming what actually needs fixing
The instinct when you see a Timer bug is to reach for a better timer — per-vehicle instances, a ConcurrentDictionary<int, Timer> keyed by vehicle ID instead of one shared field. That would fix the first gap and leave the second one completely untouched, because the second gap isn’t about how many timers exist — it’s about where the authoritative check lives. As long as “is this reservation still valid” is a question only the client’s JavaScript ever asks, the answer is unreliable by construction: any client that goes silent — closes, crashes, loses signal — takes the only enforcement mechanism down with it.
The actual fix has to move the source of truth server-side and make it independent of any specific connection: an ExpiresAt timestamp column on the reservation itself, checked by a periodic hosted-service sweep (or lazily, on the next read of that vehicle, whichever fits the traffic pattern better) — something that doesn’t care whether a browser tab is still open, because a database row doesn’t have a WebSocket to lose. The SignalR countdown can keep existing as a display on top of that — showing the user a number that ticks down is genuinely good UX — but it stops being anything the backend depends on for correctness.
| What I checked | What I found |
|---|---|
| Concurrent reservations the countdown can track at once | 1, globally, for the entire application |
| Server-side check that a reservation has expired, independent of any client | none |
| What frees an abandoned reservation if the tab closes before the countdown ends | nothing |
| Fix that addresses both gaps | move expiry to a durable, server-owned field (e.g. ExpiresAt), checked independently of any connection; keep the SignalR countdown as a display only |
Verdict
A countdown a user watches tick down is a display, not a lock — the moment it’s also the only thing enforcing that a resource gets released, a UI affordance has quietly become load-bearing. The tell here is architectural, and you can spot it without even running the app: the timer is scoped to a SignalR connection, when the thing that actually needs a lifetime is the reservation row in the database. Whatever tracks an expiring resource has to be keyed to that resource and durable across disconnects, tab closes, and server restarts — a System.Timers.Timer living in a singleton, keyed by connection ID, satisfies none of those properties, and no amount of making it “per-vehicle” fixes the deeper issue. If a client going silent can leave your system stuck forever, the authoritative check was never on the server to begin with — it just looked like it was, because the demo never left the countdown running long enough, or with enough concurrent users, to notice.
Related
MobiShare's Razor Pages, SignalR hub, and MQTT handler all call the app's own Web API over loopback HTTP instead of in-process — forcing a cookie-forwarding handler and an AsyncLocal hack. An accidental distributed monolith, and what it cost.
app.UseAuthentication() was never called and no API controller had [Authorize]. The whole JSON API was open to anyone. Here's how I found it, and what it actually took to fix it without breaking the app.
How I wired Ollama into a real ASP.NET Core app with native tool-calling and a hand-rolled cosine-similarity RAG — no vector DB, no cloud API — and the concurrency bug that nearly broke it.
Get new posts by email
No hype, unsubscribe anytime. · Powered by Buttondown