Build a Local Stock Ticker PWA

Build a self-hosted stock ticker PWA serving live prices over HTTPS.

Introduction

30 Second Summary

You probably check stock prices on your phone a dozen times a day. What if you could build your own live dashboard that works on every device in your house, without relying on someone else's app or server?

In this project, you will build a stock ticker dashboard that fetches live prices every 30 seconds, installs like a native app on your phone, and serves over your local WiFi from your own machine.

What You'll Build

A responsive stock watchlist powered by the Finnhub API, packaged as a Progressive Web App, and served over HTTPS from your own machine to any device on your network.

By the end of this project, you'll have:

  • A responsive dashboard showing live US stock prices with automatic 30-second refresh.
  • A fully installable PWA with offline caching via a service worker.
  • A local HTTPS server that any phone, tablet, or PC on your WiFi can access.
  • Secret Mission: Deploy the app to a Raspberry Pi for 24/7 operation as a dedicated home stock server.

Are there any prerequisites?

You should be comfortable with HTML, CSS, and JavaScript basics. No prior experience with PWAs, APIs, or Node.js is needed.

Set Up Your Tools and API Key

Your stock ticker PWA needs three things to work: a JavaScript runtime to power the server, trusted HTTPS certificates so the PWA installs on other devices, and live stock data from an API.

In this step, you'll install Node.js for the server, set up mkcert for local HTTPS certificates, and grab a free API key from Finnhub.

In this step, get ready to:
  • Install Node.js v24 LTS for running your server.
  • Install Chocolatey and mkcert for local HTTPS certificates.
  • Get a free Finnhub API key and create your project folder.
Install Node.js v24 LTS

Node.js is the JavaScript runtime that will power your HTTPS server later in the project. You need version 24 (the current LTS release) for the features this project uses.

  • Open your browser and go to https://nodejs.org/en/download.
  • Download the Windows x64 .msi installer for the v24 LTS release.
  • Run the installer and accept the default settings on each screen.
  • Click Finish when the installer completes.

Now verify the installation. You need to open a new terminal in VS Code so it picks up the updated system PATH.

  • Switch back to VS Code.
  • Open a new terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Check your Node.js version by running this command:
node --version

✔️ I see v24.x.x

Node.js v24 is installed and ready. Now confirm npm is working by running:

npm --version

You should see a version number printed. You're all set for the next substep.

ⓧ I see an older version

Your machine has an older version of Node.js installed. You need to upgrade to v24 LTS for this project.

  • Go to https://nodejs.org/en/download and download the Windows x64 .msi installer for v24 LTS.
  • Run the installer. It will upgrade your existing Node.js installation in place.
  • Close your VS Code terminal and open a new one with Ctrl+`.
  • Run node --version again to confirm it now shows v24.x.x.

ⓧ Command not found

Node.js is not installed on your machine yet. Follow these steps to install it.

  • Go to https://nodejs.org/en/download and download the Windows x64 .msi installer for v24 LTS.
  • Run the installer and accept all the default settings.
  • Close your VS Code terminal and open a new one with Ctrl+`. The new terminal picks up the updated PATH.
  • Run node --version to confirm it shows v24.x.x.

Still seeing the old version after installing?

Make sure you closed the old terminal and opened a fresh one in VS Code. Terminals that were open before the install won't see the new PATH.

If that doesn't help, try closing VS Code entirely and reopening it.

Help me troubleshoot my Node.js installation on Windows

Install Chocolatey and mkcert

Your PWA needs HTTPS to be installable on other devices. The tool mkcert generates locally-trusted SSL certificates that eliminate browser security warnings.

The easiest way to install mkcert on Windows is through Chocolatey, a package manager for Windows. First, check if you already have Chocolatey installed.

  • Press the Windows key to open your search bar.
  • Type PowerShell.
  • Right-click Windows PowerShell and select Run as administrator.
  • Click Yes on the User Account Control prompt.
  • Check if Chocolatey is already installed by running:
choco --version

✔️ I see a version number

Chocolatey is already installed. Skip ahead to the mkcert installation below.

ⓧ Command not found

Chocolatey is not installed yet. Install it by running this command in your Administrator PowerShell:

Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

What does this command do?

This is the official Chocolatey install script. It temporarily allows script execution for this session, enforces modern security protocols, and downloads the installer from Chocolatey's servers.

  • Wait for the installation to finish. You'll see several lines of output as it downloads and installs.
  • Close the PowerShell window and reopen it as Administrator (same steps as before). This refreshes the PATH so the choco command becomes available.
  • Verify Chocolatey installed by running choco --version. You should now see a version number.

With Chocolatey ready, install mkcert and set up its local certificate authority.

  • Install mkcert by running this command in your Administrator PowerShell:
choco install mkcert
  • Type Y and press Enter if Chocolatey asks you to confirm the installation.
  • Create and trust a local certificate authority by running:
mkcert -install

What does mkcert -install do?

This creates a local Certificate Authority (CA) on your machine and adds it to your Windows trust store. Later, you'll use mkcert to generate HTTPS certificates that your browser trusts without warnings.

This only affects your local machine. No certificates are sent to the internet.

  • You should see a message confirming the local CA is installed in the system trust store.

Seeing a permission error?

Make sure your PowerShell window says Administrator in the title bar. If it doesn't, close it and reopen PowerShell using the right-click Run as administrator option.

Help me fix mkcert permission errors on Windows

Get your Finnhub API key and create the project folder

Finnhub provides free real-time US stock market data. Their free tier gives you 60 API calls per minute, which is more than enough for polling 5 stocks every 30 seconds.

  • Open https://finnhub.io/register in your browser.
  • Sign up for a free account with your email.
  • After signing in, you'll land on your Finnhub dashboard. Your API key is displayed on this page.
  • Copy your API key and save it somewhere safe (a notes app or text file). You'll paste it into your code in a later step.

Why not store the key in a variable?

Since this is a local-only project that never gets deployed to the internet, you'll paste the API key directly into your JavaScript file. For production apps, you would use environment variables instead.

Now create your project folder. Switch back to the VS Code terminal you opened earlier.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir stock-ticker
cd stock-ticker
  • Open this folder in VS Code by running:
code .

What does code . do?

The code . command tells VS Code to open the current folder as a workspace. The dot (.) means "this directory."

  • If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.
  • Confirm the files exist by running:
ls

The folder should be empty for now. You'll see the VS Code Explorer sidebar showing STOCK-TICKER as the workspace name.

code command not recognized?

If code . doesn't work, open VS Code manually. Press Ctrl+Shift+P to open the Command Palette, type Shell Command: Install 'code' command in PATH, and select it. Then try again in a new terminal.

Alternatively, open VS Code, click File in the top menu, select Open Folder, and navigate to the stock-ticker folder on your Desktop.

Help me get the code command working in my terminal

Your development environment is ready. You have Node.js for the server, mkcert for HTTPS, and a Finnhub API key for live stock data. Next up, you'll build the stock ticker dashboard UI.

Build the Dashboard UI

Your development environment is ready. Now it's time to build the frontend that your stock ticker will display.

In this step, you'll create a responsive dashboard with a dark, Robinhood-inspired theme. The page will show stock cards in a grid layout, each displaying a ticker symbol, current price, and daily change.

In this step, get ready to:
  • Create the HTML watchlist layout with header, stock grid, and footer.
  • Style the dashboard with a dark theme and responsive card grid.
  • Write JavaScript to fetch and render live stock prices from the Finnhub API.
Create the HTML structure

Every web app starts with an HTML file that defines the page structure. Your stock ticker needs a header showing the app title and connection status, a main area for stock cards, and a footer showing refresh timing.

  • Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Create the public folder inside your stock-ticker project by running this command:
mkdir public
  • Confirm the folder exists by running:
ls
  • You should see public listed in the output.
  • In the VS Code Explorer sidebar on the left, expand the public folder.
  • Hover over the public folder name and click the New File icon (a page with a plus sign).
  • Type index.html and press Enter.
  • Paste the following code into public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Stock Ticker</title>
    <link rel="stylesheet" href="style.css">
    <link rel="manifest" href="manifest.json">
    <meta name="theme-color" content="#1a1a2e">
    <link rel="apple-touch-icon" href="icon-192.png">
</head>
<body>
    <header>
        <h1>Stock Ticker</h1>
        <div class="status">
            <span id="status-dot" class="dot live"></span>
            <span id="status-text">Live</span>
        </div>
    </header>
    <main>
        <div id="stock-grid" class="stock-grid"></div>
    </main>
    <footer>
        <p>Last updated: <span id="last-updated">--</span></p>
        <p>Next refresh in: <span id="countdown">30</span>s</p>
    </footer>
    <script src="app.js"></script>
</body>
</html>

What does this HTML do?

  • The <meta name="viewport"> tag makes the page scale correctly on mobile devices.
  • The <link rel="manifest"> tag connects a PWA manifest file you'll create in a later step.
  • The stock-grid div is an empty container that JavaScript will fill with stock cards.
  • The footer displays refresh timing so you always know when fresh prices are coming.
  • Save index.html with Ctrl+S.

Don't see the file in the Explorer?

Make sure you created the file inside the public folder, not at the root of stock-ticker. If it's in the wrong place, drag it into public in the Explorer sidebar.

help me move my index.html into the correct folder

Style the dashboard with a dark theme

The stylesheet gives the app a dark, professional look inspired by trading platforms like Robinhood. Green indicates positive price changes, red indicates negative, and the responsive grid adapts to any screen size.

  • Create a new file in the public folder called style.css (hover over public in the Explorer, click New File, type style.css).
  • Paste the following base layout styles into public/style.css:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #e0e0e0;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem 1.5rem;
    background-color: #16213e;
    border-bottom: 1px solid #0f3460;
}

header h1 {
    font-size: 1.4rem;
    color: #ffffff;
}

.status {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.85rem;
}

.dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
}

.dot.live {
    background-color: #00c853;
    animation: pulse 2s infinite;
}

.dot.stale {
    background-color: #ff9800;
    animation: none;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.4; }
}

main {
    flex: 1;
    padding: 1.5rem;
}

What does this code do?

The universal reset (*) removes default browser spacing so you have full control over layout. The body uses a deep navy background (#1a1a2e) with a flexbox column layout that stretches to fill the viewport.

The green pulsing dot (.dot.live) animates to show the connection is active. When prices go stale, the dot turns orange and stops pulsing.

  • Now add the stock card component styles and footer below your existing code in style.css:
.stock-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1rem;
}

.stock-card {
    background-color: #16213e;
    border: 1px solid #0f3460;
    border-radius: 12px;
    padding: 1.25rem;
    transition: transform 0.2s, border-color 0.2s;
}

.stock-card:hover {
    transform: translateY(-2px);
    border-color: #533483;
}

.stock-card .ticker {
    font-size: 1.1rem;
    font-weight: 700;
    color: #ffffff;
    margin-bottom: 0.25rem;
}

.stock-card .price {
    font-size: 1.8rem;
    font-weight: 700;
    color: #ffffff;
    margin-bottom: 0.5rem;
}

.stock-card .change {
    font-size: 0.95rem;
    font-weight: 600;
}

.stock-card .change.positive {
    color: #00c853;
}

.stock-card .change.negative {
    color: #ff1744;
}

.stock-card .change.neutral {
    color: #9e9e9e;
}

.stock-card .details {
    margin-top: 0.75rem;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: #9e9e9e;
}

.stock-card .details span {
    display: flex;
    justify-content: space-between;
}

footer {
    padding: 1rem 1.5rem;
    background-color: #16213e;
    border-top: 1px solid #0f3460;
    text-align: center;
    font-size: 0.85rem;
    color: #9e9e9e;
}

footer p {
    margin: 0.25rem 0;
}

.loading {
    text-align: center;
    padding: 3rem;
    color: #9e9e9e;
}

.error-message {
    background-color: #2d1b1b;
    border: 1px solid #ff1744;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 1rem;
    color: #ff8a80;
    text-align: center;
}

What does this code do?

The .stock-grid uses CSS Grid with auto-fill so cards automatically wrap into rows. Each card is at least 280px wide, making the layout responsive without media queries.

