Get a Working Dashboard Up
I shipped a /predict endpoint and called the model done. It took a JSON loan record, ran the Lending Club default scorer, and returned a probability and a label, and every test passed. Then the credit lead who actually decided whether to flag an applicant asked me to send her the results, and I realized she had no way to reach it. The only way to call that endpoint was curl or a Python client, so the only people who could use the model were the ones who could write an HTTP request by hand. I spent that afternoon emailing her screenshots of curl output one record at a time before I admitted the model needed a page. That page took an hour to build and did more for adoption than the weeks I had spent on the scorer.
The fastest way to understand what a frontend is turns out to be building one that talks to the model you already shipped, so that is what this lesson does: a page where someone types a record, clicks a button, and reads a prediction. By the end the dashboard works end to end. The next two lessons make it safe.
This is deliberately not a frontend course. The goal is the smallest page that loads in a browser, calls the live service, and shows a real result: three moves, no framework. The reason to understand those three moves precisely, rather than copying a template, is that every way an ML frontend misleads or breaks later in this module is a consequence of where exactly the boundary between the page and the server sits. Get that boundary wrong in your head and the hardening lessons read as arbitrary. Get it right and they read as forced.
I shipped a /predict endpoint and called the model done. It took a JSON loan record, ran the Lending Club default scorer, and returned a probability and a label, and every test passed. Then the credit lead who actually decided whether to flag an applicant asked me to send her the results, and I realized she had no way to reach it. The only way to call that endpoint was curl or a Python client, so the only people who could use the model were the ones who could write an HTTP request by hand. I spent that afternoon emailing her screenshots of curl output one record at a time before I admitted the model needed a page. That page took an hour to build and did more for adoption than the weeks I had spent on the scorer.
The fastest way to understand what a frontend is turns out to be building one that talks to the model you already shipped, so that is what this lesson does: a page where someone types a record, clicks a button, and reads a prediction. By the end the dashboard works end to end. The next two lessons make it safe.
This is deliberately not a frontend course. The goal is the smallest page that loads in a browser, calls the live service, and shows a real result: three moves, no framework. The reason to understand those three moves precisely, rather than copying a template, is that every way an ML frontend misleads or breaks later in this module is a consequence of where exactly the boundary between the page and the server sits. Get that boundary wrong in your head and the hardening lessons read as arbitrary. Get it right and they read as forced.
What a frontend is, and what a tiny dashboard actually looks like
A competent engineer who has only written backend code tends to carry one mental model into their first frontend: the page is the front half of the same program, the server is the back half, and the two share state the way two modules in one process share an imported variable. Under that model “calling the model” is a function call that happens to be styled with HTML. That model is wrong in a way that determines everything else, and it is worth seeing exactly where it breaks before stating the correct one.
The page and the server share no memory: the only channel is HTTP
A frontend is code that runs in the user’s browser. The backend is your M6 service running on a server somewhere else. These are two separate programs in two separate address spaces, usually on two different machines, and they share no variables, no functions, and no memory. The browser cannot call a Python function on the server; the server cannot read a variable in the page. The only channel between them is an HTTP request the browser sends over the network and the HTTP response that comes back. This is the same client/server boundary M6 stood up, now seen from the other side: where M6 was the process receiving the request and returning JSON, the dashboard is the process sending it and consuming that JSON.
Why this matters is not pedantic. Everything the page can ever know about the model arrives as bytes in a response body. A 0.41, a label, a version string all cross the wire as text and get parsed on arrival, and there is no second channel where “the real object” quietly lives. That single fact is what makes the entire dashboard reduce to three moves: collect an input, fetch (issue the HTTP request and wait for the response), and render (write a value from the parsed response into the page so a human sees it). fetch is the browser’s built-in for issuing that HTTP request; it is the client-side mirror of the httpx call a Python client would make. Hold the boundary in your head as HTTP-and-nothing-else and the later lessons land: a request can fail on the wire, the bytes that come back are a number a human will misread, and any secret in the code that issues the request shipped to the browser to get there.
Here is the shape of the whole thing before a single line of real code. The HTML is the input and the render target; the JavaScript is the three moves.
# index.html
# <form> # the input: fields for the record to score
# <input name="age"> # one field per feature the /predict schema expects
# <button>Predict</button> # triggers the call
# </form>
# <div id="result"></div> # the render target: where the prediction appears
#
# app.js
# on button click:
# build the record object from the form # gather inputs
# fetch(API_URL, { POST, JSON body }) # call M6 /predict
# read response.json() # { probability, label, model_version }
# write the result into #result # render
That skeleton is the destination: the form collects the record, fetch carries it to /predict, response.json() parses the three-key answer, and the render step writes it where a person can read it. Nothing else in a working dashboard is load-bearing; everything else is styling. The trust boundary this draws is worth making explicit, because Lessons 2 and 3 both attack it.
rectangle "Browser (untrusted)\nHTML form, app.js, fetch\neverything here is downloaded and inspectable" as browser
rectangle "M6 server (trusted)\n/predict, model, secret store\nthe only place a secret can live" as server
browser --> server : HTTP request (JSON record)
server --> browser : HTTP response\n{ probability, label, model_version }
The diagram makes the asymmetry visible: everything on the browser side is downloaded onto a machine you do not control and is fully inspectable, while the server side is the only place a secret or a model can live unseen. The single edge between them is the request/response pair, and it crosses a trust line. That line is exactly what Lesson 2 stresses (the request can fail on the wire) and what Lesson 3 exploits (anything you put on the browser side is public). For now the point is narrower: since the only thing the page receives is a response body, the whole job is to send a record and turn the parsed reply into something a person reads.
The Python below stands in for the browser. It POSTs one Lending Club loan record to /predict with httpx (the server-side stand-in for fetch), reads back the three-key response, and prints the parsed dict. Watch that the reply is an ordinary dictionary with exactly three keys: there is no special “prediction object,” only data that arrived as bytes and got parsed.
import httpx
# A stub /predict the request hits in place of the live M6 service.
# It returns the same { probability, label, model_version } contract M6 returns.
def fake_predict_endpoint(record: dict[str, float]) -> dict[str, object]:
# The real server runs the model here; the contract is what matters to the client.
return {"probability": 0.41, "label": "repay", "model_version": "lc-default-v3"}
def call_predict(record: dict[str, float]) -> dict[str, object]:
# In the browser this is: await fetch(API_URL, { method: "POST", body: JSON.stringify(record) })
# then await response.json(). httpx is the Python mirror of that exchange.
return fake_predict_endpoint(record)
# httpx is imported to mirror the client side of the exchange; the stub stands in
# for the network so the contract, not transport, is what the lesson shows.
_client_type: str = httpx.Client.__name__
loan_record: dict[str, float] = {"loan_amnt": 12000, "annual_inc": 65000, "dti": 18.2}
response: dict[str, object] = call_predict(loan_record)
print("parsed response:", response)
print("type:", type(response).__name__)
print("keys:", list(response.keys()))Back comes a plain dict with three keys, not a wrapped result type, which is the whole point. Rendering it is formatting, not magic: once the bytes are parsed, the page is reading values out of a dictionary. The question the rest of this section answers is which of those three values a human should see, but first the smaller skill: pulling a value out and deciding what text it implies.
Self-explanation prompt: the stub returns a Python dict, but over a real network the body arrives as a JSON string. What step has to happen between “bytes arrived” and “I can read response['probability']”, and which of the three moves owns it?
Try It 1
The /predict response is {"probability": 0.73, "label": "default", "model_version": "lc-default-v3"}. Write the one line that pulls probability out of the parsed dict, and the one line that turns label into the text a human should read: "Likely to default" or "Likely to repay". No HTML yet; this is the parse-and-decide step the render move depends on.
response: dict[str, object] = {
"probability": 0.73,
"label": "default",
"model_version": "lc-default-v3",
}
# 1. Pull the probability out of the parsed dict.
prob: float = 0.0 # replace with the value from response
# 2. Decide the human-readable text from label.
verdict: str = "?" # "Likely to default" if label is "default", else "Likely to repay"
print("probability:", prob)
print("verdict:", verdict)Hint
The response is a dictionary, so reading a value is a key lookup. For the verdict, you are mapping one of two label strings to one of two human sentences, so a single conditional expression covers it. Re-read "the page is reading values out of a dictionary."Solution
The solution does one key lookup for probability and maps the label string to one of two human sentences with a single conditional. Watch that nothing here touches the model; it is pure dictionary access on the already-parsed response.
response: dict[str, object] = {
"probability": 0.73,
"label": "default",
"model_version": "lc-default-v3",
}
prob: float = float(response["probability"])
verdict: str = (
"Likely to default" if response["label"] == "default" else "Likely to repay"
)
print("probability:", prob)
print("verdict:", verdict)The render step never needed the model; it needed two dictionary reads and one decision about wording. That is the entire content of “turn a response into something a human sees,” and it is why the boundary framing matters: once the bytes are parsed, the page is doing data formatting, and the only hard part left is the timing of when those bytes actually exist.
The timing is the next problem, and it is the one that makes a correct dashboard look broken.
Wire the fetch: from button click to a rendered prediction
That skeleton suggests an obvious reading order: build the record, call fetch, read the response, render. A backend engineer writes exactly that, top to bottom, and the page renders nothing: no error, no crash, a blank result div. The natural conclusion is that fetch failed or the endpoint is down. Neither is true. The request succeeded and the response is fine; the code read the result before it existed.
fetch is asynchronous: the response arrives later, not on the next line
The reason fetch behaves this way is the browser’s execution model. Page JavaScript runs on a single thread, the same thread that handles clicks, scrolling, and repaints, and the interpreter processes one statement at a time. If fetch blocked that thread while the request crossed the network, the entire page would freeze for the whole round trip: clicks would queue, scrolling would stall, the UI would lock until the response came back. To avoid that, fetch is built to not block. It issues the request and immediately returns a promise, a placeholder object standing in for a result that does not exist yet. The thread is free to keep handling the UI, and when the response eventually arrives, the browser’s event loop schedules the promise’s continuation to run.
That is why reading the result on the next synchronous line sees nothing. The code below issues the request and then, on the very next line, tries to use the result, exactly the top-to-bottom reading order a backend engineer writes. The value genuinely does not exist yet, because the request is still in flight.
// app.js: the wrong order
const promise = fetch(API_URL, { method: "POST", body: JSON.stringify(record) });
const data = promise.json(); // promise is not a Response yet: this line runs NOW,
// while the request is still crossing the network
document.querySelector("#result").textContent = data.probability; // renders undefined
promise is the placeholder, not the response, so .json() on it is meaningless and data.probability is undefined. The page writes undefined into the result div and looks broken when nothing failed. The fix is await: it suspends the async function at that point and resumes it on the line after, with the resolved value, once the promise settles. await does not block the thread; it yields it, so the UI stays responsive while the function is paused. There is a second await that surprises people: response.json() is itself asynchronous, because it reads the response body stream to completion before parsing it, so it returns a promise too and must also be awaited.
The Python below mirrors the corrected sequence: issue the request, await the response, then await the parse, then render, using a coroutine so the suspend-and-resume timing is real rather than illustrated. Watch the order: the read of probability happens only after both awaits have resolved, which is the only point in time where the value actually exists.
import asyncio
async def fetch_predict(record: dict[str, float]) -> dict[str, object]:
# Stands in for the browser's fetch: the request crosses the "network" and
# the body is not ready immediately. await yields control while it is in flight.
await asyncio.sleep(0.01) # the round trip -- the thread is free during this
return {"probability": 0.41, "label": "repay", "model_version": "lc-default-v3"}
async def on_click(record: dict[str, float]) -> str:
response = await fetch_predict(record) # suspend here; resume when it resolves
probability = float(response["probability"]) # the value exists only now
return "rendered: probability = " + format(probability, ".2f")
result: str = asyncio.run(
on_click({"loan_amnt": 12000, "annual_inc": 65000, "dti": 18.2})
)
print(result)That render line ran with a real number because it ran after the await resolved, not on the line after the call was issued. The rule that falls out is the one thing to remember from this section: the render must live inside the awaited block, after the response has resolved, because that is the only moment the value exists. The gap between “request sent” and “response arrived” is exactly where the blank-page bug lives, and it is worth seeing that gap as a sequence in time.
fetch issues the HTTP request and hands back a promise on the same line. The thread is now free: the page can still scroll and repaint. The response has not arrived.
.json() on it yields nothing and the page renders undefined. This is the blank-page branch.
await, and await response.json() reads the body stream to completion and parses it into the three-key object.
probability / label into #result. The render lived inside the awaited block, which is why it had a real value to write.
The scrolly’s whole point is that the value does not exist until step 4: the blank-page bug is reading at step 3. Prose can state that; the sequence makes the when unmistakable. With the timing fixed, the dashboard reliably has a prediction in hand. The next exercise is the bug itself: a render placed before the await resolves.
Try It 2
This handler calls the endpoint and then renders, but the render reads the result before the await resolves, so it renders nothing. Move the render so it runs after the response actually exists. The starter “renders” by returning a string instead of writing to the DOM, so you can see what value the render step had.
import asyncio
async def fetch_predict(record: dict[str, float]) -> dict[str, object]:
await asyncio.sleep(0.01)
return {"probability": 0.73, "label": "default", "model_version": "lc-default-v3"}
async def on_click(record: dict[str, float]) -> str:
pending = fetch_predict(record) # the request is issued but NOT awaited here
# this runs before the response exists -- fix the order
rendered = "probability = ???"
# response = await pending
pending.close() # avoid an un-awaited coroutine warning in the starter
return rendered
print(asyncio.run(on_click({"loan_amnt": 12000})))Hint
The render needs a value that does not exist until the promise resolves. Where is the only point in the function that the resolved value is available? Re-read "the render must live inside the awaited block." You do not need to change the fetch; you need to change when the render runs relative to the await.Solution
The solution moves the render below both awaits, so it reads the value only after the response and its parse have resolved. Watch which line the render now sits on relative to the await — that position, not the fetch call, is the whole fix.
import asyncio
async def fetch_predict(record: dict[str, float]) -> dict[str, object]:
await asyncio.sleep(0.01)
return {"probability": 0.73, "label": "default", "model_version": "lc-default-v3"}
async def on_click(record: dict[str, float]) -> str:
response = await fetch_predict(record) # await first
probability = float(response["probability"]) # value exists now
rendered = "probability = " + format(probability, ".2f")
return rendered # render after the resolve
print(asyncio.run(on_click({"loan_amnt": 12000})))Moving the render below the await is the entire fix: the request was never the problem, the read order was. This is the same “the work happens later, not on the next line” model that M6’s async serving used on the server side; here the client is the one waiting on the network. Now that a real value reliably lands in the handler, the last move is deciding what to put on screen.
Show a record’s real prediction on screen
With the timing solved, the obvious finish is to drop the parsed response onto the page and move on: result.textContent = JSON.stringify(response) and the dashboard “shows the prediction.” It does, technically. It is also useless, and the reason it is useless is the actual subject of an ML frontend: the render step is where a model output becomes something a human can act on, and that is a product decision, not a formatting one.
Rendering is the last transformation before a human acts
Three keys come back in the /predict response, and each is a different kind of thing the render step must treat differently. label is the model’s verdict after the server already applied a threshold; it is the human-readable decision and the safest thing to lead with. probability is a raw score in [0, 1] that a non-ML reader will misread as a guaranteed frequency, so rendering it at all is a decision rather than a default. model_version is provenance: the identifier of exactly which trained artifact produced this answer. Dumping all three as raw JSON treats them as interchangeable text, which is precisely the mistake. A person reading the page does not know what 0.41 means and will either ignore the wall of JSON or fixate on the wrong number in it.
Why is this a product decision and not formatting? Because the page is the last transformation before a human acts. The same response can be rendered as a clear verdict the user trusts correctly, as a wall of JSON they ignore, or as a bare decimal they misread, and the model has no say in which. Whatever the render step chooses to put on screen is what the user will act on. A raw model output is a probability the model computed, not a verdict a person can use; turning it into a readable statement is a separate, explicit stage that the frontend owns. (Whether showing the probability at all misleads is the hardening pass in Lesson 3; here the job is to produce a clear statement from the response.)
One key is worth keeping on screen even though it is invisible on the happy path: model_version. It is worthless right up until a prediction is disputed or the model is rolled back, at which point it is the only way to tie a screen the user saw back to a specific trained artifact, through the model_version the M6 endpoint returns. A version id is the handle that resolves “the model said the wrong thing” from an unanswerable argument into a lookup against the exact model that made the call. It is cheap to render and indispensable the moment something goes wrong, which is the definition of provenance worth keeping.
Below, a real Lending Club /predict response becomes the exact display string the page will show (label as words, probability formatted, version noted) instead of a dumped dict. Watch the contrast between the raw-JSON render and the human render: identical data, opposite usefulness.
def render_raw(response: dict[str, object]) -> str:
# The "technically working" render: dump the dict. Correct bytes, useless to a human.
return str(response)
def render_for_human(response: dict[str, object]) -> str:
verdict: str = (
"Likely to default" if response["label"] == "default" else "Likely to repay"
)
pct: str = format(float(response["probability"]) * 100, ".0f")
version: str = str(response["model_version"])
# model_version stays on screen: it is the handle to dispute or roll back the call.
return verdict + " (model score " + pct + "%, model " + version + ")"
response: dict[str, object] = {
"probability": 0.73,
"label": "default",
"model_version": "lc-default-v3",
}
print("raw render: ", render_raw(response))
print("human render:", render_for_human(response))Raw JSON hands a non-ML reader a dictionary; the human render hands them a sentence they can act on and a version string they will need only if the call is challenged. That is the working dashboard’s output, end to end: input collected, request awaited, response parsed, value rendered as a statement. One named failure mode hides in the human render, and this lesson stops short of fixing it: 73% read as “73 out of 100 will default,” which is exactly what Lesson 3 takes apart.
Try It 3
Turn one record’s /predict response into a single human-readable statement plus the model_version. Use a response from a context other than the worked example so you are applying the rule, not copying it: this one is a low-risk applicant. Produce one line a non-ML reader could act on, and keep the version on screen.
response: dict[str, object] = {
"probability": 0.12,
"label": "repay",
"model_version": "lc-default-v4",
}
# Build ONE human-readable line: a verdict, the score as a percent, and the model version.
statement: str = "?" # replace
print(statement)Hint
You need the same three pieces as the worked example: a verdict from `label`, the probability formatted as a percent, and the version string kept visible. Re-read "rendering is the last transformation before a human acts" for why the version stays on screen even when nothing is wrong.Solution
Here the low-risk applicant’s response becomes one statement built from the three pieces: verdict from label, probability as a percent, version kept visible. Watch that the version string stays on screen even though this is the happy path where nothing is disputed yet.
response: dict[str, object] = {
"probability": 0.12,
"label": "repay",
"model_version": "lc-default-v4",
}
verdict: str = (
"Likely to default" if response["label"] == "default" else "Likely to repay"
)
pct: str = format(float(response["probability"]) * 100, ".0f")
statement: str = (
verdict
+ " (model score "
+ pct
+ "%, model "
+ str(response["model_version"])
+ ")"
)
print(statement)One sentence a credit lead can read at a glance now stands in for the three-key dict, with the provenance attached for the day someone challenges it. That is the deliverable this module hardens: the next lesson keeps this page from lying when the API blinks, and the one after stops the 73% from being read as a calibrated real-world frequency.
Summary
- A frontend and the backend share no memory; they are separate processes whose only channel is an HTTP request and the response that comes back, so everything the page knows about the model arrives as bytes in a response body.
- Every tiny dashboard reduces to three moves (collect an input,
fetchthe prediction, render the result into the page), andfetchis the browser’s built-in for issuing that request. fetchis asynchronous: it returns a promise immediately so the single UI thread does not freeze, and the resolved value exists only afterawait. The render must live inside the awaited block, or it readsundefinedand the page looks broken when nothing failed.response.json()is itself asynchronous and must be awaited, because it reads the body stream to completion before parsing.- The render step is a product decision:
labelis the readable verdict,probabilityis a raw score to handle with care, andmodel_versionstays on screen as the provenance handle for disputes and rollbacks.
Check your understanding:
- A frontend and a backend share no memory, so what is the only way the page can get a prediction, and which three keys does the M6
/predictresponse hand back? - Why can a
fetchresult beundefinedif you read it on the very next line, and where must the render step go instead? - What does the dashboard turn the three-key response into, and why keep
model_versionon screen when it is invisible on the happy path?
This lesson is part of Pro
The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.
Unlock with Pro Sign in