Migration Guide: REST Polling to WebSocket
This guide walks you through migrating from REST polling to source-aware WebSocket delivery. The goal is to avoid unnecessary polling and detect an OilPriceAPI update without waiting for the next poll. It does not make an upstream source publish more frequently.
Why Migrate?
The Problem with Polling
REST polling creates several challenges:
sequenceDiagram
participant App as Your App
participant API as OilPriceAPI
loop Every 60 seconds
App->>API: GET /v1/prices/latest
API-->>App: Price data (maybe unchanged)
Note over App: 30-60 second latency<br/>to detect price changes
end
Note over App,API: 1,440 API calls/day<br/>~43,200 calls/month<br/>per commodity
The WebSocket advantage
WebSocket pushes a payload after OilPriceAPI receives an upstream update:
sequenceDiagram
participant App as Your App
participant WS as WebSocket
App->>WS: Connect once
WS-->>App: Connection confirmed
Note over App,WS: Persistent connection
WS-->>App: Price update after source publication
WS-->>App: Price update after source publication
WS-->>App: Keep-alive ping during a quiet period
Note over App,WS: Push delivery<br/>REST backfill still required after disconnects
Side-by-Side Comparison
| Metric | REST polling | WebSocket |
|---|---|---|
| Source freshness | Determined by the upstream source | Determined by the same upstream source |
| Detection delay | Bounded by your poll interval after OilPriceAPI receives an update | Push after OilPriceAPI receives an update; measure at your client |
| Quota usage | One request per poll, including unchanged responses | Persistent connection plus reconnects |
| Gap recovery | Request the next snapshot/history window | Reconnect, then request history since the last stored source timestamp |
Quota comparison
Calculate polling usage as polls per day × days × requests per poll, then compare it with the current limits on the pricing page. Do not poll more frequently than your workflow requires: repeated requests cannot make an unchanged upstream source newer. WebSocket access is currently a Professional-or-higher capability.
WebSocket value proposition:
- Detect OilPriceAPI updates without waiting for the next scheduled poll
- Avoid quota usage on repeatedly unchanged snapshots
- Keep REST history as a recovery path rather than treating the stream as exactly-once
Migration Steps
Step 1: Audit Your Current Implementation
Before migrating, document your current polling setup:
// BEFORE: Typical polling implementation
class PricePollingService {
constructor() {
this.prices = {};
this.pollInterval = 60000; // 1 minute
}
startPolling() {
// Initial fetch
this.fetchPrices();
// Schedule polling
this.intervalId = setInterval(() => {
this.fetchPrices();
}, this.pollInterval);
}
async fetchPrices() {
const response = await fetch(
"https://api.oilpriceapi.com/v1/prices/latest",
{
headers: {
Authorization: `Token ${API_KEY}`,
},
},
);
const data = await response.json();
// Check for changes
for (const price of data.prices) {
if (this.prices[price.code] !== price.price) {
this.onPriceChange(price);
this.prices[price.code] = price.price;
}
}
}
onPriceChange(price) {
console.log(`Price changed: ${price.code} = $${price.price}`);
// Update UI, trigger alerts, etc.
}
stopPolling() {
clearInterval(this.intervalId);
}
}
Checklist:
- [ ] Document which endpoints you poll
- [ ] Note polling intervals
- [ ] Identify all places where price data is used
- [ ] List any price-change detection logic
- [ ] Document error handling and retry logic
Step 2: Install WebSocket Dependencies
::: code-group
npm install @rails/actioncable
pip install websocket-client
go get github.com/gorilla/websocket
gem install faye-websocket eventmachine
:::
Step 3: Create WebSocket Service
Build a drop-in replacement that maintains the same interface:
// AFTER: WebSocket implementation with same interface
import { createConsumer } from "@rails/actioncable";
class PriceWebSocketService {
constructor() {
this.prices = {};
this.cable = null;
this.subscription = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
}
startStreaming() {
this.cable = createConsumer(
`wss://api.oilpriceapi.com/cable?token=${encodeURIComponent(API_KEY)}`,
);
this.subscription = this.cable.subscriptions.create(
{
channel: "EnergyPricesChannel",
},
{
connected: () => {
console.log("WebSocket connected");
this.reconnectAttempts = 0;
},
disconnected: () => {
console.log("WebSocket disconnected");
this.handleDisconnect();
},
rejected: () => {
console.error("Subscription rejected - check API key");
},
received: (message) => {
this.handleMessage(message);
},
},
);
}
handleMessage(message) {
if (message.type === "price_update") {
const price = message.data;
// Same change detection as polling version
if (this.prices[price.code] !== price.price) {
this.onPriceChange(price);
this.prices[price.code] = price.price;
}
}
}
// Same interface as polling version!
onPriceChange(price) {
console.log(`Price changed: ${price.code} = $${price.price}`);
// Update UI, trigger alerts, etc.
}
handleDisconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.startStreaming(), delay);
}
}
stopStreaming() {
if (this.subscription) {
this.subscription.unsubscribe();
}
if (this.cable) {
this.cable.disconnect();
}
}
}
Step 4: Implement Feature Flag
Use a feature flag for gradual rollout:
class PriceService {
constructor() {
this.useWebSocket = process.env.USE_WEBSOCKET === "true";
if (this.useWebSocket) {
this.provider = new PriceWebSocketService();
} else {
this.provider = new PricePollingService();
}
}
start() {
if (this.useWebSocket) {
this.provider.startStreaming();
} else {
this.provider.startPolling();
}
}
stop() {
if (this.useWebSocket) {
this.provider.stopStreaming();
} else {
this.provider.stopPolling();
}
}
// Delegate to current provider
onPriceChange(callback) {
this.provider.onPriceChange = callback;
}
}
Step 5: Add Fallback Logic
Implement automatic fallback to polling if WebSocket fails:
class ResilientPriceService {
constructor() {
this.webSocket = new PriceWebSocketService();
this.polling = new PricePollingService();
this.activeProvider = "websocket";
this.webSocketFailures = 0;
this.maxWebSocketFailures = 3;
}
start() {
// Start with WebSocket
this.webSocket.startStreaming();
// Monitor WebSocket health
this.webSocket.onDisconnect = () => {
this.webSocketFailures++;
if (this.webSocketFailures >= this.maxWebSocketFailures) {
console.warn("WebSocket unreliable, falling back to polling");
this.fallbackToPolling();
}
};
this.webSocket.onConnect = () => {
// WebSocket recovered, switch back
if (this.activeProvider === "polling") {
console.log("WebSocket recovered, switching back");
this.polling.stopPolling();
this.activeProvider = "websocket";
}
this.webSocketFailures = 0;
};
}
fallbackToPolling() {
this.activeProvider = "polling";
this.polling.startPolling();
}
stop() {
this.webSocket.stopStreaming();
this.polling.stopPolling();
}
}
Step 6: Update UI Components
Ensure UI handles real-time updates efficiently:
// BEFORE: UI updated on poll
function updatePriceDisplay(prices) {
for (const price of prices) {
document.getElementById(`price-${price.code}`).textContent =
`$${price.price}`;
}
}
// AFTER: UI updated on each message with animation
function updatePriceDisplay(price) {
const element = document.getElementById(`price-${price.code}`);
// Store previous value for comparison
const previous = parseFloat(element.dataset.price || 0);
const current = price.price;
// Update display
element.textContent = `$${current.toFixed(2)}`;
element.dataset.price = current;
// Add visual feedback for changes
if (current > previous) {
element.classList.add("price-up");
setTimeout(() => element.classList.remove("price-up"), 1000);
} else if (current < previous) {
element.classList.add("price-down");
setTimeout(() => element.classList.remove("price-down"), 1000);
}
}
/* Add price change animations */
.price-up {
animation: flash-green 1s ease-out;
}
.price-down {
animation: flash-red 1s ease-out;
}
@keyframes flash-green {
0% {
background-color: #00ff0033;
}
100% {
background-color: transparent;
}
}
@keyframes flash-red {
0% {
background-color: #ff000033;
}
100% {
background-color: transparent;
}
}
Step 7: Update Tests
Update tests to work with both providers:
describe("PriceService", () => {
describe("with WebSocket", () => {
let service;
let mockCable;
beforeEach(() => {
process.env.USE_WEBSOCKET = "true";
mockCable = createMockCable();
service = new PriceService();
});
it("receives real-time updates", async () => {
const priceUpdates = [];
service.onPriceChange((price) => priceUpdates.push(price));
service.start();
// Simulate WebSocket message
mockCable.simulateMessage({
type: "price_update",
data: { code: "WTI_USD", price: 75.5 },
});
expect(priceUpdates).toHaveLength(1);
expect(priceUpdates[0].price).toBe(75.5);
});
it("reconnects on disconnect", async () => {
service.start();
mockCable.simulateDisconnect();
await waitFor(1500); // Wait for reconnect delay
expect(mockCable.connectionAttempts).toBe(2);
});
});
describe("with REST polling (fallback)", () => {
beforeEach(() => {
process.env.USE_WEBSOCKET = "false";
});
// ... existing polling tests
});
});
Step 8: Gradual Rollout
- Development: Enable WebSocket for all developers
- Staging: Full WebSocket testing
- Production 10%: Enable for 10% of users via feature flag
- Production 50%: Increase to 50% if metrics look good
- Production 100%: Full rollout
- Cleanup: Remove polling code after 2-week observation period
Rollback Strategy
If issues arise, rollback should be instant:
// Environment variable toggle
// .env: USE_WEBSOCKET=false
// Or runtime toggle via API
async function toggleWebSocket(enabled) {
await fetch("/api/config", {
method: "POST",
body: JSON.stringify({ websocket_enabled: enabled }),
});
// Service picks up new config on next heartbeat
}
Rollback triggers:
- WebSocket connection success rate < 95%
- Latency p95 > 5 seconds
- Memory usage increasing over time (connection leak)
- User reports of stale data
Common Pitfalls
1. Not Handling Reconnection
Problem: Connection drops and app never reconnects.
// BAD: No reconnection logic
this.ws = new WebSocket(url);
this.ws.onclose = () => console.log("Disconnected");
// GOOD: Exponential backoff reconnection
this.ws.onclose = () => {
const delay = Math.min(1000 * Math.pow(2, this.attempts), 30000);
setTimeout(() => this.connect(), delay);
this.attempts++;
};
2. Memory Leaks from Event Listeners
Problem: Creating new listeners on each reconnect.
// BAD: Listeners accumulate
connect() {
this.ws = new WebSocket(url);
this.ws.onmessage = (e) => this.handleMessage(e);
}
// GOOD: Clean up before reconnect
connect() {
if (this.ws) {
this.ws.onmessage = null;
this.ws.close();
}
this.ws = new WebSocket(url);
this.ws.onmessage = (e) => this.handleMessage(e);
}
3. Blocking on Message Processing
Problem: Slow message processing blocks subsequent messages.
// BAD: Synchronous heavy processing
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
updateDatabase(data); // Blocking!
recalculatePortfolio(data); // Blocking!
renderCharts(data); // Blocking!
};
// GOOD: Queue and process asynchronously
const messageQueue = [];
ws.onmessage = (e) => {
messageQueue.push(JSON.parse(e.data));
};
// Process queue in batches
setInterval(() => {
const batch = messageQueue.splice(0, 100);
if (batch.length > 0) {
processBatch(batch);
}
}, 100);
4. Ignoring Connection State
Problem: Sending messages while disconnected.
// BAD: No state check
function subscribe(commodities) {
ws.send(JSON.stringify({ subscribe: commodities }));
}
// GOOD: Check state first
function subscribe(commodities) {
if (ws.readyState !== WebSocket.OPEN) {
console.error("Cannot subscribe: not connected");
this.pendingSubscriptions.push(commodities);
return;
}
ws.send(JSON.stringify({ subscribe: commodities }));
}
5. Not Testing Failure Scenarios
Problem: App works in happy path but fails in production.
// Test these scenarios:
describe("WebSocket edge cases", () => {
it("handles server-initiated disconnect");
it("handles network timeout");
it("handles invalid JSON messages");
it("handles rapid connect/disconnect cycles");
it("handles message during reconnect");
it("handles subscription rejection");
});
Migration Checklist
- [ ] Audit current polling implementation
- [ ] Verify Professional (or higher) subscription active
- [ ] Install WebSocket dependencies
- [ ] Implement WebSocket service
- [ ] Add reconnection with exponential backoff
- [ ] Implement feature flag
- [ ] Add fallback to polling
- [ ] Update UI for real-time updates
- [ ] Add connection status indicator
- [ ] Update tests for both providers
- [ ] Test in staging environment
- [ ] Plan gradual rollout
- [ ] Document rollback procedure
- [ ] Monitor metrics post-rollout
Performance Benchmarks
Record a baseline before rollout and compare these measurements in your own environment:
| Measurement | Before (polling) | After (WebSocket) |
|---|---|---|
| Source timestamp → client receipt | Measure from stored responses | Measure from delivered payloads |
| Requests/connections per day | Count successful and failed polls | Count connects, reconnects, and REST backfills |
| Missed-update recovery | Document current behavior | Verify history backfill after a forced disconnect |
| Customer-visible errors | Record timeout/quota/auth states | Record rejected/disconnected/backfill states |
Related Documentation
- WebSocket API Reference - Implementation examples
- Architecture - Technical deep-dive
- Troubleshooting - Common issues