<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hassan Aziz</title>
    <description>The latest articles on DEV Community by Hassan Aziz (@hassan_aziz_88b11714fef8b).</description>
    <link>https://dev.to/hassan_aziz_88b11714fef8b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1782269%2Feeff5921-4c01-48af-87a3-95c9e1930c8c.png</url>
      <title>DEV Community: Hassan Aziz</title>
      <link>https://dev.to/hassan_aziz_88b11714fef8b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hassan_aziz_88b11714fef8b"/>
    <language>en</language>
    <item>
      <title>WebSockets tutorial: how they work and when to use them</title>
      <dc:creator>Hassan Aziz</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:13:59 +0000</pubDate>
      <link>https://dev.to/hassan_aziz_88b11714fef8b/websockets-tutorial-how-they-work-and-when-to-use-them-55a0</link>
      <guid>https://dev.to/hassan_aziz_88b11714fef8b/websockets-tutorial-how-they-work-and-when-to-use-them-55a0</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A WebSocket is a connection that stays open between the browser and the server, so either side can send a message at any time.&lt;/li&gt;
&lt;li&gt;HTTP is request and response: the server can only answer when the client asks. WebSockets are events, and neither side has to reply.&lt;/li&gt;
&lt;li&gt;A WebSocket starts as an HTTP request with an Upgrade header, and the same connection then carries WebSocket messages.&lt;/li&gt;
&lt;li&gt;Use WebSockets when the server has to push updates without being asked, like chat messages or progress from a long-running job. Keep HTTP for everything else.&lt;/li&gt;
&lt;li&gt;Use wss:// rather than ws://, and pick it from the page's own protocol so the same code works locally and in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/NFe9MfyZgCY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;A WebSocket is a connection between a browser and a server that stays open, so either side can send a message whenever it wants. Plain HTTP can't do that. With HTTP the server only ever answers requests, so if the client doesn't ask, the server has no way to tell it anything.&lt;/p&gt;

&lt;p&gt;That one difference decides when you need WebSockets. Below I go through how HTTP works, how WebSockets work, when to pick each one, and then a small working demo with a Flask server and plain JavaScript in the browser. The video above covers the same ground if you'd rather watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HTTP works: the client asks, the server answers
&lt;/h2&gt;

&lt;p&gt;Every website you visit runs on HTTP. It's a request-response protocol. Your browser (the client) sends a request to the server for &lt;code&gt;index.html&lt;/code&gt;, the server sends back the HTML, and the browser renders the page. Then the browser asks for the site's icon, and the server sends the icon back.&lt;/p&gt;

&lt;p&gt;Every exchange starts with the client. If the browser never asks for the icon, the server never sends it, because there's no request to respond to. For loading pages and calling most APIs, that's exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem HTTP can't solve
&lt;/h2&gt;

&lt;p&gt;Say you're building a messaging app like WhatsApp or Discord. Other people send messages to the server all the time, and your browser has to show them as they arrive. But your browser doesn't know when a new message has landed, so it doesn't know when to ask.&lt;/p&gt;

&lt;p&gt;The usual workaround is polling: the browser asks "anything new?" every few seconds. Most of those requests come back empty, and a message can still sit on the server for a few seconds before the next poll picks it up. It works, but it's the wrong shape for the problem. What you want is for the server to say "here's a new message" the moment it has one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How WebSockets work: events in both directions
&lt;/h2&gt;

&lt;p&gt;A WebSocket starts life as an HTTP request. The browser sends a normal request with an &lt;code&gt;Upgrade: websocket&lt;/code&gt; header, the server answers with &lt;code&gt;101 Switching Protocols&lt;/code&gt;, and from then on that same connection stays open and carries WebSocket messages instead of HTTP. That's why people describe WebSockets as an upgrade of HTTP.&lt;/p&gt;

&lt;p&gt;After the upgrade, the model is completely different. HTTP is request and response. WebSockets are events. The client can send an event and the server can respond, or the server can send an event and the client can respond, and neither side has to respond at all. Replying is good practice, but nothing in the protocol requires it.&lt;/p&gt;

