Fixing Two Invisible Bugs in the iOS Trading PWA
The App
Moon Tendies — a SvelteKit 5 PWA for manual trading on iOS. Connects to the IG trading API via REST + WebSocket. Runs as a standalone PWA (no Capacitor/Ionic). Key architecture:
- AppEngine — state machine (
BOOTING->READY->BACKGROUND->RECONNECTING) - SystemController — starts/stops services on state transitions
- MarketDataPump — WebSocket feed + periodic REST history sync for chart candles
- PositionPoller — 15-second REST polling for open positions
- Typed event bus — decoupled communication between stores (
TRADE_EXECUTED,POSITION_CLOSED, etc.)
Bug 1: Account Balance Frozen After iOS Resume
The Report
Open a position, minimize the app on iOS, wait for SL/TP to hit, reopen. The position lines on the chart disappear (correct), but the account money in the HUD overlay stays at the pre-background value. Have to navigate to Accounts and back to Chart to force a refresh.
Root Cause
Two gaps in the data flow:
Gap A — No account refresh on resume. resumeFromSleep() validates the session and calls SystemController.wakeUp(), which restarts the position poller and WebSocket. But it never touches the account balance. The AccountStore.refreshActive() call was simply missing from the resume path.
Gap B — Poller doesn’t detect externally-closed positions. When the position poller discovers no positions, it calls positionStore.sync(null, null), which sets activePosition = null — the chart plugin sees this and clears the lines. But no event is emitted. The POSITION_CLOSED event only fires from the explicit user-initiated PositionStore.close() method (which calculates PnL, emits the event with { dealId, pnl }, and triggers optimistic balance update + server reconciliation). When a position vanishes externally (SL hit while backgrounded, TP hit while backgrounded, closed from another device), this entire path is bypassed.
The account balance update chain: POSITION_CLOSED event -> AccountStore.applyOptimisticUpdate(pnl) + AccountStore.pollBalanceUpdate(). No event = no update.
The Fix
Part 1 — Resume refresh (AppEngine.svelte.ts):
// resumeFromSleep(), after transitionTo('READY'):
void accountStore.refreshActive();
Part 2 — Position vanish detection (PositionStore.svelte.ts):
sync(globalPos: PositionResponse | null, localPos: PositionResponse | null) {
const prev = this.anyActivePosition;
this.anyActivePosition = globalPos;
this.activePosition = localPos;
if (prev && !globalPos && !this.isClosing) {
session.removeInitialBalance(prev.position.dealId);
bus.emit(EVENTS.POSITION_VANISHED, undefined as never);
}
}
New POSITION_VANISHED event added to the typed event map. AccountStore listens and calls refreshActive():
bus.on(EVENTS.POSITION_VANISHED, () => {
void this.refreshActive();
});
The !this.isClosing guard prevents double-firing when the user closes a position via the app (that path already emits POSITION_CLOSED with PnL for the optimistic update).
Part 1 handles the iOS-specific resume case immediately. Part 2 catches all externally-closed positions regardless of app state — including the edge case where SL/TP hits while the app is in the foreground and the poller picks it up on the next 15-second cycle.
Files Changed
src/lib/shared/constants/events.ts— addedPOSITION_VANISHEDsrc/lib/shared/types/events.ts— registered in typed event map asvoidsrc/lib/domains/trading/stores/PositionStore.svelte.ts— vanish detection insync()src/lib/domains/trading/stores/AccountStore.svelte.ts— listener forPOSITION_VANISHEDsrc/lib/core/engine/AppEngine.svelte.ts— account refresh on resume
Bug 2: Missing Bar When Loading Near a Minute Boundary
The Report
Load the chart at :59 or :01 and the previous 1-minute candle is missing from the chart. It eventually appears ~30 seconds later when the periodic history sync fires, or on app restart.
Root Cause
The history sync runs on a 1-second interval but only triggers the actual API call at the 30-second mark of each minute:
if (sec >= 30 && sec <= 35 && this.lastSyncMinute !== now.getMinutes()) {
this.lastSyncMinute = now.getMinutes();
void this.syncHistory();
}
When loading near a minute boundary, the IG API hasn’t settled the just-closed candle yet. The flow:
load()at :59 of minute M — API returns candles up to M-1 (M hasn’t settled)- Feed seeds with the last candle (M-1)
- WebSocket connects, first tick arrives for minute M+1
CandleAggregator.processTick()seestime M+1 > M-1— rolls over M-1, creates M+1- Minute M is never seen. It wasn’t in the API response, and the feed never got ticks for it.
The first-tick sync fires syncHistory(), but the API still might not have M. The gap persists until the :30 mark sync, which is up to 30 seconds later.
The Fix
After every syncHistory(), compare the last history candle timestamp to the live candle timestamp. If the gap is more than 60 seconds (one bar), aggressive-poll every second until the API catches up.
private syncInProgress = false;
private hasPreviousBarGap = false;
private startHistorySync() {
this.syncInterval = setInterval(() => {
if (this.hasPreviousBarGap) {
void this.syncHistory();
return;
}
// ... normal :30 mark sync ...
}, 1000);
}
private checkForPreviousBarGap() {
const lastHistoryTime = marketStore.bidHistory.length > 0
? marketStore.bidHistory[marketStore.bidHistory.length - 1].time
: 0;
const liveTime = marketStore.liveBidCandle?.time ?? 0;
const hadGap = this.hasPreviousBarGap;
this.hasPreviousBarGap = liveTime > 0 && lastHistoryTime > 0
&& (liveTime - lastHistoryTime) > 60;
if (hadGap && !this.hasPreviousBarGap) {
log.info('[MarketDataPump] Previous bar gap filled');
}
}
syncHistory() got a syncInProgress concurrency guard (since 1-second polling can overlap with async API calls) and calls checkForPreviousBarGap() in its finally block.
Typical gap lifetime: API usually settles the bar within 2-5 seconds. So instead of a 30-second visible gap, it’s 2-5 seconds and usually unnoticeable.
Files Changed
src/lib/domains/market/services/MarketDataPump.ts— gap detection + aggressive sync + concurrency guard
Lessons
-
Event-driven architectures have blind spots. The position close flow was well-designed for user-initiated closes (optimistic update, PnL calculation, reconciliation polling). But the “position disappeared from the server” case was a completely separate code path (poller -> store.sync) that had no event emission. The two paths need to converge at the notification layer.
-
Resume is not just reconnect. Restarting WebSocket + polling services is necessary but not sufficient. Any derived state (account balance, unrealized PnL) that could have changed while backgrounded needs an explicit refresh. iOS PWAs freeze JS entirely — there’s no background execution to keep state current.
-
APIs have settlement delays. The IG API doesn’t instantly serve a candle the moment it closes. Near minute boundaries, the just-closed bar might not exist in the response yet. Aggressive retry with backoff (in this case, 1-second polling) is the right pattern — the data will appear shortly, you just need to keep asking.