The .positive class uses green (#00c853) for gains, .negative uses red (#ff1744) for losses, and .neutral uses gray for no change. This matches the color conventions of real trading apps.

  • Save style.css with Ctrl+S.
  • Right-click index.html in the Explorer and select Open with Live Server (if you have the Live Server extension) or double-click the file to open it in your browser.
  • You should see a dark page with the "Stock Ticker" title in the header, a pulsing green dot, and a footer showing "Last updated: --" and "Next refresh in: 30s".

Page looks unstyled or all white?

Confirm that style.css is inside the public folder alongside index.html. The <link rel="stylesheet" href="style.css"> tag uses a relative path, so both files must be in the same directory.

help me debug why my CSS styles aren't loading in my stock ticker page

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete public/style.css file:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #e0e0e0;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem 1.5rem;
    background-color: #16213e;
    border-bottom: 1px solid #0f3460;
}

header h1 {
    font-size: 1.4rem;
    color: #ffffff;
}

.status {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.85rem;
}

.dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
}

.dot.live {
    background-color: #00c853;
    animation: pulse 2s infinite;
}

.dot.stale {
    background-color: #ff9800;
    animation: none;
}

@keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.4; }
}

main {
    flex: 1;
    padding: 1.5rem;
}

.stock-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1rem;
}

.stock-card {
    background-color: #16213e;
    border: 1px solid #0f3460;
    border-radius: 12px;
    padding: 1.25rem;
    transition: transform 0.2s, border-color 0.2s;
}

.stock-card:hover {
    transform: translateY(-2px);
    border-color: #533483;
}

.stock-card .ticker {
    font-size: 1.1rem;
    font-weight: 700;
    color: #ffffff;
    margin-bottom: 0.25rem;
}

.stock-card .price {
    font-size: 1.8rem;
    font-weight: 700;
    color: #ffffff;
    margin-bottom: 0.5rem;
}

.stock-card .change {
    font-size: 0.95rem;
    font-weight: 600;
}

.stock-card .change.positive {
    color: #00c853;
}

.stock-card .change.negative {
    color: #ff1744;
}

.stock-card .change.neutral {
    color: #9e9e9e;
}

.stock-card .details {
    margin-top: 0.75rem;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: #9e9e9e;
}

.stock-card .details span {
    display: flex;
    justify-content: space-between;
}

footer {
    padding: 1rem 1.5rem;
    background-color: #16213e;
    border-top: 1px solid #0f3460;
    text-align: center;
    font-size: 0.85rem;
    color: #9e9e9e;
}

footer p {
    margin: 0.25rem 0;
}

.loading {
    text-align: center;
    padding: 3rem;
    color: #9e9e9e;
}

.error-message {
    background-color: #2d1b1b;
    border: 1px solid #ff1744;
    border-radius: 8px;
    padding: 1rem;
    margin-bottom: 1rem;
    color: #ff8a80;
    text-align: center;
}
Add the stock-fetching JavaScript

The JavaScript file connects to the Finnhub API and turns raw price data into visual stock cards. You'll define a watchlist of ticker symbols, fetch a quote for each one, and render the results into the grid.

  • Create a new file in the public folder called app.js (same process as before: hover over public, click New File, type app.js).
  • Paste the following constants and fetch function into public/app.js:
const FINNHUB_API_KEY = 'your-api-key-here';
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
const REFRESH_INTERVAL = 30000;

const WATCHLIST = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA'];

let countdownSeconds = 30;
let countdownInterval = null;

// Fetch a single stock quote from Finnhub
async function fetchQuote(symbol) {
    const url = `${FINNHUB_BASE_URL}/quote?symbol=${symbol}&token=${FINNHUB_API_KEY}`;
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`HTTP ${response.status} for ${symbol}`);
    }
    const data = await response.json();
    return { symbol, ...data };
}

What does this code do?