&lt;p&gt;So a real conversation over a WebSocket can look like this: the server sends an event, the client says nothing back, the server sends another one, the client sends one of its own, and then the server sends two more. No request ever has to come first.&lt;/p&gt;

&lt;h2&gt;
  
  
  WebSocket vs HTTP
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;WebSocket&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who can start an exchange&lt;/td&gt;
&lt;td&gt;Only the client&lt;/td&gt;
&lt;td&gt;Either side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection&lt;/td&gt;
&lt;td&gt;A request, then a response&lt;/td&gt;
&lt;td&gt;Stays open until one side closes it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does every message need a reply?&lt;/td&gt;
&lt;td&gt;Yes, every request gets a response&lt;/td&gt;
&lt;td&gt;No, a reply is optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL scheme&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;http://&lt;/code&gt; and &lt;code&gt;https://&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ws://&lt;/code&gt; and &lt;code&gt;wss://&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Pages and most APIs&lt;/td&gt;
&lt;td&gt;Chat, games, live updates, notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use WebSockets when the server has to push something to the client without being asked. Keep HTTP for everything else. They aren't competitors, and most real apps use both: HTTP to load the page and fetch data, and one WebSocket for the live parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use WebSockets
&lt;/h2&gt;

&lt;p&gt;These are the cases where I'd reach for them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A messaging app, where new messages have to show up the moment someone sends them.&lt;/li&gt;
&lt;li&gt;A multiplayer game, where every player needs to see what the others do many times a second, and sends their own moves up the same connection.&lt;/li&gt;
&lt;li&gt;A long-running job on the server. It can send an update to the browser as each step finishes, and the user watches it happen instead of refreshing.&lt;/li&gt;
&lt;li&gt;A shared document or whiteboard, where everyone sees each other's edits as they type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If only the server ever needs to talk, Server-Sent Events are a simpler option worth a look, since they run over plain HTTP. If neither side needs to push anything, you don't need either one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a WebSocket server in Flask
&lt;/h2&gt;

&lt;p&gt;Here's a minimal app to see all of this working. The server sends random events to the browser on its own schedule, and whenever the browser sends a message, the server replies with "Message received".&lt;/p&gt;

&lt;p&gt;I'm using Python with Flask and the &lt;a href="https://github.com/miguelgrinberg/flask-sock" rel="noopener noreferrer"&gt;Flask-Sock&lt;/a&gt; extension, and plain JavaScript in the browser with no libraries. Most people will be on Node.js, and that's fine: the server has the same shape in any language, and the browser code is identical.&lt;/p&gt;

&lt;p&gt;Install the two packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;flask flask-sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project has three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;websockets-demo/
├── app.py
├── templates/
│   └── index.html
└── static/
    └── index.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the server, &lt;code&gt;app.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render_template&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_sock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Sock&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;simple_websocket&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ConnectionClosed&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Sock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;EVENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;New order received&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User signed up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Report finished generating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Payment processed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Backup completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;render_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@sock.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/ws&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Two threads send on this socket, so they take turns
&lt;/span&gt;    &lt;span class="n"&gt;send_lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;send_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;background_sender&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EVENTS&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ConnectionClosed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;background_sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# receive() blocks until the client sends something, and raises
&lt;/span&gt;    &lt;span class="c1"&gt;# ConnectionClosed when it leaves, which Flask-Sock handles for us
&lt;/span&gt;    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Message received: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/&lt;/code&gt; route is plain HTTP. It renders the page, and that's all it does.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/ws&lt;/code&gt; route is the WebSocket. Flask-Sock handles the upgrade handshake and hands the function a &lt;code&gt;ws&lt;/code&gt; object for that one client. The function then does two jobs at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It starts a background thread running &lt;code&gt;background_sender&lt;/code&gt;, which sends a random event from the list every one to three seconds, for as long as the client stays connected. This is the part HTTP can't do: the server talks without being asked.&lt;/li&gt;
&lt;li&gt;It sits in a loop waiting for the client. Every time a message arrives, it sends back "Message received" with the text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both of those send on the same socket from different threads, so they share a lock and never write at the same moment. When the browser tab closes, &lt;code&gt;receive()&lt;/code&gt; raises &lt;code&gt;ConnectionClosed&lt;/code&gt;, Flask-Sock cleans up, and the background thread stops within a few seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser side: the native WebSocket API
&lt;/h2&gt;

