Tutorial
By the end of this page a web page in your browser will count to five, one number a second, driven by a shell script on your machine.
Here is what websocketd does between the two. It runs your script and
connects it to the browser: every line the script prints to stdout is
sent to the browser as one WebSocket message, at the moment it is
printed, and anything the browser sends back arrives on the script's
stdin. The script contains no networking code and never knows a browser
is there.
You need websocketd on your PATH. If websocketd --version does not
print a version, work through the install
first.
1. Write a script
Make a directory to work in, and go into it:
mkdir counter
cd counter
Create a file called count.sh containing exactly this:
#!/bin/bash
for COUNT in 1 2 3 4 5; do
echo $COUNT
sleep 1
done
Make it executable:
chmod +x count.sh
Run it on its own first, with no websocketd involved:
./count.sh
1
2
3
4
5
The numbers appear one a second. Nothing about this script knows what a WebSocket is, and nothing about it is going to change.
2. Wrap it with websocketd
websocketd --port=8080 --sameorigin ./count.sh
The server starts and prints two lines:
Sun, 06 Sep 2026 19:41:15 -0700 | INFO | server | | Serving using application : ./count.sh
Sun, 06 Sep 2026 19:41:15 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/
Your second line will name your own machine rather than
example-host.local, which stands in for it throughout this page.
--sameorigin allows a WebSocket connection only from a page served by
this same host and port. Leave it out and websocketd accepts one from
any page in any browser that can reach the port, and prints a long
warning at startup saying so. Keep it on for the rest of this tutorial.
The security model
covers the other
policies and when to reach for them.
Now open http://localhost:8080/ in your browser. You get this:
404 page not found
This server speaks WebSocket and nothing else so far, and you have not given it a web page to hand out. Step 4 does that. The server agrees with the browser, in its own log:
Sun, 06 Sep 2026 19:41:16 -0700 | ACCESS | http | url:'http://localhost:8080/' | NOT FOUND
The banner says a different hostname than you typed
The startup line above says ws://example-host.local:8080/, but you
opened http://localhost:8080/. Both reach the same server.
websocketd looks up your machine's own network name to build that
banner. It never prints example-host.local: that name is this page's
stand-in for whatever your machine is called, and yours will read
something else entirely.
This tutorial says localhost everywhere, because localhost always
resolves to your own machine no matter what network you are on. When the
banner and this page disagree about the hostname, they are not in
conflict. Use localhost.
Flags go before the command, always
websocketd --port=8080 ./count.sh works. websocketd ./count.sh --port=8080 does not, and it does not tell you so. Go's flag parser stops
looking for flags at the first argument that is not one, so everything
after ./count.sh is handed to count.sh as an argument instead:
Sun, 06 Sep 2026 19:36:12 -0700 | INFO | server | | Serving using application : ./count.sh --port=9999
Sun, 06 Sep 2026 19:36:12 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:80/
Look at the first line. If a flag you typed shows up after your script
name on the Serving using application line, it was never read as a flag,
and the server fell back to its default of port 80. Move the flag before
the command.
3. Drive it from the dev console
You have a WebSocket server, but nothing to talk to it with yet.
websocketd ships with a page for exactly this. Stop the server with
Ctrl+C and start it again with --devconsole:
websocketd --port=8080 --sameorigin --devconsole ./count.sh
There is a third startup line now:
Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Serving using application : ./count.sh
Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/
Sun, 06 Sep 2026 19:38:40 -0700 | INFO | server | | Developer console enabled : http://example-host.local:8080/
Open http://localhost:8080/ again. Instead of the 404 you get the
console: a connect button, a box to send messages from, and a running log
of everything your script prints.
Connect, and watch 1 through 5 arrive one a second. Each number is a
separate WebSocket message: count.sh wrote a line to stdout, and
websocketd sent that line on as it appeared. The server logs the
session as it opens:
Sun, 06 Sep 2026 19:38:40 -0700 | ACCESS | http | url:'http://localhost:8080/' | DEVCONSOLE
Sun, 06 Sep 2026 19:38:40 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f43ed0dfd946de9' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' | CONNECT
Every connection you make starts its own fresh copy of count.sh. Open a
second browser tab and it counts from 1 again, independently, in its own
process.
Numbers arriving one at a time is not free
You saw the numbers stream because count.sh is a bash script, and bash
writes each echo out immediately. Most other languages do not. Python,
Ruby, PHP, C, and others switch to holding output in a buffer when stdout
is a pipe rather than a terminal, and a pipe is exactly what your program
gets here. The symptom is all five numbers landing at once when the script
exits, instead of one a second.
The fix is one flag or one line per language, and it is on that language's page: Python , Ruby , PHP , C , Node.js . The reason it happens is in output buffering .
Do not add --staticdir to this command
--devconsole serves its own page at /, so it cannot share the server
with --staticdir or --cgidir, which also want to serve /. Combining
them is not ignored or quietly resolved. The server refuses to start and
exits with code 4:
Sun, 06 Sep 2026 19:38:22 -0700 | FATAL | server | | Invalid parameters: --devconsole cannot be used with --staticdir. Pick one.
These are two ways to run the server, not two flags to combine. Use
--devconsole while you are poking at the script. Use --staticdir, as
you are about to, once you have a front end of your own.
4. Serve your own page
Stop the server with Ctrl+C.
Your HTML goes in a directory of its own. websocketd serves everything
in the directory you point --staticdir at, and count.sh is not
something you want handed out:
mkdir public
Create public/count.html:
<!DOCTYPE html>
<title>count</title>
<pre id="log"></pre>
<script>
const log = document.getElementById('log');
const ws = new WebSocket('ws://' + location.host + '/');
ws.onopen = () => { log.textContent += 'CONNECT\n'; };
ws.onmessage = (e) => { log.textContent += e.data + '\n'; };
ws.onclose = () => { log.textContent += 'DISCONNECT\n'; };
</script>
Start the server pointing at that directory:
websocketd --port=8080 --sameorigin --staticdir=public ./count.sh
Four startup lines this time, including an http:// one:
Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving using application : ./count.sh
Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving static content from : public
Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Starting WebSocket server : ws://example-host.local:8080/
Sun, 06 Sep 2026 19:39:14 -0700 | INFO | server | | Serving CGI or static files : http://example-host.local:8080/
Open http://localhost:8080/count.html. The page shows CONNECT,
then 1 through 5 one a second, then DISCONNECT when the script
finishes and its process exits. The server logs the same story:
Sun, 06 Sep 2026 19:39:14 -0700 | ACCESS | http | url:'http://localhost:8080/count.html' | STATIC
Sun, 06 Sep 2026 19:39:15 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'12d9958a57d99ae6' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' | CONNECT
Sun, 06 Sep 2026 19:39:21 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'12d9958a57d99ae6' remote:'127.0.0.1' command:'./count.sh' origin:'http://localhost:8080' pid:'1965' | DISCONNECT
That is the whole thing working.
Open the page through the server, not off the disk
Do not double-click count.html, and do not open it as a file:// URL
straight from the disk. A page loaded that way has no
origin. Browsers send Origin: null for it, --sameorigin rejects the
upgrade, and the page sits there having never connected:
Sun, 06 Sep 2026 19:38:58 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f6bfb8c6295f37b' remote:'127.0.0.1' command:'./count.sh' origin:'file:' | Same origin policy mismatch
Sun, 06 Sep 2026 19:38:58 -0700 | ACCESS | session | url:'http://localhost:8080/' id:'4f6bfb8c6295f37b' remote:'127.0.0.1' command:'./count.sh' origin:'file:' | Unable to Upgrade: websocket: request origin not allowed by Upgrader.CheckOrigin
The browser gets a 403 Forbidden for the upgrade, which is visible only
in its developer tools. On the page itself nothing happens at all. Always
reach the page at http://localhost:8080/count.html, so the page and the
socket share an origin.
Never type the port into the JavaScript
The snippet above builds the socket URL from location.host, which is the
host and port the page itself was loaded from. Change --port and it
follows, with nothing to keep in sync.
Had it said new WebSocket('ws://localhost:8080/') and you were running on
8081, the page would load perfectly and simply never connect: no server
log line, no error on the page, nothing. That silent mismatch is easy to
miss and slow to diagnose, and location.host removes it entirely.
What you know now
- Any program that reads stdin and writes stdout is a WebSocket backend.
count.shwas never modified. - Every connection gets its own process. Two tabs are two independent runs of your script.
- Flags go before the command name. If a flag appears on the
Serving using applicationline, it was swallowed as an argument. - Without an origin policy the server accepts connections from anywhere
and prints a warning saying so.
--sameoriginis the right answer while you are developing. - The startup banner names your machine's hostname.
localhostreaches the same server. --devconsoleand--staticdirare two ways to run the server, chosen per run. Together they exit with code 4.- Serve your page over
http://and derive the socket URL fromlocation.host. Afile://page is rejected, and a hardcoded port fails silently.
Where to go next
- The process model covers what one process per connection means once your script does something real, and what it rules out.
- Output buffering
explains why the
output stops appearing when you rewrite
count.shin another language. - Pass data into your script gets query-string and per-connection data into your program.
- Debug a script gets more out of the dev console than the connect button.
- CLI flags
lists everything
websocketdtakes, with its default.