Android Data, Networking & APIs

Networking with HttpURLConnection

18 min Lesson 5 of 12

Networking with HttpURLConnection

Almost every modern Android app exchanges data with a remote server — fetching a news feed, posting a user action, downloading an image. Before you reach for a higher-level library like Retrofit, you need to understand the low-level plumbing that underlies all Android HTTP work: java.net.HttpURLConnection. This class ships with the Java standard library and requires no additional dependencies, making it the right tool for small requests, build scripts, or anywhere you want zero extra weight.

Why You Cannot Do Network Work on the Main Thread

Android's main thread drives the UI: it processes touch events, measures and draws views, and runs your Activity lifecycle callbacks. Any blocking operation on this thread — including a network call that might take hundreds of milliseconds — freezes the whole UI. Android enforces this rule at runtime: trying to open a socket on the main thread throws a NetworkOnMainThreadException.

NetworkOnMainThreadException is a hard crash. It is not a lint warning you can ignore — Android deliberately kills apps that block the UI thread with network I/O. Every HTTP call you write must run on a background thread.

In this lesson you will use a plain background Thread to keep the focus on HttpURLConnection itself. In Lesson 4 you saw how AsyncTask, HandlerThread, and ExecutorService give you cleaner abstractions; in real apps you would wrap this code in one of those patterns.

Declaring the Internet Permission

Before your app can open any socket, you must declare the INTERNET permission in AndroidManifest.xml. Unlike dangerous permissions (camera, contacts), this is a normal permission: Android grants it automatically and you do not need a runtime prompt.

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.INTERNET" />

Forgetting this line produces a silent failure: the openConnection() call succeeds but every connect() call throws a java.net.SocketException: Permission denied. Always add it before writing any networking code.

Making a GET Request Step by Step

A minimal GET request with HttpURLConnection follows six steps: build a URL, open the connection, configure it, connect, read the response, and close the stream. Here is a complete, self-contained example that fetches a JSON payload from a public API:

import android.os.Handler; import android.os.Looper; import android.util.Log; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; public class NetworkHelper { private static final String TAG = "NetworkHelper"; /** Fetch the given URL on a background thread; deliver result on the main thread. */ public static void fetchGet(String urlString, Callback callback) { new Thread(() -> { String result = null; String error = null; HttpURLConnection conn = null; try { // 1. Build URL and open connection URL url = new URL(urlString); conn = (HttpURLConnection) url.openConnection(); // 2. Configure the connection conn.setRequestMethod("GET"); conn.setConnectTimeout(10_000); // 10 s to establish TCP conn.setReadTimeout(15_000); // 15 s to receive data conn.setRequestProperty("Accept", "application/json"); // 3. Connect and check the HTTP status code int statusCode = conn.getResponseCode(); Log.d(TAG, "HTTP " + statusCode + " from " + urlString); if (statusCode == HttpURLConnection.HTTP_OK) { // 200 // 4. Read success stream result = readStream(conn.getInputStream()); } else { // 4b. Read error stream for diagnostic detail InputStream errStream = conn.getErrorStream(); error = (errStream != null) ? readStream(errStream) : "HTTP " + statusCode; } } catch (IOException e) { error = e.getMessage(); Log.e(TAG, "Network error", e); } finally { // 5. Always disconnect if (conn != null) conn.disconnect(); } // 6. Deliver on the main thread final String finalResult = result; final String finalError = error; new Handler(Looper.getMainLooper()).post(() -> { if (finalResult != null) { callback.onSuccess(finalResult); } else { callback.onError(finalError); } }); }).start(); } private static String readStream(InputStream is) throws IOException { StringBuilder sb = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { sb.append(line).append('\n'); } } return sb.toString(); } public interface Callback { void onSuccess(String body); void onError(String message); } }

A few details deserve attention:

  • setConnectTimeout vs setReadTimeout: The connect timeout governs how long the OS waits to complete the TCP three-way handshake. The read timeout governs how long to wait for data after the connection is established. Both default to zero (infinite) if you do not set them, which means your background thread could block forever on a dead server.
  • getResponseCode() triggers the request: Calling this method is the point at which HttpURLConnection actually sends the request and waits for the response headers. Calls before this point only configure the connection object.
  • Error stream: When the server returns 4xx or 5xx, the body is on getErrorStream(), not getInputStream(). Reading it lets you surface useful API error messages to the developer.
  • disconnect() in finally: This closes the underlying socket. Skipping it leaks OS resources, especially on busy screens that make many requests.

Sending a POST Request with a JSON Body

For a POST request you must configure three extra things: set the method to "POST", enable output with setDoOutput(true), and write the body bytes to getOutputStream(). The connection will not be sent until you read the response code.

public static void postJson(String urlString, String jsonBody, Callback callback) { new Thread(() -> { HttpURLConnection conn = null; try { URL url = new URL(urlString); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setConnectTimeout(10_000); conn.setReadTimeout(15_000); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setRequestProperty("Accept", "application/json"); conn.setDoOutput(true); // enables writing a request body // Write body byte[] bodyBytes = jsonBody.getBytes(StandardCharsets.UTF_8); conn.getOutputStream().write(bodyBytes); conn.getOutputStream().flush(); int status = conn.getResponseCode(); String result = (status >= 200 && status < 300) ? readStream(conn.getInputStream()) : readStream(conn.getErrorStream()); final String finalResult = result; new Handler(Looper.getMainLooper()).post(() -> callback.onSuccess(finalResult)); } catch (IOException e) { new Handler(Looper.getMainLooper()).post(() -> callback.onError(e.getMessage())); } finally { if (conn != null) conn.disconnect(); } }).start(); }
Set Content-Type before calling getOutputStream(). Once you start writing the body, HttpURLConnection locks in the request headers. Setting headers after this point silently has no effect.

Reading the Response Code and Handling Errors

HTTP status codes tell you the semantic outcome of a request. Your code should handle at minimum three families:

  • 2xx Success — read getInputStream() for the body.
  • 4xx Client Error (404 Not Found, 401 Unauthorized, 422 Unprocessable) — your request was wrong; read getErrorStream() for the API message and surface it to the developer or user.
  • 5xx Server Error — server is broken; retry with exponential back-off, or degrade gracefully.
HTTP_OK is not the only success code. 201 Created is the standard response to a successful POST that creates a resource. Always check for the full 2xx range (status >= 200 && status < 300) rather than hard-coding == 200.

Delivering Results Back to the UI

The network call runs on a background thread, but all view updates — setting text, showing a progress bar, navigating — must happen on the main thread. The canonical way to hop back is new Handler(Looper.getMainLooper()).post(runnable). If you are inside an Activity, you can use the equivalent shortcut runOnUiThread(runnable).

// Inside an Activity fetchGet("https://api.example.com/users/1", new NetworkHelper.Callback() { @Override public void onSuccess(String body) { // Running on the main thread — safe to update UI textView.setText(body); } @Override public void onError(String message) { Toast.makeText(MainActivity.this, "Error: " + message, Toast.LENGTH_LONG).show(); } });

HTTPS and Cleartext Traffic

From Android 9 (API 28) onward, cleartext (plain HTTP) traffic is blocked by default. Your app must use HTTPS for all production URLs. During development you can temporarily allow cleartext to a specific debug host by adding a network_security_config.xml, but never ship that configuration to production.

Summary

Every Android HTTP call lives on a background thread — NetworkOnMainThreadException is non-negotiable. HttpURLConnection gives you precise control over the request method, headers, timeouts, and body. The core loop is: open, configure, call getResponseCode(), read the appropriate stream, disconnect in a finally block, and dispatch the result back to the main thread via a Handler. Once you are comfortable with this plumbing, the next lesson examines Retrofit, which automates this boilerplate while preserving all the same underlying concepts.