&lt;p&gt;Browsers have WebSockets built in, so the client needs no library. The page itself is a text box plus a list the messages go into. Here's &lt;code&gt;templates/index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!doctype html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;WebSockets demo&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Status: &lt;span class="nt"&gt;&amp;lt;strong&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Connecting...&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Say something"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"log"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{ url_for('static', filename='index.js') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;static/index.js&lt;/code&gt;, which is where the WebSocket lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Same rule as http and https: use the secure wss:// when the page itself is on https&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wss:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ws:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;//&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ws`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;li&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statusText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Server: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statusText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The connection had an error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OPEN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;addLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`You: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two lines pick the URL. The difference between &lt;code&gt;ws://&lt;/code&gt; and &lt;code&gt;wss://&lt;/code&gt; is the same as the difference between &lt;code&gt;http://&lt;/code&gt; and &lt;code&gt;https://&lt;/code&gt;: one is encrypted and the other isn't. Use the secure one. Deriving it from the page's own protocol means the same code works on your laptop over &lt;code&gt;http&lt;/code&gt; and in production over &lt;code&gt;https&lt;/code&gt;. Browsers also block a plain &lt;code&gt;ws://&lt;/code&gt; connection from a page served over &lt;code&gt;https&lt;/code&gt;, so getting this wrong breaks the app in production.&lt;/p&gt;

&lt;p&gt;The rest is event handlers. &lt;code&gt;open&lt;/code&gt; fires when the connection is up, &lt;code&gt;message&lt;/code&gt; fires for every event the server sends, &lt;code&gt;close&lt;/code&gt; fires when either side ends the connection, and &lt;code&gt;error&lt;/code&gt; fires when something goes wrong. &lt;code&gt;socket.send()&lt;/code&gt; is how the browser sends its own events, and the form calls it when you press Send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;From the project folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flask &lt;span class="nt"&gt;--app&lt;/span&gt; app run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://127.0.0.1:5000&lt;/code&gt;. The status switches to "Connected", and within a few seconds events start appearing in the list, without you sending anything. Type "hello" and press Send, and the server answers with "Message received: hello" in between its own events.&lt;/p&gt;

&lt;p&gt;Watch the list for a minute and you'll see the server keep talking to the browser without a single request from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learn how it works, and let AI write the syntax
&lt;/h2&gt;

&lt;p&gt;I didn't spend long on the code in the video, and I'd do the same here. Everyone uses a different stack, and AI writes most of the code for us now anyway. Ask any AI tool for "a WebSocket server in Express" and you'll get something close to the Flask code above in a few seconds.&lt;/p&gt;

&lt;p&gt;What AI can't do for you is decide whether you need a WebSocket in the first place. That takes knowing how the protocols work under the hood: HTTP only answers, a WebSocket lets either side speak, and each one fits different problems. WebSockets aren't better than HTTP, and you shouldn't use them everywhere. When the client and server need to talk in real time without waiting on requests and responses, that's where they're the right tool.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.hassandev.me/blog/youtube/the-only-websockets-tutorial-you-need-NFe9MfyZgCY" rel="noopener noreferrer"&gt;hassandev.me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flask</category>
      <category>python</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I score Reddit job posts for scams with an LLM</title>
      <dc:creator>Hassan Aziz</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:15:44 +0000</pubDate>
      <link>https://dev.to/hassan_aziz_88b11714fef8b/how-i-score-reddit-job-posts-for-scams-with-an-llm-1g2f</link>
      <guid>https://dev.to/hassan_aziz_88b11714fef8b/how-i-score-reddit-job-posts-for-scams-with-an-llm-1g2f</guid>
      <description>&lt;p&gt;If you've ever looked for freelance work on Reddit, you know how it goes. You open r/forhire or r/slavelabour, sort by New, and start scrolling. The first post is someone advertising their logo design services. The next one asks whether Upwork is dead. Then there's another freelancer's portfolio, and after that a "job" that wants you to message them on Telegram about an exciting opportunity in crypto.&lt;/p&gt;

