⚡ Front-end Real‑time SMS Listener: Push Verification Codes to the Browser with WebSocket

A passionate live‑coding guide by a front‑end engineer who hates polling. We'll make SMS actively kick your browser with fresh codes.

The Pain Point: Polling Is Like Checking Your Mailbox Every Second

You've built a Python script that reads SMS from a SIM800L module via serial. Now you want a web dashboard to show incoming verification codes. The old way? Set a setInterval to hit /sms/latest every 2 seconds. It works — but it's wasteful, slow, and makes your backend wake up constantly for empty responses. Imagine waiting for a package and walking to the front door every 10 seconds even when the postman hasn't arrived.

What if the postman could ring your doorbell instead? That's WebSocket. The server pushes an event the moment an SMS arrives, and the browser reacts instantly. No wasted requests, sub‑second latency, and a delightful user experience.

Architecture Evolution: From Pull to Push

Traditional Polling: Browser ---GET /latest---> Flask API ---check queue---> empty? return "no message" (repeat every N seconds) ↳ high overhead, max N-second delay WebSocket Push: SIM800L → serial thread → Queue → Socket.IO Server ╌╌╌╌> Browser (event: 'new_sms') ↑ keeps TCP connection open

With WebSocket, the data path becomes a real‑time pipeline. The moment our Python serial listener pushes a message into a thread‑safe queue, the Socket.IO handler grabs it and emits it to all connected clients. That's what we're going to build today.

🛠️ The Full Tech Stack

🔥 Backend: The Real‑time Server

Create a new file websocket_server.py. It wraps our serial listener and exposes Socket.IO events.

from flask import Flask, render_template_string
from flask_socketio import SocketIO, emit
from serial_reader import message_queue, start_reader
import threading
import time

app = Flask(__name__)
app.config['SECRET_KEY'] = 'super-secret-sms-key'
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading')

# Background task that polls the queue and broadcasts new SMS
def background_sms_broadcast():
    while True:
        while not message_queue.empty():
            msg = message_queue.get_nowait()
            # Extract verification code with regex
            import re
            code_match = re.search(r'\b(\d{4,6})\b', msg.get('text', ''))
            msg['code'] = code_match.group(1) if code_match else None
            # Emit to ALL connected clients (or use rooms for isolation)
            socketio.emit('new_sms', msg)
        socketio.sleep(0.1)  # non‑blocking wait

@socketio.on('connect')
def handle_connect():
    print('⚡ Client connected')
    emit('status', {'msg': 'Connected to SMS relay'})

@socketio.on('disconnect')
def handle_disconnect():
    print('🔌 Client disconnected')

@app.route('/')
def index():
    # We'll serve the HTML file inline
    return HTML_CONTENT  # defined below

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--port', default='/dev/ttyUSB0')
    args = parser.parse_args()
    # Start serial reader in background thread
    start_reader(args.port)
    # Start the background broadcaster
    socketio.start_background_task(background_sms_broadcast)
    # Run the server
    socketio.run(app, host='0.0.0.0', port=5000, debug=False)

Notice the cors_allowed_origins='*' – without it, if your frontend is served from a different port or domain, the browser will block the WebSocket handshake. We also used async_mode='threading' for simplicity; for production with many concurrent clients, swap to eventlet or gevent.

🎨 Frontend: The Live Dashboard

