GeistHaus
log in · sign up

YAGNI Club

Part of yagni.club

Thoughts on simple software

stories primary
Misunderstanding SSE
On my path of practicing simple software my existing understanding of systems, protocols and tools are challenged. My mental model for SSE (Server Sent Events) was a way to keep a connection open and send updates to the client from the server. Broadly this is correct, but read on as I break down what SSE actually is to see why you may be underutilizing SSE in your applications.
Show full content

I was already using SSE as the mechanism for pushing html updates for all of my explorations so far, but was still tripping up on why I might reach for it for simple scenarios where I would only have a single response. After chatting in the Datastar discord I took a deeper dive to better understand their position on always using SSE and improve my mental model.

First off: SSE has been around for forever, but everywhere I've worked over the last 20 years has either not utilized it well or couldn't quite figure out an effective way of using it beyond simple use cases or making it a key aspect of the architecture. Often conflating SSE with a special real-time protocol like WebSockets and not realizing it's just HTTP.

Over the years I've seen it used more and more like in local-first architectures that leverage SSE to poke the client, literally just a message that says 'poke', to then pull updates from a sync server. Initially I thought this was clever, but now realize that it's missing out on so much potential sitting right there in front of our faces.

So… What is SSE?

It's a specification for text-based formatting of the response body.

Thanks for coming to my TED talk.

OK for real I had been conflating it with events specifically and how you handle those events on the client like using EventSource. Really all it is is:

event: event-name
data: first line of data
data: second line of data
id: 123
retry: 5000

event: another-event
data: {"json": "works fine"}

(blank line)

The rules:

  • Each message is a series of lines

  • Lines start with field names: ⁠event:, ⁠data:, ⁠id:, ⁠retry:

  • A blank line (⁠\n\n) marks the end of a message

  • That's literally it

Read more about the Event stream format.

This SSE protocol uses the text/event-stream MIME type, just like HTML uses text/html, and APIs typically use application/json. When I send a text/event-stream response, the server can keep the connection open (by not closing it) and send 0, 1 or multiple messages over time. Each message follows the SSE format (⁠event:, ⁠data:, blank line), but the data payload itself can be anything - HTML, JSON, base64-encoded content, or plain text. This means I can use one consistent response pattern for all my interactions instead of switching between different response types.

Where this idea becomes really powerful is when pairing it with fetch on the client which unlocks handling SSE responses (text/event-stream) from any kind of request (GET, POST etc) instead of only GET when using using EventSource.

// EventSource: Only GET requests
new EventSource("/some-resource");

// fetch: Any HTTP method + SSE response
fetch('/contact', {
  method: 'POST',
  body: formData,
  headers: { 'Accept': 'text/event-stream' }
});

When reasoning about why I wouldn't just use a text/html response I could morph in with submission state for something like a simple contact form I was mostly thinking about:

  • Handling a form submission/persisting data is simple

  • Handling the response even with Datastar is simple

Which is true on the surface or happy path, but breaks down quickly.

  • What about form validation?

  • What about errors when persisting the submission to a data store?

  • What about when I also want to kick off a background process or send an email on submission?

When using text/html I have to handle an all-or-nothing response and sequential bottlenecks.

All code examples will assume using Datastar and "fat morph" approach where you send the whole page and let Datastar efficiently update the DOM.

// text/html approach: Must wait for EVERYTHING to complete
app.post('/contact', async (req, res) => {
  // Validate (wait...)
  const validationErrors = await validateForm(req.body);
  if (validationErrors) {
    return res.send(renderContactPage({
      formData: req.body,
      errors: validationErrors,
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    }));
  }
  
  // Save to database (wait...)
  await saveToDatabase(req.body);
  
  // Send email (wait... 2-5 seconds!)
  await sendConfirmationEmail(req.body.email);
  
  // Update analytics (wait...)
  await trackSubmission(req.body);
  
  // Notify admin via Slack (wait...)
  await notifySlackChannel(req.body);
  
  // FINALLY respond after 5-10 seconds
  res.send(renderContactPage({
    formData: {},
    successMessage: 'Thank you! Your message has been sent.',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  }));
});

So even on success we're waiting for all of this to complete before sending a response back to the user. Lots of waiting. Lots that can go wrong with all those async operations. No way of notifying the user of progress unless you're emitting events for another part of the system to handle and notifying the user or updating page.

How about error handling?