&lt;p&gt;The real jobs are in there, buried, and some of the ones you find aren't real either.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://onlyhiring.hassandev.me" rel="noopener noreferrer"&gt;Only Hiring&lt;/a&gt;. It reads every new post from 18 job subreddits and lists only the ones where someone is paying for work, on one page you can scan in a couple of minutes. Each job gets a short summary and a scam score from 0 to 1, and the pay is pulled out into numbers so you can sort by it.&lt;/p&gt;

&lt;p&gt;This is how the LLM side of it works, with the prompt rules that mattered most.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many of the posts are jobs
&lt;/h2&gt;

&lt;p&gt;Here's what the 712 posts the pipeline has read so far turned out to be:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the post was&lt;/th&gt;
&lt;th&gt;Posts&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hiring&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;44%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A question or discussion&lt;/td&gt;
&lt;td&gt;208&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Someone advertising their own services&lt;/td&gt;
&lt;td&gt;111&lt;/td&gt;
&lt;td&gt;16%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spam or promotion&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unpaid "collaboration"&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Something else&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those 712 are what's left after skipping every post tagged [For Hire] or [Offer], which never reach the model at all. Even after that, fewer than half are jobs.&lt;/p&gt;

&lt;p&gt;Some subreddits are much worse than others. On r/softwareengineerjobs, 14 of the 147 posts I read were hiring. That's under 10%, and most of the rest were career questions. r/RemoteJobs and r/developerjobs weren't much better. On subreddits that enforce their tags, like r/forhire and r/DesignJobs, nearly every post that's left is a real job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;It's a small Python pipeline that runs on my machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scrape new posts ──&amp;gt; skip [For Hire] tags ──&amp;gt; LLM grades each post ──&amp;gt; clean the output
                                                                            │
website &amp;lt;── Postgres &amp;lt;── publish jobs only &amp;lt;── drop reposts and deleted posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The website is Next.js on Vercel, and it only ever reads from the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Don't send everything to the model
&lt;/h3&gt;

&lt;p&gt;The cheapest filter is one that doesn't need an LLM. Most job subreddits make people tag their posts, so anything tagged &lt;code&gt;[For Hire]&lt;/code&gt;, &lt;code&gt;[Offer]&lt;/code&gt; or &lt;code&gt;Hire Me&lt;/code&gt; is skipped before it's even fetched. On r/forhire, a regex gets rid of most of the subreddit.&lt;/p&gt;

&lt;p&gt;Everything else goes to the model, because tags don't catch the rest. People tag their own services as [Hiring] to get more views, and some "hiring" posts turn out to be course sales or questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: One prompt, one JSON object
&lt;/h3&gt;

&lt;p&gt;Each post goes to an LLM (I use DeepSeek) with a single prompt. The model decides what kind of post it is and, if it's a job, pulls out everything useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"is_job_post"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"post_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hiring_job_offer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"job_title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Video Editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role_category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"video_animation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"seniority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"employment_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"freelance_project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pay_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"per_unit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pay_min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pay_max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pay_currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pay_raw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$25-40 per video"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"work_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Premiere Pro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"After Effects"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apply_method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reddit_dm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scam_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"red_flags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reasoning"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A client offering paid editing work per video."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I care most about for extraction is this one, copied from the prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a detail is not stated, set it to null. Missing information is expected and fine; a wrong value is much worse than null.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;LLMs love to fill gaps. If a post doesn't say whether the work is remote, a model will often guess "remote" because most Reddit jobs are. That guess then shows up on the site as a fact, and someone filters by it. So only three fields may be inferred: the job title, the summary and the role category. Every other field has to come from the post itself. Every key is required too, so the model has to write &lt;code&gt;null&lt;/code&gt; explicitly instead of leaving a field out.&lt;/p&gt;