Now for the juicy part — a complete HTML page that connects to our Socket.IO server and displays SMS in real‑time with a slick animation.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SMS Live Monitor</title>
    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <style>
        body { font-family: 'Inter', sans-serif; background: #0f172a; color: #e2e8f0;
               display: flex; flex-direction: column; align-items: center; padding-top: 3rem; }
        .card { background: #1e293b; border-radius: 14px; padding: 18px 24px; width: 360px;
                box-shadow: 0 4px 12px rgba(0,0,0,0.4); margin-bottom: 16px;
                transform: translateY(20px); opacity: 0; transition: all 0.35s ease; }
        .card.show { transform: translateY(0); opacity: 1; }
        .sender { font-weight: 600; color: #818cf8; }
        .code { font-size: 2.2rem; font-weight: 700; color: #facc15; letter-spacing: 3px; }
        .text { color: #cbd5e1; margin-top: 6px; }
        .time { font-size: 0.8rem; color: #64748b; }
        .copied { color: #4ade80; margin-left: 8px; font-size: 0.8rem; display: none; }
        .status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
                       background: #4ade80; margin-right: 6px; }
        #status { margin-bottom: 20px; }
    </style>
</head>
<body>
    <div id="status"><span class="status-dot"></span> Connected</div>
    <div id="message-container"></div>

    <script>
        const socket = io('http://localhost:5000', { transports: ['websocket', 'polling'] });
        const container = document.getElementById('message-container');

        socket.on('connect', () => {
            document.getElementById('status').innerHTML = '<span class="status-dot"></span> Live';
        });

        socket.on('disconnect', () => {
            document.getElementById('status').innerHTML = '<span style="color:red">●</span> Disconnected';
        });

        socket.on('new_sms', async (msg) => {
            const card = document.createElement('div');
            card.className = 'card';
            const codeHTML = msg.code ? `<div class="code">${msg.code}</div>` : '';
            card.innerHTML = `
                <div class="sender">${msg.sender || 'Unknown'}</div>
                ${codeHTML}
                <div class="text">${msg.text}</div>
                <div class="time">${new Date(msg.time*1000).toLocaleTimeString()}</div>
                <span class="copied" id="copy-${msg.time}">Copied!</span>
            `;
            container.prepend(card);
            // Trigger animation
            requestAnimationFrame(() => card.classList.add('show'));

            // Auto‑copy code if present
            if (msg.code) {
                await navigator.clipboard.writeText(msg.code);
                const copyEl = document.getElementById(`copy-${msg.time}`);
                if (copyEl) { copyEl.style.display = 'inline'; setTimeout(() => copyEl.style.display = 'none', 1500); }
            }
        });

        // Reconnection logic is built‑in, but we can tune it
        socket.io.on('reconnect_attempt', (attempt) => {
            console.log(`Reconnection attempt ${attempt}`);
        });
    </script>
</body>
</html>

This page connects to our backend using the Socket.IO client. When a 'new_sms' event fires, it creates a stylish card with the sender, extracted code, full text, and timestamp. The verification code is automatically copied to the clipboard — a tiny UX delight!

🎬 The Full Demo: See It in Action

Here’s how you test the full stack:

  1. Ensure your SIM800L is powered and connected via USB‑TTL.
  2. Run the server: python websocket_server.py --port /dev/ttyUSB0
  3. Open your browser to http://localhost:5000. You should see the green “Connected” indicator.
  4. Use another phone to send an SMS to the module’s number. In less than a second, the card slides in with the verification code highlighted and copied to your clipboard.

The magic is real. No page refresh, no polling. The instant the serial thread reads the message, the WebSocket pushes it across the wire.

💥 Three Debugging Nightmares & Their Cures

1. CORS & 403 Forbidden during Handshake

Symptom: Browser console shows WebSocket connection failed: Error during WebSocket handshake: Unexpected response code: 403. You double‑check the URL and port — everything seems fine. Root cause: Flask‑SocketIO, by default, restricts CORS. If the HTML file is loaded from a different origin (even localhost:3000 vs localhost:5000), the server rejects the upgrade. Fix: Set cors_allowed_origins='*' in the SocketIO constructor. In production, lock it down to your actual domain.

2. Disconnections & Silent Reconnect Loops

Symptom: The connection drops when the server restarts or network glitches, but the frontend never fully recovers. You see infinite reconnect attempts. Diagnosis: Socket.IO client automatically retries, but if the backend rejects the new connection (e.g., session state mismatch), the loop continues. Enable debug logs: localStorage.debug = '*'; in browser console. Fix: On the server, handle disconnect gracefully with no stale session expectations. Use @socketio.on('disconnect') only for cleanup. Also, configure client reconnection delay: reconnectionDelayMax: 5000.

3. Room Isolation for Multiple Users

Scenario: You have two browser tabs open. A new SMS pops up in both — annoying. You want each tab to have its own isolated feed. Solution: When a client connects, assign a unique session ID (or room name) and join it: from flask import session; socketio.emit('new_sms', msg, room=request.sid). The background broadcaster must know which room to target. For a single‑user dashboard, however, shared broadcast is fine.

🔒 Transport Upgrade & Fallbacks

Socket.IO starts with HTTP long‑polling by default and then upgrades to WebSocket if possible. You can watch the handshake in the browser’s Network tab: a request with 101 Switching Protocols confirms the upgrade. If the browser or proxy blocks WebSocket, Socket.IO seamlessly falls back to long‑polling — but the user experience remains the same because our code listens to 'new_sms' events regardless of the transport.

To enforce WebSocket‑only for speed, set transports: ['websocket'] on the client, but keep a fallback for compatibility. Our example above tries WebSocket first, then polling.

🚀 Beyond the Basics: Where to Take This

By marrying a $5 GSM module with WebSocket, you’ve built a real‑time SMS pipeline that feels like a modern notification system. No more blind polling, no more refreshing. Just clean, instant delivery that makes your inner engineer smile.