// text/html: Handling partial failures is complex
app.post('/contact', async (req, res) => {
  try {
    await saveToDatabase(req.body);
    // Success! Data is saved. But now...
    
    try {
      await sendEmail(req.body.email);
    } catch (emailError) {
      // Email failed, but data IS saved!
      // What do I tell the user?
      return res.send(renderContactPage({
        formData: {},
        warningMessage: 'Your message was saved, but we couldn\'t send a confirmation email.',
        count: getSubmissionCount(),
        recent: getRecentSubmissions()
      }));
    }
    
    try {
      await notifySlack(req.body);
    } catch (slackError) {
      // Slack failed, but data saved and email sent!
      // Is this success or failure?
      return res.send(renderContactPage({
        formData: {},
        successMessage: 'Message sent! (Admin notification failed)',
        count: getSubmissionCount(),
        recent: getRecentSubmissions()
      }));
    }
    
    // Everything worked
    return res.send(renderContactPage({
      formData: {},
      successMessage: 'Thank you! Your message has been sent.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    }));
    
  } catch (dbError) {
    // Database failed - this is a clear error
    return res.send(renderContactPage({
      formData: req.body,
      errorMessage: 'Failed to save your message. Please try again.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    }));
  }
});
  • Have to manually track of what failed or didn't across multiple try/catch blocks.

  • What does success even mean in this flow?

  • User won't know what actually happened until all operations succeed or partially succeeded which will be slow.

And validation?

// text/html: Need separate endpoints or client-side duplication
app.post('/validate-email', async (req, res) => {
  const isValid = await checkEmailExists(req.body.email);
  res.send(renderContactPage({
    formData: req.body,
    emailError: isValid ? null : 'Email already exists',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  }));
});

app.post('/contact', async (req, res) => {
  // Re-validate everything on final submit
  const emailError = await checkEmailExists(req.body.email);
  const messageError = req.body.message.length < 10 ? 'Message too short' : null;
  
  if (emailError || messageError) {
    return res.send(renderContactPage({
      formData: req.body,
      emailError,
      messageError,
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    }));
  }
  
  await saveToDatabase(req.body);
  
  res.send(renderContactPage({
    formData: {},
    successMessage: 'Message sent!',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  }));
});
  • Multiple endpoints for field validation (or handling per-field validation branching logic in /contacts).

  • Can't easily show validation progress.

  • Inconsistency where async field validation passes, but maybe doesn't on submission.

So we can see using text/html ends up being more complex because it forces breaking up logic to handle the natural request/response (single shot) communication.

So how does SSE help?

// text/event-stream: Send multiple page updates as processing happens
app.post('/contact', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  
  const sendPage = (state) => {
    const html = renderContactPage(state);
    const body = html.match(/<body[^>]*>([\s\S]*)<\/body>/)[1];
    
    res.write('event: datastar-patch-elements\n');
    res.write(`data: ${body}\n\n`);
  };
  
  // Update 1: Show validation in progress
  sendPage({
    formData: req.body,
    statusMessage: '⏳ Validating your input...',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  const errors = await validateForm(req.body);
  if (errors) {
    // Update 2: Show validation errors
    sendPage({
      formData: req.body,
      errors,
      statusMessage: '❌ Please fix the errors above',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    return res.end();
  }
  
  // Update 3: Show saving in progress
  sendPage({
    formData: req.body,
    statusMessage: '⏳ Saving to database...',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  await saveToDatabase(req.body);
  
  // Update 4: Show email sending
  sendPage({
    formData: {},
    statusMessage: '⏳ Sending confirmation email...',
    successMessage: 'Your message has been saved!',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  await sendEmail(req.body.email);
  
  // Update 5: Complete
  sendPage({
    formData: {},
    statusMessage: '✅ All done!',
    successMessage: 'Thank you! Your message has been sent.',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  res.end();
});

Now using SSE we can patch in updates granularly, immediately and quickly all in the same route handler.

And error handling?

// text/event-stream: Each step can succeed or fail independently
app.post('/contact', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  
  const sendPage = (state) => {
    const html = renderContactPage(state);
    const body = html.match(/<body[^>]*>([\s\S]*)<\/body>/)[1];
    
    res.write('event: datastar-patch-elements\n');
    res.write(`data: ${body}\n\n`);
  };
  
  sendPage({
    formData: req.body,
    statusMessage: '⏳ Saving to database...',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  try {
    await saveToDatabase(req.body);
    
    sendPage({
      formData: {},
      statusMessage: '✓ Data saved successfully!',
      successMessage: 'Your message has been received.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    
  } catch (dbError) {
    sendPage({
      formData: req.body,
      statusMessage: '❌ Database error. Please try again.',
      errorMessage: dbError.message,
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    return res.end();
  }
  
  sendPage({
    formData: {},
    statusMessage: '⏳ Sending confirmation email...',
    successMessage: 'Your message has been received.',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  try {
    await sendEmail(req.body.email);
    
    sendPage({
      formData: {},
      statusMessage: '✓ Confirmation email sent!',
      successMessage: 'Your message has been received.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    
  } catch (emailError) {
    sendPage({
      formData: {},
      statusMessage: '⚠️ Data saved, but email failed. We\'ll retry later.',
      successMessage: 'Your message has been received.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
  }
  
  sendPage({
    formData: {},
    statusMessage: '⏳ Notifying team...',
    successMessage: 'Your message has been received.',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  try {
    await notifySlack(req.body);
    
    sendPage({
      formData: {},
      statusMessage: '✓ All done!',
      successMessage: 'Your message has been received.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    
  } catch (slackError) {
    sendPage({
      formData: {},
      statusMessage: '⚠️ Your submission is saved. Team notification failed.',
      successMessage: 'Your message has been received.',
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
  }
  
  res.end();
});

No error/success states leaking in to your route handler. Just passing in current state as it changes in to your template where all the display logic should live.

And validation.

// text/event-stream: One endpoint handles both validation and submission
app.post('/contact', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  
  const sendPage = (state) => {
    const html = renderContactPage(state);
    const body = html.match(/<body[^>]*>([\s\S]*)<\/body>/)[1];
    
    res.write('event: datastar-patch-elements\n');
    res.write(`data: ${body}\n\n`);
  };
  
  const { email, message, validateOnly } = req.body;
  
  // Live validation while typing
  if (validateOnly) {
    const emailError = await checkEmailExists(email);
    const messageError = message.length < 10 ? 'Message too short' : null;
    
    sendPage({
      formData: { email, message },
      emailError,
      messageError,
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    
    return res.end();
  }
  
  // Full submission with validation
  sendPage({
    formData: req.body,
    statusMessage: '⏳ Validating...',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  const emailError = await checkEmailExists(email);
  const messageError = message.length < 10 ? 'Message too short' : null;
  
  if (emailError || messageError) {
    sendPage({
      formData: req.body,
      statusMessage: '❌ Please fix errors',
      emailError,
      messageError,
      count: getSubmissionCount(),
      recent: getRecentSubmissions()
    });
    return res.end();
  }
  
  sendPage({
    formData: req.body,
    statusMessage: '✓ Valid! Saving...',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  await saveToDatabase(req.body);
  
  sendPage({
    formData: {},
    statusMessage: '✓ Saved!',
    successMessage: 'Thank you! Your message has been sent.',
    count: getSubmissionCount(),
    recent: getRecentSubmissions()
  });
  
  res.end();
});
  • One endpoint handles both live validation and final submission.

  • Same rendering logic for both scenarios.

  • Can show validation progress during submission ("Validating..." → "Valid! Saving...").

  • No duplication of validation logic.

  • Live validation and submission validation are guaranteed to be identical.

The main point to take away is text/event-stream and SSE can do anything a text/html or application/json response can AND more. It's not a specialized tool for real-time features, but a better default to client-server communication that you're already doing, but over multiple request/response round trips and multiple resources.

With traditional approaches, you need different mental models for different scenarios:

  • Simple form? Return ⁠text/html

  • Need to update multiple elements? Add out-of-band swaps or multiple requests

  • Want live validation? Create separate endpoints

  • Need progress updates? Switch to polling or WebSockets

  • Real-time features? Now you need WebSockets infrastructure

With SSE, you have one mental model:

  • Client makes a request (GET, POST, PUT, DELETE)

  • Server opens an event stream

  • Server sends page states as they evolve

  • Server closes the connection when done

That's it. Whether you send one update or a hundred, whether it takes 10ms or 10 seconds, the pattern is identical.

Your route handlers will feel more unified, easier to debug and reason about and easier to refactor later. Say you want to use a CQRS architecture you can move the rendering to a dedicated resource for the SSE connection and emit events from your submission handler and the plumbing is all the same. You only need to worry about text/html for your initial page loads and text/event-stream for everything else. No more deciding "should this be JSON or HTML?" or "should I use WebSockets for this?" or "do I need polling here?" One pattern handles all server-driven page updates. One way to handle all page updates.

So anyway I no longer think text/html is the simpler approach for page updates or that SSE is an "advanced" feature meant only for specific use cases like streaming or updates over time. It's a an often overlooked and simpler approach that I'm glad I have a better understanding of.

https://yagni.club/3m475dwkjvc2o
ui = fn(state) done right
If "ui = fn(state)" has ever resonated with you as a frontend developer you owe it to yourself to consider we as an industry have been going about it all wrong for years. Likely your state already lives on the backend and by syncing it to the frontend to render UI we're adding measurable amounts of unnecessary complexity. If you're to radically simplify if you development environments and production deployments then read on…
Show full content

Some preamble before we get started:

  • If you are sold on local-first syncing for reasons like data sovereignty and privacy this isn't for you. Consider authoring local native apps though!

  • I realize the irony that I am authoring this in a heavily client side application with local-first features. <3 leaflet!

  • If you are vibes based and LOVE authoring JavaScript/TypeScript for the frontend this isn't for you.

  • If your identity is strongly tied to a front-end framework this likely isn't for you.

  • If you think offsetting server costs by moving computation to the client is a good trade off… this isn't for you.

  • If you're dead set on SPA-like navigation this isn't for you.

  • If while reading you find yourself thinking "you can't implement continuous media playback across navigations" this isn't for you.

If you have an open mind, want to simplify your development and build some of the most performant frontends you've ever built then you might get something out of this. I don't aim to convince die-hard JS devs, but hopefully connect some dots for people who find themselves tired of conversations like this in 2025:

OK so you hopefully have heard of something a long the lines of:

ui = function(state)

Idea being you should be able to derive your UI via pure functions that are provided the entire state of the world. It's a great model and is simple to reason about. The issue is how we go about implementing this. React popularized component driven development that really does embody this idea.

function Button({ label }) { 
  return <button>{label}</button> 
}

It's really great and beautiful in its simplicity. However when you extrapolate this in practice real-world applications with a lot of complex state we're looking at:

  • Syncing that state from the backend to the frontend to render any updates after initial page load

    • Now you're bring things in like Tanstack Query, Redux, signals etc.

    • Polling API endpoints or getting pushed JSON

    • Deserializing that JSON to store in memory. Oh the deserialization! Oh the humanity!

  • Hydrating components on the frontend when server rendering

    • Sending initial HTML along with the state needed to render so that it can then re-render in to a client side renderable app for future updates.

Now if your state really does only live locally, great, but still consider authoring a native app. taps preamble: This isn't for you.

That is a lot of extra work to get some HTML on the page when your backend can serve HTML directly and is the true owner of your state anyway.

This is just the read path too. What about updating state? Well again we have a beautiful out of context example.

function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <button onClick={handleClick}>
        Count: {count}
      </button>
    </div>
  );
}

With a real-world application we're looking at:

  • Tanstack Query, zustand, (pick your poison) or calling fetch inside a useEffect where hopefully you remembered to account for all the various loading and error states involved in making a network request OH AND that your useEffect dependency array is g2g

    • Even with Tanstack Query you need to understand how to author a mutation and how that impacts your local cache etc.

    • Do you really want to baby-sit your client side cache?!

  • OK this update also needs to sync the change to the backend over the network

    • I HATE the network it's so slow…

    • Better do this as an optimistic update

    • OK so I have some pretend state, on top of the copy of the backend state… wait wtf I thought it was just supposed to be ui = fn(state)! Shit!

  • t

I've built many applications like this. I've enjoyed using some of these tools/approaches. I admired how well abstracted and nicely designed the API is for tanstack-query (it really is good software!!). All this stuff feels like a known quantity or just how you build modern applications. I joined the cargo cult.

However I've come to realize it's all unnecessary. You may have dug yourself in to a deep hole with this kinds of architecture and are just feeling like…

There is a better way.

You've probably been waiting for me to mention "hypermedia" so here it is.

initiating shill mode

Datastar is your path to salvation. Datastar is a 10kb hypermedia shim+framework. There are many like it, but this one is mine (mine as in choice, not the author!).

There are plenty of pages out there comparing Datastar to other frontend frameworks, hypermedia or not. How you can do the same thing with htmx etc… I won't do that here, but will maybe loop back and add some more links to that. What I will say quickly however is yes you can do this with any library or framework (it's mostly a matter of leveraging http effectively) Datastar does it in the most terse and direct way. API design matters and Datastar does a bang up job here.

I want to focus on how using something like Datastar puts you in the pit of success by encouraging these properties:

  • Keep most of your state on the backend. Backend is the source of truth always.

  • Leverage "fat morphs" (replace most of page with new html). Render the whole page on each update.

  • Separate your updates from reading when rending your UI (CQRS).

I think most people might trip up here. Yes you're going to have to write less frontend code and more backend code. Business logic doesn't disappear, but incidental complexity does.

I'm going to throw you a bone… You can keep using JSX and components alright? Not only that you can literally use any backend language you're planning on learning next weekend when you get some free time.

You'll need your http server and for the sake of simplicity a single route… I'll call this a Single Route Architecture.

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { EventEmitter, on } from "node:events";

let counter = 0;

function Counter({ count }: { count: number }) {
  return <button data-on-click="@post('/')">Count: {count}</button>;
}

function Body({ count }: { count: number }) {
  return (
    <body data-on-load="@get('/')">
      <Counter count={count} />
    </body>
  );
}

function Page({ count }: { count: number }) {
  return (
    <html>
      <head>
        <script
          type="module"
          src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.5/bundles/datastar.js"
        ></script>
      </head>
      <Body count={count} />
    </html>
  );
}

const app = new Hono();
const events = new EventEmitter();

app.get("/", async (c) => {
  const seereq = c.req.raw;

  if (req.headers.get("datastar-request")) {
    return streamSSE(c, async (stream) => {
      const ac = new AbortController();

      stream.onAbort(() => {
        ac.abort();
      });

      try {
        for await (const _ of on(events, "inc", { signal: ac.signal })) {
          const htmlString = (<Body count={counter} />).toString();
          await stream.writeSSE({
            event: "datastar-patch-elements",
            data: `selector body\nelements ${htmlString}`,
          });
        }
      } catch (err: any) {
        if (or err.code !== "ABORT_ERR") {
          throw err;
        }
      }
    });
  }

  return c.html(<Page count={counter} />);
});

app.post("/", async (c) => {
  counter++;
  events.emit("inc");
  return c.body(null);
});

export default {
  port: 3000,
  fetch: app.fetch,
};
GitHub - derekr/ui-fn-state-done-right: Bun app showcasing simple ui = fn(state) idea using Datastar

Bun app showcasing simple ui = fn(state) idea using Datastar - derekr/ui-fn-state-done-right

This is the whole app. No backend or frontend monorepo splitting. No frontend or backend bundling/splitting. This is with Bun+Hono, but can be done w/ node (tsx), deno and other JS runtimes. There's an optional Datastar TypeScript SDK for working with SSE as well if your needs are more complex than this.

Clicking the button makes a POST request which increments the counter and emits the inc event. When the page loads a SSE connection is established where we iterate over the events object keeping the connection alive and handling any inc events. When the event is fired we pass the count to Body for "re-rendering" and send the whole html payload over SSE back to the client.

This basic example is realtime multiplayer btw. Anyone that accesses the app is seeing the exact same view and when they click the shared count is incremented and pushed to everyone.

There are lots of nuanced benefits to this approach with regards to efficiency and how much you can send over the wire etc… (hint: it's a lot more than you think).

Pay attention to how we kept the ui = fn(state) principal while still communicating over the wire, but with way less overhead and complexity. Your templating language or components still just take whatever state is needed and render based on that. In a real-world application your state typically includes things like current user/session, posts for that route, presence data for multi-player experiences… 

And while this is a simple example this basic architecture can scale to almost any kind of experience. That one SSE connection can push updates from anywhere, not just an action or mutation triggered by the user. Imagine using the same exact setup above to push updates from a background job or process like AI chats. You bring your own event bus/messaging system and push some more updates over SSE.

So if you're even a little curious about simplifying your web projects without sacrificing performance or capabilities come by the Discord and see what it's about. Lot's of smart craftspeople with lots of experience that you'll learn a ton just be hanging out for a bit and absorbing these concepts.

If you're happy and productive writing React or other client heavy apps that's great. This isn't for you.

Resources

https://data-star.dev/guide/getting_started

The example in this post is simplified to help highlight the ui = fn(state) concept. If you want to learn more about using Datastar with Bun you should check out The Datastar Chat Example repo.

Curious about how far people are taking the concept with Datastar? Check out these amazing projects:

https://yagni.club/3m3anpetejc23