The FINNHUB_API_KEY constant holds your API key (you'll paste your real key here shortly). The WATCHLIST array defines which five stock tickers to track.

The fetchQuote function calls the Finnhub quote endpoint for a single symbol and returns the response data with the symbol name attached. If the request fails, it throws an error so the caller can handle it gracefully.

  • Replace 'your-api-key-here' with the actual API key you copied from Finnhub in Step 1.

Why not use a variable placeholder for the API key?

API keys are secrets. You should never store them in a way that could accidentally be shared or committed to version control. For this local project, pasting the key directly is fine. In a production app, you'd use environment variables instead.

  • Now add the main orchestration function below your existing code in app.js:
// Fetch all watchlist quotes and render them
async function fetchQuotes() {
    const grid = document.getElementById('stock-grid');
    const statusDot = document.getElementById('status-dot');
    const statusText = document.getElementById('status-text');
    const lastUpdated = document.getElementById('last-updated');

    try {
        const promises = WATCHLIST.map(symbol => fetchQuote(symbol));
        const results = await Promise.allSettled(promises);

        grid.innerHTML = '';

        results.forEach(result => {
            if (result.status === 'fulfilled') {
                const quote = result.value;
                grid.appendChild(createStockCard(quote));
            } else {
                console.error('Failed to fetch quote:', result.reason);
            }
        });

        statusDot.className = 'dot live';
        statusText.textContent = 'Live';
        lastUpdated.textContent = new Date().toLocaleTimeString();
    } catch (error) {
        statusDot.className = 'dot stale';
        statusText.textContent = 'Error';
        console.error('Failed to fetch quotes:', error);
    }

    resetCountdown();
}

What does this code do?

The fetchQuotes function fetches prices for all five stocks in parallel using Promise.allSettled. This means if one stock fails, the others still display. It clears the grid, renders each successful result as a card, and updates the status indicator.

The resetCountdown call at the end resets the refresh timer. You'll add that function in the next step when you wire up automatic polling.

  • Finally, add the card-rendering function below your existing code in app.js:
// Build a DOM element for a single stock card
function createStockCard(quote) {
    const card = document.createElement('div');
    card.className = 'stock-card';

    const changeClass = quote.d > 0 ? 'positive' : quote.d < 0 ? 'negative' : 'neutral';
    const changeSign = quote.d > 0 ? '+' : '';

    card.innerHTML = `
        <div class="ticker">${quote.symbol}</div>
        <div class="price">$${quote.c.toFixed(2)}</div>
        <div class="change ${changeClass}">
            ${changeSign}${quote.d.toFixed(2)} (${changeSign}${quote.dp.toFixed(2)}%)
        </div>
        <div class="details">
            <span>Open: $${quote.o.toFixed(2)}</span>
            <span>Prev: $${quote.pc.toFixed(2)}</span>
            <span>High: $${quote.h.toFixed(2)}</span>
            <span>Low: $${quote.l.toFixed(2)}</span>
        </div>
    `;

    return card;
}

What does this code do?

The createStockCard function takes a quote object from Finnhub and builds a styled card element. It reads the d field (daily change) to decide whether the card shows green, red, or gray text.

The fields c (current price), o (open), h (high), l (low), and pc (previous close) come directly from the Finnhub API response.

  • Save app.js with Ctrl+S.
  • Refresh your browser. The page still looks the same (empty grid) because nothing calls fetchQuotes() yet. You'll wire that up in the next step.
  • Open DevTools by pressing F12 and check the Console tab. You should see no errors, confirming the file loaded without syntax issues.

Seeing a syntax error in the console?

Check that you have matching backticks (`) around the template literal in createStockCard. Template literals use backticks, not regular single quotes. Also confirm the ${} expressions have matching curly braces.

help me fix a syntax error in my app.js stock ticker code

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and that the console shows no errors.

ⓧ I'd like to double check the full code

Here is the complete public/app.js file so far:

const FINNHUB_API_KEY = 'your-api-key-here';
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
const REFRESH_INTERVAL = 30000;

const WATCHLIST = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA'];

let countdownSeconds = 30;
let countdownInterval = null;

// Fetch a single stock quote from Finnhub
async function fetchQuote(symbol) {
    const url = `${FINNHUB_BASE_URL}/quote?symbol=${symbol}&token=${FINNHUB_API_KEY}`;
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`HTTP ${response.status} for ${symbol}`);
    }
    const data = await response.json();
    return { symbol, ...data };
}

// Fetch all watchlist quotes and render them
async function fetchQuotes() {
    const grid = document.getElementById('stock-grid');
    const statusDot = document.getElementById('status-dot');
    const statusText = document.getElementById('status-text');
    const lastUpdated = document.getElementById('last-updated');

    try {
        const promises = WATCHLIST.map(symbol => fetchQuote(symbol));
        const results = await Promise.allSettled(promises);

        grid.innerHTML = '';

        results.forEach(result => {
            if (result.status === 'fulfilled') {
                const quote = result.value;
                grid.appendChild(createStockCard(quote));
            } else {
                console.error('Failed to fetch quote:', result.reason);
            }
        });

        statusDot.className = 'dot live';
        statusText.textContent = 'Live';
        lastUpdated.textContent = new Date().toLocaleTimeString();
    } catch (error) {
        statusDot.className = 'dot stale';
        statusText.textContent = 'Error';
        console.error('Failed to fetch quotes:', error);
    }

    resetCountdown();
}

// Build a DOM element for a single stock card
function createStockCard(quote) {
    const card = document.createElement('div');
    card.className = 'stock-card';

    const changeClass = quote.d > 0 ? 'positive' : quote.d < 0 ? 'negative' : 'neutral';
    const changeSign = quote.d > 0 ? '+' : '';

    card.innerHTML = `
        <div class="ticker">${quote.symbol}</div>
        <div class="price">$${quote.c.toFixed(2)}</div>
        <div class="change ${changeClass}">
            ${changeSign}${quote.d.toFixed(2)} (${changeSign}${quote.dp.toFixed(2)}%)
        </div>
        <div class="details">
            <span>Open: $${quote.o.toFixed(2)}</span>
            <span>Prev: $${quote.pc.toFixed(2)}</span>
            <span>High: $${quote.h.toFixed(2)}</span>
            <span>Low: $${quote.l.toFixed(2)}</span>
        </div>
    `;

    return card;
}

Your dashboard shell is complete. You have the HTML structure, the dark theme, and the JavaScript functions ready to fetch and render stock data. Next up, you'll wire the polling interval and error handling to bring this page to life with real-time prices.

Connect Live Price Polling

Your dashboard has the layout and styling ready, but right now it fetches prices once and stops.

A real stock ticker needs to keep refreshing automatically so you always see the latest numbers. In this step, you'll wire up a polling loop that fetches fresh prices every 30 seconds and displays a live countdown in the footer.

In this step, get ready to:
  • Add a countdown timer that ticks down from 30 to 0 between each refresh.
  • Understand how the error handling and status indicator protect against failed API calls.
  • Test the live polling in your browser to confirm prices load and auto-refresh.
Add the countdown timer and start polling

Your fetchQuotes() function already calls resetCountdown() at the end, but that function doesn't exist yet. You also need to kick off the initial fetch and schedule the repeating interval.

  • In app.js, scroll to the bottom of the file (below the createStockCard function).
  • Add the following code at the very end of the file:
function resetCountdown() {
    // Reset the countdown display back to 30 seconds
    countdownSeconds = 30;
    if (countdownInterval) {
        clearInterval(countdownInterval);
    }
    // Tick the countdown down every second
    countdownInterval = setInterval(() => {
        countdownSeconds--;
        document.getElementById('countdown').textContent = countdownSeconds;
        if (countdownSeconds <= 0) {
            clearInterval(countdownInterval);
        }
    }, 1000);
}

// Fetch prices immediately on page load
fetchQuotes();
// Schedule automatic refresh every 30 seconds
setInterval(fetchQuotes, REFRESH_INTERVAL);

What does this code do?

The resetCountdown() function resets the counter to 30 each time prices refresh. It clears any existing interval, then starts a new one that decrements countdownSeconds every second and updates the footer's countdown span.

The two lines at the bottom start the whole system. fetchQuotes() runs immediately so you see data on load. setInterval(fetchQuotes, REFRESH_INTERVAL) repeats the fetch every 30,000 milliseconds (30 seconds).

  • Save app.js.

Seeing a 'resetCountdown is not defined' error?

This means you didn't add the resetCountdown() function definition. Make sure you pasted the entire block above, including the function declaration, before the fetchQuotes() call.

Help me fix a resetCountdown is not defined error in my stock ticker app.

Understand the error handling and status indicator

Your fetchQuotes() function already includes error handling that protects against network failures and rate limits. Take a moment to understand how it works.

How does the status indicator work?

Inside the try block, after prices load successfully, the status dot gets the live class (green pulsing dot) and the text shows "Live". The lastUpdated span displays the current time.

If any error occurs (network timeout, Finnhub rate limit, or server error), the catch block switches the dot to stale (orange, no animation) and shows "Error". This gives you a clear visual signal that prices might be outdated.

What happens outside market hours?

US stock markets are open 9:30 AM to 4:00 PM Eastern Time. Outside those hours, Finnhub still returns data, but the d (change) and dp (percent change) fields will be 0. The c field shows the last closing price.

Your code handles this gracefully. When d is 0, the change text gets the neutral class (grey color) instead of green or red.

Test the app with Live Server

You need a local server to test the app because opening the HTML file directly can cause issues with the Finnhub API calls. The Live Server extension for VS Code handles this perfectly.

  • Install the Live Server extension by pressing Ctrl+P in VS Code and typing:
ext install ritwickdey.liveserver
  • Open index.html in the editor by clicking it in the Explorer sidebar.
  • Right-click anywhere inside the index.html editor and select Open with Live Server.

Your browser opens automatically and you should see a dark-themed dashboard with five stock cards showing live prices.

  • Watch the footer countdown tick from 30 down to 0.
  • When the countdown reaches 0, the prices refresh and the "Last updated" timestamp changes.
  • Confirm the green pulsing dot in the header shows "Live".

What should I see?

Each stock card displays a ticker symbol (like AAPL), the current price in large white text, the daily change in green or red, and four detail fields (Open, Prev, High, Low). If markets are closed, the change values show 0.00 in grey.

Stock cards not appearing?

  • Open the browser console (F12, then Console tab) and check for errors. If you see a 401 status, your API key is incorrect or missing.
  • Make sure you replaced your-api-key-here in app.js with your actual Finnhub API key from the previous step.
  • If you see a 429 status, you have hit the rate limit. Wait one minute and refresh the page.

Help me troubleshoot why my stock ticker isn't showing prices.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and confirmed the prices refresh automatically.

ⓧ I'd like to double check the full code

Here is the complete app.js file at this point:

const FINNHUB_API_KEY = 'your-api-key-here';
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
const REFRESH_INTERVAL = 30000;

const WATCHLIST = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA'];

let countdownSeconds = 30;
let countdownInterval = null;

async function fetchQuote(symbol) {
    const url = `${FINNHUB_BASE_URL}/quote?symbol=${symbol}&token=${FINNHUB_API_KEY}`;
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`HTTP ${response.status} for ${symbol}`);
    }
    const data = await response.json();
    return { symbol, ...data };
}

async function fetchQuotes() {
    const grid = document.getElementById('stock-grid');
    const statusDot = document.getElementById('status-dot');
    const statusText = document.getElementById('status-text');
    const lastUpdated = document.getElementById('last-updated');

    try {
        const promises = WATCHLIST.map(symbol => fetchQuote(symbol));
        const results = await Promise.allSettled(promises);

        grid.innerHTML = '';

        results.forEach(result => {
            if (result.status === 'fulfilled') {
                const quote = result.value;
                grid.appendChild(createStockCard(quote));
            } else {
                console.error('Failed to fetch quote:', result.reason);
            }
        });

        statusDot.className = 'dot live';
        statusText.textContent = 'Live';
        lastUpdated.textContent = new Date().toLocaleTimeString();
    } catch (error) {
        statusDot.className = 'dot stale';
        statusText.textContent = 'Error';
        console.error('Failed to fetch quotes:', error);
    }

    resetCountdown();
}

function createStockCard(quote) {
    const card = document.createElement('div');
    card.className = 'stock-card';

    const changeClass = quote.d > 0 ? 'positive' : quote.d < 0 ? 'negative' : 'neutral';
    const changeSign = quote.d > 0 ? '+' : '';

    card.innerHTML = `
        <div class="ticker">${quote.symbol}</div>
        <div class="price">$${quote.c.toFixed(2)}</div>
        <div class="change ${changeClass}">
            ${changeSign}${quote.d.toFixed(2)} (${changeSign}${quote.dp.toFixed(2)}%)
        </div>
        <div class="details">
            <span>Open: $${quote.o.toFixed(2)}</span>
            <span>Prev: $${quote.pc.toFixed(2)}</span>
            <span>High: $${quote.h.toFixed(2)}</span>
            <span>Low: $${quote.l.toFixed(2)}</span>
        </div>
    `;

    return card;
}

function resetCountdown() {
    countdownSeconds = 30;
    if (countdownInterval) {
        clearInterval(countdownInterval);
    }
    countdownInterval = setInterval(() => {
        countdownSeconds--;
        document.getElementById('countdown').textContent = countdownSeconds;
        if (countdownSeconds <= 0) {
            clearInterval(countdownInterval);
        }
    }, 1000);
}

fetchQuotes();
setInterval(fetchQuotes, REFRESH_INTERVAL);

Your dashboard is now fetching live prices and refreshing automatically every 30 seconds. Next up, you'll turn this into a full PWA that can be installed on your phone and works offline.

Add PWA Features

Your stock ticker fetches live prices and refreshes every 30 seconds. Right now it only works inside a browser tab.

To make it feel like a native app that you can install on your home screen and load offline, you need to turn it into a Progressive Web App. A PWA needs two key pieces: a manifest file that tells the browser how to install the app, and a service worker that caches files for offline access.

In this step, get ready to:
  • Create a web app manifest with app metadata and icons.
  • Build a service worker that caches the app shell for offline loading.
  • Register the service worker in your app code.
Create the web app manifest and icons

The web app manifest is a JSON file that tells the browser your app's name, colors, and icons. Without it, the browser has no way to offer an "Install" prompt.

  • Create a new file called manifest.json inside your public folder.
  • Paste the following into manifest.json:
{
    "name": "Stock Ticker",
    "short_name": "Stocks",
    "description": "Live stock prices on your local network",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#1a1a2e",
    "theme_color": "#1a1a2e",
    "icons": [
        {
            "src": "icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

What does this manifest do?

  • display set to standalone makes the app run without browser chrome (address bar, tabs) when installed.
  • start_url tells the browser which page to open when the user launches the installed app.
  • The icons array provides images at 192x192 and 512x512 pixels for home screens and splash screens.

The manifest references two icon files that don't exist yet. You'll generate solid-color placeholder PNGs using PowerShell.

  • Generate the placeholder icons by running this command in your VS Code terminal:
Add-Type -AssemblyName System.Drawing
foreach ($size in 192, 512) {
    $bmp = New-Object System.Drawing.Bitmap($size, $size)
    $g = [System.Drawing.Graphics]::FromImage($bmp)
    $g.Clear([System.Drawing.Color]::FromArgb(26, 26, 46))
    $bmp.Save("public\icon-$size.png", [System.Drawing.Imaging.ImageFormat]::Png)
    $g.Dispose()
    $bmp.Dispose()
    Write-Host "Created public\icon-$size.png"
}

What does this command do?

This PowerShell script creates two PNG files filled with your app's dark theme color (#1a1a2e). These are simple placeholder icons. For a production app you would design proper icons, but solid-color squares satisfy the PWA requirements for now.

  • Confirm the icons were created by running:
ls public\icon-*

You should see icon-192.png and icon-512.png listed in the output.

Icons not created?

  • If you see an error about System.Drawing, make sure you are running the command in PowerShell (not Command Prompt). Type powershell and press Enter to switch.
  • Ensure the public folder already exists. If it does not, create it with mkdir public first.

help me create placeholder PWA icon files

Create the service worker

A service worker is a JavaScript file that runs in the background, separate from your web page. It intercepts network requests and can serve cached files when the network is unavailable.

  • Create a new file called sw.js inside your public folder.
  • Paste the following into sw.js:
const CACHE_NAME = 'stock-ticker-v1';
const STATIC_ASSETS = [
    '/',
    '/index.html',
    '/style.css',
    '/app.js',
    '/manifest.json',
    '/icon-192.png',
    '/icon-512.png'
];

// Cache all static assets when the service worker installs
self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(STATIC_ASSETS))
    );
    self.skipWaiting();
});

// Remove old caches when a new version activates
self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => {
            return Promise.all(
                keys.filter(key => key !== CACHE_NAME)
                    .map(key => caches.delete(key))
            );
        })
    );
    self.clients.claim();
});

// Intercept fetch requests
self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    // Never cache API calls - prices must be fresh
    if (url.hostname === 'finnhub.io') {
        event.respondWith(fetch(event.request));
        return;
    }

    // Cache-first for static assets
    event.respondWith(
        caches.match(event.request)
            .then(cached => cached || fetch(event.request))
    );
});

What does this service worker do?

  • The install event fires when the browser first encounters the service worker. It opens a cache and stores all static files (HTML, CSS, JS, icons) so they are available offline.
  • The activate event fires after installation. It cleans up any old caches from previous versions by deleting anything that does not match CACHE_NAME.
  • The fetch event intercepts every network request. Finnhub API calls always go to the network (prices must be fresh). Everything else uses cache-first: serve from cache if available, otherwise fetch from network.
  • Save sw.js.

Not sure if the file is in the right place?

The service worker file must live at the root of the folder the server will serve. Confirm sw.js is directly inside public/ (next to index.html), not inside a subfolder.

help me check my service worker file placement

Register the service worker in your app

A service worker file does nothing on its own. Your app needs to explicitly register it so the browser knows to download and activate it.

  • In app.js, scroll down to the bottom of the file.
  • Find these two lines:
fetchQuotes();
setInterval(fetchQuotes, REFRESH_INTERVAL);
  • Add the service worker registration code directly above those two lines, so the bottom of your file looks like this:
// Register the service worker for offline caching
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('Service Worker registered:', reg.scope))
        .catch(err => console.error('Service Worker registration failed:', err));
}

fetchQuotes();
setInterval(fetchQuotes, REFRESH_INTERVAL);

What does this code do?

The if ('serviceWorker' in navigator) check ensures the browser supports service workers before attempting registration. Not all browsers do (particularly older ones), so this guard prevents errors.

If supported, navigator.serviceWorker.register('/sw.js') tells the browser to download and install your service worker. The .then() logs the scope on success. The .catch() logs any failures.

  • Save app.js.

What about the manifest link in the HTML?

If you check index.html, the <link rel="manifest" href="manifest.json"> tag is already there. This was added when you built the dashboard in Step 2. The browser uses this link to find your manifest file.

You will not see visible changes on the page yet since the service worker operates silently in the background. You will confirm it is working properly in the next step when you serve the app over HTTPS.

Seeing a console error about the service worker?

Service workers require either localhost or HTTPS to register. If you are opening index.html directly as a file (file:// protocol), registration will fail. This is expected. The HTTPS server in the next step will resolve it.

help me fix service worker registration errors

✔️ Awesome, I've got everything!

Great. Double check you have saved both manifest.json and sw.js in the public/ folder, and that app.js has the service worker registration block at the bottom.

ⓧ I'd like to double check the full code

Here is the complete public/app.js file:

const FINNHUB_API_KEY = 'your-api-key-here';
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
const REFRESH_INTERVAL = 30000;

const WATCHLIST = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA'];

let countdownSeconds = 30;
let countdownInterval = null;

async function fetchQuote(symbol) {
    const url = `${FINNHUB_BASE_URL}/quote?symbol=${symbol}&token=${FINNHUB_API_KEY}`;
    const response = await fetch(url);
    if (!response.ok) {
        throw new Error(`HTTP ${response.status} for ${symbol}`);
    }
    const data = await response.json();
    return { symbol, ...data };
}

async function fetchQuotes() {
    const grid = document.getElementById('stock-grid');
    const statusDot = document.getElementById('status-dot');
    const statusText = document.getElementById('status-text');
    const lastUpdated = document.getElementById('last-updated');

    try {
        const promises = WATCHLIST.map(symbol => fetchQuote(symbol));
        const results = await Promise.allSettled(promises);

        grid.innerHTML = '';

        results.forEach(result => {
            if (result.status === 'fulfilled') {
                const quote = result.value;
                grid.appendChild(createStockCard(quote));
            } else {
                console.error('Failed to fetch quote:', result.reason);
            }
        });

        statusDot.className = 'dot live';
        statusText.textContent = 'Live';
        lastUpdated.textContent = new Date().toLocaleTimeString();
    } catch (error) {
        statusDot.className = 'dot stale';
        statusText.textContent = 'Error';
        console.error('Failed to fetch quotes:', error);
    }

    resetCountdown();
}

function createStockCard(quote) {
    const card = document.createElement('div');
    card.className = 'stock-card';

    const changeClass = quote.d > 0 ? 'positive' : quote.d < 0 ? 'negative' : 'neutral';
    const changeSign = quote.d > 0 ? '+' : '';

    card.innerHTML = `
        <div class="ticker">${quote.symbol}</div>
        <div class="price">$${quote.c.toFixed(2)}</div>
        <div class="change ${changeClass}">
            ${changeSign}${quote.d.toFixed(2)} (${changeSign}${quote.dp.toFixed(2)}%)
        </div>
        <div class="details">
            <span>Open: $${quote.o.toFixed(2)}</span>
            <span>Prev: $${quote.pc.toFixed(2)}</span>
            <span>High: $${quote.h.toFixed(2)}</span>
            <span>Low: $${quote.l.toFixed(2)}</span>
        </div>
    `;

    return card;
}

function resetCountdown() {
    countdownSeconds = 30;
    if (countdownInterval) {
        clearInterval(countdownInterval);
    }
    countdownInterval = setInterval(() => {
        countdownSeconds--;
        document.getElementById('countdown').textContent = countdownSeconds;
        if (countdownSeconds <= 0) {
            clearInterval(countdownInterval);
        }
    }, 1000);
}

if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('Service Worker registered:', reg.scope))
        .catch(err => console.error('Service Worker registration failed:', err));
}

fetchQuotes();
setInterval(fetchQuotes, REFRESH_INTERVAL);

Here is the complete public/sw.js file:

const CACHE_NAME = 'stock-ticker-v1';
const STATIC_ASSETS = [
    '/',
    '/index.html',
    '/style.css',
    '/app.js',
    '/manifest.json',
    '/icon-192.png',
    '/icon-512.png'
];

self.addEventListener('install', event => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(STATIC_ASSETS))
    );
    self.skipWaiting();
});

self.addEventListener('activate', event => {
    event.waitUntil(
        caches.keys().then(keys => {
            return Promise.all(
                keys.filter(key => key !== CACHE_NAME)
                    .map(key => caches.delete(key))
            );
        })
    );
    self.clients.claim();
});

self.addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (url.hostname === 'finnhub.io') {
        event.respondWith(fetch(event.request));
        return;
    }

    event.respondWith(
        caches.match(event.request)
            .then(cached => cached || fetch(event.request))
    );
});

Here is the complete public/manifest.json file:

{
    "name": "Stock Ticker",
    "short_name": "Stocks",
    "description": "Live stock prices on your local network",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#1a1a2e",
    "theme_color": "#1a1a2e",
    "icons": [
        {
            "src": "icon-192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        {
            "src": "icon-512.png",
            "sizes": "512x512",
            "type": "image/png"
        }
    ]
}

Your app now has all three pillars of a PWA: a manifest, a service worker, and the registration glue. Next, you will set up an HTTPS server so these features actually activate on any device on your network.

Launch Your HTTPS Local Network Server

Your stock ticker dashboard fetches live prices and refreshes automatically. Right now it only works on your machine through a file or dev server.

To make the app installable as a PWA on your phone and other devices, you need to serve it over HTTPS on your local network. In this step, you will create an Express server with locally-trusted SSL certificates so any device on your WiFi can access and install the app.

In this step, get ready to:
  • Initialize a Node.js project and install Express.
  • Generate HTTPS certificates with mkcert for your local IP.
  • Launch an HTTPS server accessible from any device on your WiFi.
Initialize the project and install Express

Before you can run a server, you need a package.json file that defines your project and its dependencies. The npm init command creates this file for you.

  • Open the terminal in VS Code by pressing Ctrl+`.
  • Make sure you are in the stock-ticker folder, then initialize the project by running this command:
npm init -y

What does this command do?

The -y flag accepts all defaults, creating a package.json with your project name set to stock-ticker and version 1.0.0. This file tracks your project's metadata and dependencies.

  • Install Express as a dependency by running:
npm install express
  • You should see a message confirming the package was added. A node_modules/ folder and a package-lock.json file now appear in your project.

What is Express?

Express is a minimal web framework for Node.js. It handles incoming HTTP requests and serves files. You will use it to serve your public/ folder over HTTPS.

  • Create a file called server.js in the root of your stock-ticker folder (not inside public/). Add the following code:
const fs = require('fs');
const https = require('https');
const express = require('express');
const path = require('path');

const app = express();

// Serve all files from the public/ folder
app.use(express.static(path.join(__dirname, 'public')));

What does this code do?

  • The first four lines import Node.js modules: fs for reading files, https for creating a secure server, express for routing, and path for safe file paths.
  • The express.static middleware tells Express to serve any file inside public/ when a browser requests it.
  • Save server.js.

Seeing a module not found error?

If you see an error about Express not being found, make sure you ran npm install express from inside the stock-ticker folder. Check that node_modules/ exists in your project root.

Help me debug a module not found error in my Node.js server

Generate HTTPS certificates with mkcert

Service workers and the PWA install prompt require a secure context. Localhost counts as secure, but other devices on your network need a real HTTPS connection.

You will use mkcert to generate locally-trusted certificates that include your machine's local IP address.

  • Find your local IP address by running this command in your terminal:
ipconfig
  • Look for the IPv4 Address under your WiFi adapter section (often labeled Wireless LAN adapter Wi-Fi). It will look something like 192.168.1.50.

Which address to use

You want the IPv4 address from your WiFi adapter, not from Ethernet or virtual adapters like VirtualBox or Docker. It usually starts with 192.168 or 10.0.

  • Create a certs/ folder inside your stock-ticker project by running:
mkdir certs
  • Generate the certificate and key files directly into the certs/ folder by running this command (replace the IP with yours):
mkcert -key-file certs/key.pem -cert-file certs/cert.pem localhost 127.0.0.1 [[LOCAL_IP="your WiFi IPv4 address e.g. 192.168.1.50"]]

What does this command do?

  • The -key-file and -cert-file flags tell mkcert exactly where to save the output files.
  • The three hostnames (localhost, 127.0.0.1, and your IP) are baked into the certificate. Browsers will only trust the certificate for connections to these exact addresses.
  • Confirm the files were created by running:
ls certs
  • You should see cert.pem and key.pem listed in the output.

Seeing a permission error from mkcert?

Make sure you are running your terminal as Administrator. Right-click on the terminal application and select Run as administrator. Then run the mkcert command again.

Help me fix mkcert permission errors on Windows

Complete the server and start it

Now that your certificates exist, you can wire them into the server so it starts over HTTPS and listens for connections from any device on your network.

  • In server.js, add the following code below the app.use(express.static(...)) line:
const certPath = path.join(__dirname, 'certs');
const options = {
    key: fs.readFileSync(path.join(certPath, 'key.pem')),
    cert: fs.readFileSync(path.join(certPath, 'cert.pem'))
};

const PORT = 3000;
const server = https.createServer(options, app);

server.listen(PORT, '0.0.0.0', () => {
    const { networkInterfaces } = require('os');
    const nets = networkInterfaces();
    console.log(`Stock Ticker server running on:`);
    console.log(`  Local:   https://localhost:${PORT}`);
    for (const name of Object.keys(nets)) {
        for (const net of nets[name]) {
            if (net.family === 'IPv4' && !net.internal) {
                console.log(`  Network: https://${net.address}:${PORT}`);
            }
        }
    }
});

What does this code do?

  • The options object reads your certificate and key from disk so the HTTPS server can present them to browsers.
  • Binding to 0.0.0.0 means the server accepts connections from any network interface, not just localhost. This is what allows other devices on your WiFi to reach it.
  • The startup callback loops through your network interfaces and prints every IPv4 address where the server is reachable.
  • Save server.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete server.js file:

const fs = require('fs');
const https = require('https');
const express = require('express');
const path = require('path');

const app = express();

app.use(express.static(path.join(__dirname, 'public')));

const certPath = path.join(__dirname, 'certs');
const options = {
    key: fs.readFileSync(path.join(certPath, 'key.pem')),
    cert: fs.readFileSync(path.join(certPath, 'cert.pem'))
};

const PORT = 3000;
const server = https.createServer(options, app);

server.listen(PORT, '0.0.0.0', () => {
    const { networkInterfaces } = require('os');
    const nets = networkInterfaces();
    console.log(`Stock Ticker server running on:`);
    console.log(`  Local:   https://localhost:${PORT}`);
    for (const name of Object.keys(nets)) {
        for (const net of nets[name]) {
            if (net.family === 'IPv4' && !net.internal) {
                console.log(`  Network: https://${net.address}:${PORT}`);
            }
        }
    }
});
  • Start the server by running:
node server.js
  • You should see output like this in your terminal:
Stock Ticker server running on:
  Local:   https://localhost:3000
  Network: https://192.168.1.50:3000
  • Open https://localhost:3000 in your browser. You should see your stock ticker dashboard with live prices loading.
  • On your phone or another device connected to the same WiFi, open the Network URL shown in your terminal (for example https://192.168.1.50:3000). The dashboard should load there too.

Why does my phone show a browser warning?

Your computer trusts the mkcert certificates because mkcert -install added the root CA to your system trust store. Other devices do not have that CA installed, so they show a warning.

You can safely tap Advanced and then Proceed to load the app. The connection is still encrypted. The warning just means the device does not recognize who issued the certificate.

Server won't start or port already in use?

  • If you see an error about port 3000 being in use, another process is occupying it. Close any other dev servers (like Live Server) and try again.
  • If you see an error about missing cert files, double check that certs/cert.pem and certs/key.pem exist by running ls certs.

Help me troubleshoot my HTTPS server startup

Your stock ticker is now a fully installable PWA running over HTTPS on your local network. Any device on your WiFi can access it, and browsers will offer the install prompt so you can add it to your home screen. Next up, you will take things further with a secret mission.

Secret mission

Deploy to a Raspberry Pi

Your stock ticker works great on your Windows machine. But what if you want it running 24/7 without keeping your PC on? In this bonus challenge, you'll deploy the entire app to a Raspberry Pi, turning it into a dedicated home stock server that any device on your network can access at any time.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine and uses the Finnhub free tier, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • stock-ticker project folder on your Windows Desktop.
  • mkcert's local Certificate Authority installed in your Windows trust store.
  • Local SSL certificates in your project's certs/ folder.
  • Node.js HTTPS server process (if still running in your terminal).
  • Raspberry Pi deployment with systemd service (Secret Mission only).

Keep everything running

No action needed. Choose this if you're still actively building or want the stock ticker available on your local network.

  • Your HTTPS server continues running in the terminal. Close the terminal window when you're done for the day.
  • The Finnhub free account persists at no cost and requires no credit card.
  • mkcert's local CA stays trusted in your system. This is harmless and useful for future local HTTPS projects.
  • If you completed the Secret Mission, your Raspberry Pi continues serving the app to your network automatically on boot.

Pause - I'll come back to this later

Stop the running server to free up your terminal, but keep all project files on your machine so you can restart later.

  • Press Ctrl+C in the terminal where your server is running to stop it.
  • To restart later, open a terminal in your stock-ticker folder and run node server.js.
  • If you completed the Secret Mission, SSH into your Pi and stop the service by running:
sudo systemctl stop stock-ticker
  • To restart the Pi server later, run sudo systemctl start stock-ticker.

Delete - I don't want to use this again

Remove all project resources from your Windows machine and Raspberry Pi. This cannot be undone.

  • Press Ctrl+C in the terminal where your server is running to stop it.
  • Delete the project folder by running this in PowerShell:
rmdir /s /q $HOME\Desktop\stock-ticker
  • Remove mkcert's local CA from your Windows trust store by opening PowerShell as Administrator and running:
mkcert -uninstall
  • Uninstall mkcert itself by running:
choco uninstall mkcert
  • If you completed the Secret Mission, SSH into your Raspberry Pi and remove the deployment:
sudo systemctl stop stock-ticker
sudo systemctl disable stock-ticker
sudo rm /etc/systemd/system/stock-ticker.service
rm -rf ~/stock-ticker
  • Your Finnhub account is free with no credit card on file. You can leave it as-is with no financial impact.

That's a Wrap!

That's a Wrap!

Nice work. You just built a self-hosted stock ticker PWA that serves live market data over HTTPS to any device on your local network.

You've learned how to:

  • Build a responsive stock watchlist dashboard that polls the Finnhub API for live prices refreshing every 30 seconds.
  • Implement a service worker and web app manifest to make a web app installable as a PWA on phones and desktops.
  • Set up locally-trusted HTTPS certificates with mkcert and deploy an Express HTTPS server accessible from any device on your local WiFi.
  • Secret Mission: Deployed the app to a Raspberry Pi for 24/7 operation as a dedicated home stock server.

Ready to quiz yourself?