&lt;p&gt;Pay gets the most rules, because it's the field people care about most and it's the messiest. The model first copies the shortest exact bit of the post that states the pay (&lt;code&gt;"$20-25/hr"&lt;/code&gt;), then splits it into a type, a min, a max and a currency. Keeping the raw excerpt means I can always check the parsed numbers against what the post said. "Competitive salary" or a bare [PAID] tag keeps its excerpt but gets no numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Scoring scams
&lt;/h3&gt;

&lt;p&gt;I spent more time on this part than any other. The prompt has a scoring guide with three bands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0-0.2: identifiable employer or concrete project, specific deliverables, realistic pay.
0.3-0.5: vague work and no details, commission-only with no clear product,
         pay that seems off for the work.
0.6-1.0: classic scam or exploitation signs, such as: upfront fees or paid "training";
         crypto or forex trading and "investment" schemes; contact only through
         Telegram/WhatsApp; requests for ID, bank or personal details; renting or
         "borrowing" someone's LinkedIn/Upwork/bank account; reshipping or payment
         processing; MLM; unpaid "test" tasks that are real work; very high pay for
         trivial work.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The list that made the biggest difference is the opposite one, the things that are &lt;em&gt;not&lt;/em&gt; red flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Normal on Reddit and NOT warning signs on their own: applying by Reddit DM or comment,
an individual client who gives no company name, pay not stated, payment in crypto,
and job requirements such as location, age, language or equipment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, a model that has absorbed generic "how to spot a job scam" advice flags half of Reddit. Real clients on r/slavelabour are anonymous individuals who want a DM and don't name a company, because they don't have one. Advice written for LinkedIn treats that as suspicious, and on Reddit it's how most hiring works. So the score has to measure how unusual a post is for Reddit specifically.&lt;/p&gt;

&lt;p&gt;The model also has to list each warning sign it found in &lt;code&gt;red_flags&lt;/code&gt;. The site shows them next to the warning, so you can see why a post was flagged and decide for yourself. "Scam score: 0.7" on its own tells you very little, while "Telegram-only contact" and "unpaid sample before the go-ahead" tell you what to look out for.&lt;/p&gt;

&lt;p&gt;On the site, a score of 0.4 or more gets a visible warning, and 0.6 or more hides the post unless you choose to see likely scams.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Never trust the JSON
&lt;/h3&gt;

&lt;p&gt;Even with a strict format, model output is untrusted input. Every response goes through a sanitiser before it's stored:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unknown keys are dropped, and enum values that aren't on the list become &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Strings like &lt;code&gt;"N/A"&lt;/code&gt;, &lt;code&gt;"null"&lt;/code&gt;, &lt;code&gt;"unknown"&lt;/code&gt; and &lt;code&gt;"not specified"&lt;/code&gt; become real nulls.&lt;/li&gt;
&lt;li&gt;Numbers are parsed (&lt;code&gt;"1,500"&lt;/code&gt; becomes 1500), and negative pay is thrown away.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;pay_min&lt;/code&gt; is bigger than &lt;code&gt;pay_max&lt;/code&gt;, they're swapped.&lt;/li&gt;
&lt;li&gt;If the post isn't a job, every job field is cleared, whatever the model said.&lt;/li&gt;
&lt;li&gt;Currency codes have to be three letters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is clever, but without it, bad values leak through to the filters. It's the same rule I follow in client work: &lt;a href="https://www.hassandev.me/blog/reliable-ai-automations" rel="noopener noreferrer"&gt;check an AI step's output in code&lt;/a&gt; before anything else uses it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Reposts and deleted posts
&lt;/h3&gt;

&lt;p&gt;Hiring posts get crossposted to three subreddits at once, or reposted every few days to bump them. A post counts as a duplicate when the same author posts near-identical text (90% similar or more, using Python's &lt;code&gt;difflib&lt;/code&gt;). The copy reuses the first one's grade instead of going through the model again, and only the newest copy is published.&lt;/p&gt;

&lt;p&gt;Posts also disappear. Authors delete them once the job's filled, and Reddit's spam filters remove others. Every run asks Reddit which of the last 14 days' jobs are gone and takes those off the site. On one day, 24 of the 221 jobs from the previous two weeks had already gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scores found
&lt;/h2&gt;

&lt;p&gt;I expected the scam scores to catch a lot of scams. They mostly didn't, because there aren't many. Of the 311 hiring posts, 40 got a warning (13%), and only 6 scored high enough to be hidden.&lt;/p&gt;

&lt;p&gt;What I found much more often was exploitation: real jobs on bad terms. These are some of the red flags the model wrote down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"unpaid sample clip requested before go-ahead"&lt;/li&gt;
&lt;li&gt;"payment only after all 30 clips are approved"&lt;/li&gt;
&lt;li&gt;"full usage rights granted for a $30 bulk set"&lt;/li&gt;
&lt;li&gt;"very low pay for live screen-share work"&lt;/li&gt;
&lt;li&gt;"unpaid substantive rewriting assessment"&lt;/li&gt;
&lt;li&gt;"WhatsApp-only contact"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these jobs are real. They pay very little, or want a lot of free work first, which is why I think the red flags matter more than the score: "payment only after all 30 clips are approved" tells you exactly what you'd be agreeing to. The &lt;a href="https://onlyhiring.hassandev.me/guides/reddit-job-scams" rel="noopener noreferrer"&gt;guide to Reddit job scams&lt;/a&gt; breaks it down by subreddit.&lt;/p&gt;

&lt;h2&gt;
  
  
  An LLM writing tic I didn't expect
&lt;/h2&gt;

&lt;p&gt;The first version of the prompt asked for a summary of "what the work is and who it suits". The model took the second half literally. At one point 65% of the summaries had the word "suit" in them, and 44% had a sentence starting "It suits...". A page of jobs read like a page of horoscopes.&lt;/p&gt;

&lt;p&gt;The fix was to ban stock phrases in the prompt ("It suits...", "ideal for", "great opportunity", "Seeking a...") and ask for the most useful specific detail instead, like the deliverables or the timeline. Re-grading every listed job took the summaries mentioning "suit" from 171 to 1.&lt;/p&gt;

&lt;p&gt;If you're generating text for lots of similar items, read twenty of them in a row. A tic you'd never spot in one output is obvious in a list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get on the site
&lt;/h2&gt;

&lt;p&gt;All that extracted data is what makes the board quick to scan. Every job from the 18 subreddits is in one list, newest first. You can filter by role (19 categories, from video editing to data and AI), by experience level, by remote, hybrid or on-site, and by how it pays: hourly, fixed price, per piece, monthly and so on. Sorting by highest pay only works because pay is parsed into numbers. One tick hides commission-only jobs, and search covers titles, skills, locations and subreddits.&lt;/p&gt;

&lt;p&gt;Scam warnings show their reasons, and likely scams are hidden by default. You can mark jobs you've applied to, then hide them or see only those. That works without an account, and signing in keeps the list across devices. There's also an RSS feed of new jobs, and a page for each subreddit with what it pays and how many of its posts are real jobs.&lt;/p&gt;

&lt;p&gt;You still apply on Reddit. Every job links to the original post, and the site only keeps a summary and the post's opening lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it gets wrong
&lt;/h2&gt;

&lt;p&gt;The model makes mistakes. Sometimes it misses a red flag or misreads a pay figure, and now and then it flags something harmless. So the site says so and links every job to its source, and the footer tells you to read the original post before you reply to anyone. Treat a scam score as a hint and make the call yourself.&lt;/p&gt;

&lt;p&gt;Some fields are often empty because the posts don't say. Only about one job in five states a seniority level, for example, so the experience filter has a "Not stated" option. I'd rather show that than guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It's free, with no sign-up needed: &lt;a href="https://onlyhiring.hassandev.me" rel="noopener noreferrer"&gt;onlyhiring.hassandev.me&lt;/a&gt;. The website's code is &lt;a href="https://github.com/hassanaziz0012/only-hiring" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; under the MIT licence.&lt;/p&gt;

&lt;p&gt;If you know a subreddit with real job posts that I'm not reading, or you find a post it got wrong, let me know in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>webdev</category>
      <category>python</category>
    </item>
  </channel>
</rss>
